use crate::browser::ProxySettings;
use crate::events;
use crate::group_manager::GROUP_MANAGER;
use crate::profile::manager::ProfileManager;
use crate::proxy_manager::PROXY_MANAGER;
use crate::tag_manager::TAG_MANAGER;
use axum::{
  extract::{
    ws::{Message as WsMessage, WebSocket, WebSocketUpgrade},
    DefaultBodyLimit, Path, Query, State,
  },
  http::{header, HeaderMap, Method, StatusCode},
  middleware::{self, Next},
  response::{IntoResponse, Json, Response},
  routing::get,
  Router,
};
use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::{mpsc, Mutex};
use tower_http::cors::CorsLayer;
use utoipa::{OpenApi, ToSchema};
use utoipa_axum::{router::OpenApiRouter, routes};

// API Types
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct ApiProfile {
  pub id: String,
  pub name: String,
  pub browser: String,
  pub version: String,
  pub proxy_id: Option<String>,
  pub launch_hook: Option<String>,
  pub process_id: Option<u32>,
  pub last_launch: Option<u64>,
  pub release_type: String,
  pub group_id: Option<String>,
  pub tags: Vec<String>,
  pub is_running: bool,
  pub proxy_bypass_rules: Vec<String>,
  pub vpn_id: Option<String>,
  /// Extension group loaded into the browser at launch. Settable via
  /// `PUT /v1/profiles/{id}`; exposed here so a caller can read back what it
  /// set instead of having to go through the desktop app.
  pub extension_group_id: Option<String>,
  pub clear_on_close: bool,
  /// Cloud sync mode: `"Disabled"`, `"Regular"` or `"Encrypted"`.
  /// Settable via `PUT /v1/profiles/{id}`; exposed here so a caller can read
  /// back what it set, and so a remote-launch caller can tell whether the
  /// profile is actually available in cloud storage.
  pub sync_mode: String,
  /// Convenience form of `sync_mode` — true for Regular or Encrypted.
  pub cloud_sync_enabled: bool,
  /// OS the profile was created on (`"macos"`, `"windows"`, `"linux"`).
  /// `null` when neither `host_os` nor the browser config records one.
  pub host_os: Option<String>,
  /// True when the profile belongs to a different OS than this machine.
  /// Such a profile cannot be launched locally, and must only ever run on a
  /// remote host of its own OS — Chromium profile state is OS-specific.
  pub is_cross_os: bool,
  /// The fingerprint operating system set at creation via `wayfern_config.os`
  /// (`"windows"`, `"macos"`, `"linux"`, `"android"` or `"ios"`), or `null`
  /// when the fingerprint was generated for the host. This is what the browser
  /// reports to sites; `host_os` is the machine the profile was created on and
  /// is a different thing.
  pub fingerprint_os: Option<String>,
}

impl From<&crate::profile::types::BrowserProfile> for ApiProfile {
  /// Single conversion for every profile-returning route. Previously open-coded
  /// at three call sites, which is how `sync_mode` came to be settable but not
  /// readable: a field added to the struct had to be remembered three times.
  fn from(profile: &crate::profile::types::BrowserProfile) -> Self {
    Self {
      id: profile.id.to_string(),
      name: profile.name.clone(),
      browser: profile.browser.clone(),
      version: profile.version.clone(),
      proxy_id: profile.proxy_id.clone(),
      launch_hook: profile.launch_hook.clone(),
      process_id: profile.process_id,
      last_launch: profile.last_launch,
      release_type: profile.release_type.clone(),
      group_id: profile.group_id.clone(),
      tags: profile.tags.clone(),
      is_running: profile.process_id.is_some(),
      proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
      vpn_id: profile.vpn_id.clone(),
      extension_group_id: profile.extension_group_id.clone(),
      clear_on_close: profile.clear_on_close,
      sync_mode: format!("{:?}", profile.sync_mode),
      cloud_sync_enabled: profile.is_sync_enabled(),
      host_os: profile.resolved_os().map(|os| os.to_string()),
      is_cross_os: profile.is_cross_os(),
      fingerprint_os: profile.wayfern_config.as_ref().and_then(|c| c.os.clone()),
    }
  }
}

#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ApiProfilesResponse {
  pub profiles: Vec<ApiProfile>,
  pub total: usize,
}

#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ApiProfileResponse {
  pub profile: ApiProfile,
}

#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CreateProfileRequest {
  pub name: String,
  /// Browser engine. Must be `"wayfern"` (anti-detect Chromium). Any other
  /// value (e.g. `"chromium"`) is rejected with 400.
  pub browser: String,
  /// Optional. Omit (or pass `"latest"`) to use the newest already-downloaded
  /// version of the chosen browser. A concrete version must already be
  /// downloaded; the create path does not fetch new versions.
  #[serde(default)]
  pub version: Option<String>,
  pub proxy_id: Option<String>,
  pub vpn_id: Option<String>,
  pub launch_hook: Option<String>,
  pub release_type: Option<String>,
  /// Wayfern fingerprint/config. Send only when `browser` is `"wayfern"`.
  /// Omit it, or pass an empty object `{}`, to have a fresh fingerprint
  /// generated automatically at creation. Provide a `fingerprint` field to
  /// pin a specific one.
  #[schema(value_type = Option<Object>)]
  pub wayfern_config: Option<serde_json::Value>,
  pub group_id: Option<String>,
  pub tags: Option<Vec<String>>,
}

#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateProfileRequest {
  pub name: Option<String>,
  // No `browser` field: a profile's engine is fixed at creation (changing it
  // would invalidate the generated fingerprint and on-disk profile dir).
  // Accepting it here only to silently ignore it misled API clients.
  pub version: Option<String>,
  pub proxy_id: Option<String>,
  pub vpn_id: Option<String>,
  pub launch_hook: Option<String>,
  pub release_type: Option<String>,
  pub group_id: Option<String>,
  pub tags: Option<Vec<String>>,
  pub extension_group_id: Option<String>,
  pub proxy_bypass_rules: Option<Vec<String>>,
  /// One of "Disabled", "Regular", "Encrypted".
  pub sync_mode: Option<String>,
  /// Wipe browsing data (keeping extensions and bookmarks) when the browser
  /// exits. Rejected (400) for ephemeral or password-protected profiles.
  pub clear_on_close: Option<bool>,
}

#[derive(Clone)]
struct ApiServerState {
  app_handle: tauri::AppHandle,
}

#[derive(Debug, Serialize, Deserialize, ToSchema)]
struct ApiGroupResponse {
  id: String,
  name: String,
  profile_count: usize,
}

#[derive(Debug, Deserialize, ToSchema)]
struct CreateGroupRequest {
  name: String,
}

#[derive(Debug, Deserialize, ToSchema)]
struct UpdateGroupRequest {
  name: String,
}

#[derive(Debug, Serialize, Deserialize, ToSchema)]
struct ApiProxyResponse {
  id: String,
  name: String,
  #[schema(value_type = Object)]
  proxy_settings: ProxySettings,
}

#[derive(Debug, Deserialize, ToSchema)]
struct CreateProxyRequest {
  name: String,
  #[schema(value_type = Object)]
  proxy_settings: ProxySettings,
}

#[derive(Debug, Deserialize, ToSchema)]
struct UpdateProxyRequest {
  name: Option<String>,
  #[schema(value_type = Option<Object>)]
  proxy_settings: Option<ProxySettings>,
}

#[derive(Debug, Serialize, Deserialize, ToSchema)]
struct ApiVpnResponse {
  id: String,
  name: String,
  /// Always "WireGuard"
  vpn_type: String,
  created_at: i64,
  last_used: Option<i64>,
}

#[derive(Debug, Serialize, ToSchema)]
struct ApiVpnExportResponse {
  id: String,
  name: String,
  /// Always "WireGuard"
  vpn_type: String,
  /// Raw `.conf` file content (decrypted)
  config_data: String,
}

#[derive(Debug, Deserialize, ToSchema)]
struct ImportVpnRequest {
  /// Raw WireGuard `.conf` file content
  content: String,
  /// Original filename
  filename: String,
  /// Optional display name; defaults to filename-based name
  name: Option<String>,
}

#[derive(Debug, Deserialize, ToSchema)]
struct CreateVpnRequest {
  name: String,
  /// Must be "WireGuard"
  vpn_type: String,
  config_data: String,
}

#[derive(Debug, Deserialize, ToSchema)]
struct UpdateVpnRequest {
  name: String,
}

#[derive(Debug, Deserialize, ToSchema)]
struct DownloadBrowserRequest {
  browser: String,
  version: String,
}

#[derive(Debug, Serialize, ToSchema)]
struct DownloadBrowserResponse {
  browser: String,
  version: String,
  status: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ToastPayload {
  pub message: String,
  pub variant: String,
  pub title: String,
  pub description: Option<String>,
}

#[derive(Debug, Serialize, ToSchema)]
struct RunProfileResponse {
  profile_id: String,
  remote_debugging_port: u16,
  headless: bool,
}

#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RunRemoteRequest {
  /// Optional URL to open once the remote browser is up.
  pub url: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SetCloudSyncRequest {
  /// `Disabled`, `Regular`, or `Encrypted`.
  ///
  /// `Encrypted` derives its key from a passphrase that never leaves this
  /// machine, so a profile in that mode can be synced but NOT run remotely —
  /// a remote host would download ciphertext it cannot decrypt.
  pub mode: String,
}

#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SetCloudSyncResponse {
  pub profile_id: String,
  pub mode: String,
  /// Whether the profile can now be launched on a remote host.
  pub remote_launchable: bool,
  /// Why not, when `remote_launchable` is false.
  pub remote_blocked_reason: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RunRemoteResponse {
  pub profile_id: String,
  /// Remote session id, for polling or closing the session.
  pub session_id: String,
  /// Operating system the session was scheduled onto — always the profile's own.
  pub platform: String,
  pub status: String,
}

#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct StopRemoteResponse {
  pub session_id: String,
  pub status: String,
  /// What the session actually cost, in seconds.
  pub billed_seconds: u64,
}

/// Every remote session the signed-in account currently owns.
///
/// `run-remote` hands back a session id and the literal string `provisioning`;
/// without a way to read the real state back, an automation client can only
/// discover that a session became usable by trying to drive it.
#[derive(Debug, Serialize, ToSchema)]
struct ApiRemoteSessionsResponse {
  sessions: Vec<crate::remote_session::RemoteSessionState>,
}

/// Enrol a profile in the nightly cookie bot, or replace its enrolment.
///
/// `platform` and `profile_name` are optional because this machine already
/// knows both: the platform is the profile's own operating system, and a
/// caller-supplied one that disagrees is a mistake, not a choice.
#[derive(Debug, Deserialize, ToSchema)]
struct SetCookieBotScheduleRequest {
  /// Defaults to the profile's local name.
  profile_name: Option<String>,
  /// `windows` or `macos`. Defaults to the profile's own operating system, and
  /// must match it when supplied.
  platform: Option<String>,
  /// Whether the nightly run is armed. A disabled schedule keeps its settings.
  enabled: bool,
  /// Minutes past local midnight the run is anchored to (0..1439).
  run_at_minute: u16,
  /// Bitmask of local weekdays, bit 0 = Monday (1..127).
  days_mask: u8,
  /// IANA zone the run time is expressed in.
  timezone: String,
  /// Server-issued preset id from `GET /v1/cookie-bot/presets`.
  preset: String,
  /// Upper bound on one run, in minutes.
  max_minutes: u32,
  /// Absolute http(s) URLs to browse. The bot visits only these.
  #[serde(default)]
  sites: Vec<String>,
  /// Random spread around the anchor time, in seconds.
  jitter_seconds: Option<u32>,
  /// Write anyway when a teammate already enrols this profile. Without it, a
  /// colliding write is refused with 409 and the teammate's details.
  #[serde(default)]
  acknowledge_conflict: bool,
}

#[derive(Debug, Deserialize, ToSchema)]
struct StartCookieBotRunRequest {
  /// Profile to warm. It must already have a schedule: the preset and the site
  /// list live there, so a run never carries a behaviour of its own.
  profile_id: String,
  /// Overrides the schedule's own cap for this run only.
  max_minutes: Option<u32>,
}

#[derive(Debug, Deserialize)]
struct CookieBotScopeQuery {
  scope: Option<String>,
}

#[derive(Debug, Deserialize)]
struct CookieBotConflictsQuery {
  profile_id: String,
  run_at_minute: Option<u16>,
  timezone: Option<String>,
  days_mask: Option<u8>,
}

#[derive(Debug, Deserialize)]
struct CookieBotRunsQuery {
  profile_id: Option<String>,
  scope: Option<String>,
  limit: Option<u32>,
  before: Option<String>,
}

#[derive(Debug, Deserialize)]
struct CookieBotUsageQuery {
  period: Option<String>,
}

#[derive(Debug, Deserialize, ToSchema)]
struct RunProfileRequest {
  url: Option<String>,
  headless: Option<bool>,
}

#[derive(Debug, Deserialize, ToSchema)]
struct OpenUrlRequest {
  url: String,
}

#[derive(Debug, Deserialize, ToSchema)]
struct ImportCookiesRequest {
  /// Raw cookie file content. Format is auto-detected: a JSON array
  /// (Puppeteer / EditThisCookie style) or a Netscape `cookies.txt`.
  content: String,
}

#[derive(Debug, Serialize, ToSchema)]
struct ImportCookiesResponse {
  cookies_imported: usize,
  cookies_replaced: usize,
  errors: Vec<String>,
}

/// Add an extension from exactly one source: an uploaded payload
/// (`file_name` together with `file_data_base64`), or `source_path` on the
/// machine running Donut. Supplying both, or neither, is a 400.
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateExtensionRequest {
  /// Display name. Optional — the manifest's own name wins when it has one,
  /// and a blank name is only rejected when the manifest has none either.
  pub name: Option<String>,
  /// Name of the uploaded file. Its suffix picks the type: `.crx` or `.zip`.
  pub file_name: Option<String>,
  /// Payload bytes, standard base64. Only meaningful with `file_name`.
  pub file_data_base64: Option<String>,
  /// Path on this machine: a `.crx`/`.zip`, or an unpacked extension
  /// directory holding a top-level `manifest.json`.
  pub source_path: Option<String>,
  /// Load a `source_path` directory in place instead of copying it into the
  /// store, so edits to the folder apply on the next browser start. Directory
  /// sources only, and a linked extension never syncs.
  pub link: Option<bool>,
}

/// Replace an extension's payload, rename it, or both. Every field is
/// optional, but a request that carries neither a name nor a source has
/// nothing to do and is a 400.
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateExtensionRequest {
  /// New display name.
  pub name: Option<String>,
  /// Name of the replacement upload. Its suffix picks the type: `.crx` or `.zip`.
  pub file_name: Option<String>,
  /// Replacement payload bytes, standard base64. Only meaningful with `file_name`.
  pub file_data_base64: Option<String>,
  /// Path on this machine to re-import from: a `.crx`/`.zip`, or an unpacked
  /// extension directory.
  pub source_path: Option<String>,
  /// Load a `source_path` directory in place instead of copying it in.
  pub link: Option<bool>,
}

#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateExtensionGroupRequest {
  pub name: String,
}

#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateExtensionGroupRequest {
  /// New group name.
  pub name: Option<String>,
  /// Replaces the whole membership list. Omit to leave it untouched; use the
  /// `/extensions/{extension_id}` sub-routes to add or remove one member.
  pub extension_ids: Option<Vec<String>>,
}

#[derive(Debug, Deserialize, ToSchema)]
struct BatchRunRequest {
  /// Profile IDs to launch.
  profile_ids: Vec<String>,
  /// Optional URL to open in every launched profile.
  url: Option<String>,
  /// Launch headless. Defaults to false.
  headless: Option<bool>,
}

#[derive(Debug, Serialize, ToSchema)]
struct BatchRunResult {
  profile_id: String,
  /// Whether this profile launched successfully.
  ok: bool,
  /// Remote debugging port if launched, otherwise null.
  remote_debugging_port: Option<u16>,
  /// Failure reason if not launched, otherwise null.
  error: Option<String>,
}

#[derive(Debug, Serialize, ToSchema)]
struct BatchRunResponse {
  results: Vec<BatchRunResult>,
}

#[derive(Debug, Deserialize, ToSchema)]
struct BatchStopRequest {
  /// Profile IDs to stop.
  profile_ids: Vec<String>,
}

#[derive(Debug, Serialize, ToSchema)]
struct BatchStopResult {
  profile_id: String,
  /// Whether this profile was stopped successfully.
  ok: bool,
  /// Failure reason if not stopped, otherwise null.
  error: Option<String>,
}

#[derive(Debug, Serialize, ToSchema)]
struct BatchStopResponse {
  results: Vec<BatchStopResult>,
}

#[derive(Debug, Serialize, ToSchema)]
struct DetectedProfilesResponse {
  profiles: Vec<crate::profile_importer::DetectedProfile>,
  total: usize,
}

#[derive(Debug, Deserialize)]
struct DetectImportQuery {
  /// Optional folder to scan instead of the default browser locations.
  folder: Option<String>,
}

#[derive(Debug, Deserialize, ToSchema)]
struct ImportProfilesRequest {
  /// Profiles to import. Each item is isolated — one failure doesn't stop the rest.
  items: Vec<crate::profile_importer::ImportProfileItem>,
  /// Optional group to assign every imported profile to.
  group_id: Option<String>,
  /// How to handle an already-taken profile name: "skip" or "rename"
  /// (auto-suffix). Defaults to "rename".
  duplicate_strategy: Option<crate::profile_importer::DuplicateStrategy>,
  /// Wayfern fingerprint/config applied to every imported profile. Omit to
  /// have fresh fingerprints generated automatically.
  #[schema(value_type = Option<Object>)]
  wayfern_config: Option<serde_json::Value>,
}

#[derive(Debug, Deserialize, ToSchema)]
struct ImportProxiesRequest {
  /// "txt" — one proxy per line (`host:port`, `host:port:user:pass`, or URL
  /// forms like `http://user:pass@host:port`). "json" — a Donut proxy export.
  format: String,
  /// Raw proxy list / export content.
  content: String,
  /// Name prefix for txt imports; proxies are named "{prefix} Proxy {n}".
  name_prefix: Option<String>,
}

#[derive(Debug, Serialize, ToSchema)]
struct ImportProxiesResponse {
  imported_count: usize,
  skipped_count: usize,
  errors: Vec<String>,
  proxies: Vec<ApiProxyResponse>,
}

#[derive(OpenApi)]
#[openapi(
  paths(
    get_profiles,
    get_profile,
    create_profile,
    update_profile,
    delete_profile,
    run_profile,
    run_profile_remote,
    stop_remote_session,
    remote_session_cdp,
    list_remote_sessions_api,
    get_remote_session_api,
    get_remote_hours,
    set_profile_cloud_sync,
    list_cookie_bot_schedules,
    get_cookie_bot_schedule,
    set_cookie_bot_schedule,
    delete_cookie_bot_schedule,
    get_cookie_bot_conflicts,
    list_cookie_bot_runs,
    start_cookie_bot_run,
    cancel_cookie_bot_run,
    list_cookie_bot_presets,
    get_cookie_bot_usage,
    open_url_in_profile,
    kill_profile,
    batch_run_profiles,
    batch_stop_profiles,
    detect_import_profiles,
    import_profiles_api,
    import_profile_cookies,
    get_groups,
    get_group,
    create_group,
    update_group,
    delete_group,
    get_tags,
    get_proxies,
    get_proxy,
    create_proxy,
    import_proxies_api,
    update_proxy,
    delete_proxy,
    get_vpns,
    get_vpn,
    export_vpn,
    import_vpn,
    create_vpn,
    update_vpn,
    delete_vpn,
    get_extensions,
    create_extension_api,
    get_extension_api,
    update_extension_api,
    delete_extension_api,
    get_extension_groups,
    create_extension_group_api,
    get_extension_group_api,
    update_extension_group_api,
    delete_extension_group_api,
    add_extension_to_group_api,
    remove_extension_from_group_api,
    download_browser_api,
    get_browser_versions,
    check_browser_downloaded,
  ),
  components(schemas(
    ApiProfile,
    ApiProfilesResponse,
    ApiProfileResponse,
    CreateProfileRequest,
    UpdateProfileRequest,
    ApiGroupResponse,
    CreateGroupRequest,
    UpdateGroupRequest,
    ApiProxyResponse,
    CreateProxyRequest,
    UpdateProxyRequest,
    ApiVpnResponse,
    ApiVpnExportResponse,
    ImportVpnRequest,
    CreateVpnRequest,
    UpdateVpnRequest,
    DownloadBrowserRequest,
    DownloadBrowserResponse,
    RunProfileResponse,
    RunRemoteRequest,
    RunRemoteResponse,
    StopRemoteResponse,
    SetCloudSyncRequest,
    SetCloudSyncResponse,
    ApiRemoteSessionsResponse,
    SetCookieBotScheduleRequest,
    StartCookieBotRunRequest,
    crate::remote_session::RemoteSessionState,
    crate::cookie_bot::CookieBotSchedule,
    crate::cookie_bot::CookieBotScheduleList,
    crate::cookie_bot::CookieBotScheduleSaved,
    crate::cookie_bot::CookieBotScheduleDeleted,
    crate::cookie_bot::CookieBotConflict,
    crate::cookie_bot::CookieBotConflictCheck,
    crate::cookie_bot::CookieBotRun,
    crate::cookie_bot::CookieBotRunPage,
    crate::cookie_bot::CookieBotRunStarted,
    crate::cookie_bot::CookieBotPreset,
    crate::cookie_bot::CookieBotPresetList,
    crate::cookie_bot::CookieBotUsage,
    crate::cookie_bot::CookieBotUsageMember,
    crate::cookie_bot::CookieBotUsageProfile,
    crate::cookie_bot::RemoteHoursQuota,
    crate::cookie_bot::RemoteHoursMember,
    crate::cookie_bot::RemoteHoursBreakdown,
    RunProfileRequest,
    BatchRunRequest,
    BatchRunResult,
    BatchRunResponse,
    BatchStopRequest,
    BatchStopResult,
    BatchStopResponse,
    OpenUrlRequest,
    ImportCookiesRequest,
    ImportCookiesResponse,
    CreateExtensionRequest,
    UpdateExtensionRequest,
    CreateExtensionGroupRequest,
    UpdateExtensionGroupRequest,
    crate::extension_manager::Extension,
    crate::extension_manager::ExtensionGroup,
    ProxySettings,
    DetectedProfilesResponse,
    ImportProfilesRequest,
    ImportProxiesRequest,
    ImportProxiesResponse,
    crate::profile_importer::DetectedProfile,
    crate::profile_importer::ImportProfileItem,
    crate::profile_importer::DuplicateStrategy,
    crate::profile_importer::ProfileImportItemResult,
    crate::profile_importer::ProfileImportBatchResult,
    crate::profile_import::report::ProfileImportReport,
  )),
  tags(
    (name = "profiles", description = "Profile management endpoints"),
    (name = "groups", description = "Group management endpoints"),
    (name = "tags", description = "Tag management endpoints"),
    (name = "proxies", description = "Proxy management endpoints"),
    (name = "vpns", description = "VPN management endpoints"),
    (name = "extensions", description = "Extension management endpoints"),
    (name = "browsers", description = "Browser management endpoints"),
    (name = "cookies", description = "Cookie management endpoints"),
    (name = "remote-sessions", description = "Sessions running on the leased remote fleet"),
    (name = "cookie-bot", description = "Scheduled cookie-warming runs on the remote fleet"),
  ),
  modifiers(&SecurityAddon),
)]
struct ApiDoc;

struct SecurityAddon;

impl utoipa::Modify for SecurityAddon {
  fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
    if let Some(components) = openapi.components.as_mut() {
      components.add_security_scheme(
        "bearer_auth",
        utoipa::openapi::security::SecurityScheme::Http(
          utoipa::openapi::security::HttpBuilder::new()
            .scheme(utoipa::openapi::security::HttpAuthScheme::Bearer)
            .bearer_format("JWT")
            .build(),
        ),
      );
    }
  }
}

pub struct ApiServer {
  port: Option<u16>,
  shutdown_tx: Option<mpsc::Sender<()>>,
  task_handle: Option<tokio::task::JoinHandle<()>>,
}

impl ApiServer {
  fn new() -> Self {
    Self {
      port: None,
      shutdown_tx: None,
      task_handle: None,
    }
  }

  fn get_port(&self) -> Option<u16> {
    self.port
  }

  async fn start(
    &mut self,
    app_handle: tauri::AppHandle,
    preferred_port: u16,
  ) -> Result<u16, String> {
    // Stop existing server if running
    self.stop().await.ok();

    let (shutdown_tx, mut shutdown_rx) = mpsc::channel(1);
    let state = ApiServerState {
      app_handle: app_handle.clone(),
    };

    // Try preferred port first, then random port
    let listener = match TcpListener::bind(format!("127.0.0.1:{preferred_port}")).await {
      Ok(listener) => listener,
      Err(_) => {
        // Port conflict, try random port
        let random_port = rand::random::<u16>().saturating_add(10000);
        match TcpListener::bind(format!("127.0.0.1:{random_port}")).await {
          Ok(listener) => {
            let _ = events::emit(
              "api-port-conflict",
              format!("API server using fallback port {random_port}"),
            );
            listener
          }
          Err(e) => {
            return Err(crate::backend_error_with_detail("API_PORT_UNAVAILABLE", e));
          }
        }
      }
    };

    let actual_port = listener
      .local_addr()
      .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?
      .port();

    let v1_routes = build_v1_router();

    let api = ApiDoc::openapi();

    let v1_routes = v1_routes
      // Innermost so only authenticated automation requests consume quota.
      .layer(middleware::from_fn(rate_limit_middleware))
      .layer(middleware::from_fn_with_state(
        state.clone(),
        auth_middleware,
      ))
      .layer(middleware::from_fn(terms_check_middleware));

    let api_for_v1 = api.clone();
    let app = Router::new()
      .merge(v1_routes)
      .route("/openapi.json", get(move || async move { Json(api) }))
      .route(
        "/v1/openapi.json",
        get(move || async move { Json(api_for_v1) }),
      )
      // Outermost layer: logs every request so customer reports show what
      // their automation is actually calling, what the response status was,
      // and how long it took. Never logs request bodies or auth headers.
      .layer(middleware::from_fn(request_logging_middleware))
      .layer(CorsLayer::permissive())
      .with_state(state);

    // Start server task
    let task_handle = tokio::spawn(async move {
      let server = axum::serve(listener, app);
      tokio::select! {
        _ = server => {},
        _ = shutdown_rx.recv() => {},
      }
    });

    self.port = Some(actual_port);
    self.shutdown_tx = Some(shutdown_tx);
    self.task_handle = Some(task_handle);

    Ok(actual_port)
  }

  async fn stop(&mut self) -> Result<(), String> {
    if let Some(shutdown_tx) = self.shutdown_tx.take() {
      let _ = shutdown_tx.send(()).await;
    }

    if let Some(handle) = self.task_handle.take() {
      handle.abort();
    }

    self.port = None;
    Ok(())
  }
}

/// Register every `/v1` handler.
///
/// Pulled out of `start` so a test can build it. Axum panics when two handlers
/// claim the same path, and until this was callable the only thing that
/// exercised it was starting the real server — a conflict introduced here
/// would have shipped as an app that dies the moment the API is switched on.
///
/// The OpenAPI half of `split_for_parts` is discarded on purpose: the served
/// spec comes from the hand-maintained `ApiDoc`, which is why
/// `openapi_spec_covers_registered_routes` exists.
fn build_v1_router() -> Router<ApiServerState> {
  let (routes, _) = OpenApiRouter::new()
    .routes(routes!(get_profiles, create_profile))
    .routes(routes!(get_profile, update_profile, delete_profile))
    .routes(routes!(run_profile))
    .routes(routes!(run_profile_remote))
    // One `routes!` per PATH, not per handler: the GET and the DELETE share
    // `/v1/remote-sessions/{id}`, and registering them separately would have
    // the second overwrite the first.
    .routes(routes!(get_remote_session_api, stop_remote_session))
    .routes(routes!(remote_session_cdp))
    .routes(routes!(list_remote_sessions_api))
    .routes(routes!(get_remote_hours))
    .routes(routes!(set_profile_cloud_sync))
    .routes(routes!(list_cookie_bot_schedules))
    .routes(routes!(
      get_cookie_bot_schedule,
      set_cookie_bot_schedule,
      delete_cookie_bot_schedule
    ))
    .routes(routes!(get_cookie_bot_conflicts))
    .routes(routes!(list_cookie_bot_runs, start_cookie_bot_run))
    .routes(routes!(cancel_cookie_bot_run))
    .routes(routes!(list_cookie_bot_presets))
    .routes(routes!(get_cookie_bot_usage))
    .routes(routes!(open_url_in_profile))
    .routes(routes!(kill_profile))
    .routes(routes!(batch_run_profiles))
    .routes(routes!(batch_stop_profiles))
    .routes(routes!(detect_import_profiles))
    .routes(routes!(import_profiles_api))
    .routes(routes!(import_profile_cookies))
    .routes(routes!(get_groups, create_group))
    .routes(routes!(get_group, update_group, delete_group))
    .routes(routes!(get_tags))
    .routes(routes!(get_proxies, create_proxy))
    .routes(routes!(import_proxies_api))
    .routes(routes!(get_proxy, update_proxy, delete_proxy))
    .routes(routes!(get_vpns, create_vpn))
    .routes(routes!(import_vpn))
    .routes(routes!(export_vpn))
    .routes(routes!(get_vpn, update_vpn, delete_vpn))
    .routes(routes!(get_extension_groups, create_extension_group_api))
    .routes(routes!(
      get_extension_group_api,
      update_extension_group_api,
      delete_extension_group_api
    ))
    .routes(routes!(
      add_extension_to_group_api,
      remove_extension_from_group_api
    ))
    .routes(routes!(download_browser_api))
    .routes(routes!(get_browser_versions))
    .routes(routes!(check_browser_downloaded))
    .split_for_parts();

  // The two paths that carry an extension payload, kept apart so the raised
  // body limit reaches them and nothing else. Axum's 2 MiB default is smaller
  // than plenty of real `.crx` files, and a create that 413s before the
  // handler runs is indistinguishable from a broken endpoint. The GET and
  // DELETE on these paths ride along because a limit has to be attached per
  // path, and neither reads a body.
  let (extension_payload_routes, _) = OpenApiRouter::new()
    .routes(routes!(get_extensions, create_extension_api))
    .routes(routes!(
      get_extension_api,
      update_extension_api,
      delete_extension_api
    ))
    .split_for_parts();

  routes.merge(extension_payload_routes.layer(DefaultBodyLimit::max(64 * 1024 * 1024)))
}

// Terms and Conditions check middleware
async fn terms_check_middleware(
  request: axum::extract::Request,
  next: Next,
) -> Result<Response, StatusCode> {
  // Check if Wayfern terms have been accepted
  if !crate::wayfern_terms::WayfernTermsManager::instance().is_terms_accepted() {
    return Err(StatusCode::FORBIDDEN);
  }

  Ok(next.run(request).await)
}

// Authentication middleware
async fn auth_middleware(
  State(state): State<ApiServerState>,
  headers: HeaderMap,
  request: axum::extract::Request,
  next: Next,
) -> Result<Response, StatusCode> {
  let path = request.uri().path().to_string();

  // Get the Authorization header
  let auth_header = headers
    .get("Authorization")
    .and_then(|h| h.to_str().ok())
    .and_then(|h| h.strip_prefix("Bearer "));

  let token = match auth_header {
    Some(token) => token,
    None => {
      log::warn!("[api] Rejected {path}: missing Authorization header");
      return Err(StatusCode::UNAUTHORIZED);
    }
  };

  // Get the stored token
  let settings_manager = crate::settings_manager::SettingsManager::instance();
  let stored_token = match settings_manager.get_api_token(&state.app_handle).await {
    Ok(Some(stored_token)) => stored_token,
    Ok(None) => {
      log::warn!(
        "[api] Rejected {path}: API server has no stored token (was the API toggled off?)"
      );
      return Err(StatusCode::UNAUTHORIZED);
    }
    Err(e) => {
      log::error!("[api] Failed to read stored API token: {e}");
      return Err(StatusCode::INTERNAL_SERVER_ERROR);
    }
  };

  // Constant-time comparison so the auth check doesn't leak the shared-prefix
  // length via timing. `ConstantTimeEq` on equal-length byte slices; differing
  // lengths simply compare unequal.
  use subtle::ConstantTimeEq;
  let token_bytes = token.as_bytes();
  let stored_bytes = stored_token.as_bytes();
  let matches = token_bytes.len() == stored_bytes.len() && token_bytes.ct_eq(stored_bytes).into();
  if !matches {
    log::warn!("[api] Rejected {path}: token mismatch");
    return Err(StatusCode::UNAUTHORIZED);
  }

  // Token is valid, continue with the request
  Ok(next.run(request).await)
}

/// Logs every request: method, path, query, response status, duration.
/// Skips Authorization header and request bodies entirely.
async fn request_logging_middleware(request: axum::extract::Request, next: Next) -> Response {
  let method = request.method().clone();
  let path = request.uri().path().to_string();
  let query = request.uri().query().map(|q| q.to_string());
  let started = std::time::Instant::now();

  let response = next.run(request).await;

  let status = response.status();
  let elapsed_ms = started.elapsed().as_millis();

  let level = if status.is_server_error() {
    log::Level::Error
  } else if status.is_client_error() {
    log::Level::Warn
  } else {
    log::Level::Info
  };

  match query {
    Some(q) => log::log!(
      level,
      "[api] {method} {path}?{q} -> {status} ({elapsed_ms} ms)"
    ),
    None => log::log!(level, "[api] {method} {path} -> {status} ({elapsed_ms} ms)"),
  }

  response
}

fn is_automation_request(method: &Method, path: &str) -> bool {
  // Ending a remote session is the one automation action that is not a POST.
  // Its handler declares a 429, which could never fire while this function
  // returned early for every non-POST method.
  //
  // Cancelling a cookie-bot run joins it: both reach across to the fleet, and
  // treating one stop as metered and the other as free would be arbitrary.
  // Note that the desktop's own stop button goes through a Tauri command, not
  // this server, so a human can always stop a run the limiter has cut off.
  if method == Method::DELETE {
    let mut segments = match path
      .strip_prefix("/v1/remote-sessions/")
      .or_else(|| path.strip_prefix("/v1/cookie-bot/runs/"))
    {
      Some(rest) => rest.split('/'),
      None => return false,
    };
    return matches!((segments.next(), segments.next()), (Some(id), None) if !id.is_empty());
  }

  if method != Method::POST {
    return false;
  }

  // Starting a bot run leases a host for up to two hours and spends the
  // account's pooled remote-hour budget, which makes it the single most
  // expensive thing this API can be asked to do.
  //
  // Deliberately NOT here: the cookie-bot schedule writes (PUT and DELETE on
  // /v1/cookie-bot/schedules/{profile_id}). They are configuration — a small
  // row in donutbrowser-infra — and lease nothing. Metering them would 429 a
  // client enrolling a fleet of profiles at start-up, while the thing that
  // actually protects the hardware, the pooled hour budget, is enforced
  // server-side on every run whether or not it was scheduled from here.
  if matches!(
    path,
    "/v1/profiles/batch/run" | "/v1/profiles/batch/stop" | "/v1/cookie-bot/runs"
  ) {
    return true;
  }

  let Some(profile_action) = path.strip_prefix("/v1/profiles/") else {
    return false;
  };
  let mut segments = profile_action.split('/');
  matches!(
    (segments.next(), segments.next(), segments.next()),
    // `run-remote` is a separate segment from `run`, so it matched nothing here
    // and every remote launch bypassed the quota it declares a 429 for.
    (
      Some(_),
      Some("run" | "open-url" | "kill" | "run-remote"),
      None
    )
  )
}

async fn rate_limit_middleware(request: axum::extract::Request, next: Next) -> Response {
  if !is_automation_request(request.method(), request.uri().path()) {
    return next.run(request).await;
  }

  match crate::automation_rate_limiter::check_automation_rate_limit().await {
    crate::automation_rate_limiter::RateLimitOutcome::Limited { retry_after_secs } => {
      log::warn!(
        "[api] Rejected {}: automation rate limit exceeded; retry in {}s",
        request.uri().path(),
        retry_after_secs
      );
      (
        StatusCode::TOO_MANY_REQUESTS,
        [(header::RETRY_AFTER, retry_after_secs.to_string())],
        "automation request rate limit exceeded",
      )
        .into_response()
    }
    crate::automation_rate_limiter::RateLimitOutcome::Unlimited
    | crate::automation_rate_limiter::RateLimitOutcome::Allowed { .. } => next.run(request).await,
  }
}

// Global API server instance
lazy_static! {
  pub static ref API_SERVER: Arc<Mutex<ApiServer>> = Arc::new(Mutex::new(ApiServer::new()));
}

// Tauri commands
#[tauri::command]
pub async fn start_api_server_internal(
  port: u16,
  app_handle: &tauri::AppHandle,
) -> Result<u16, String> {
  let mut server_guard = API_SERVER.lock().await;
  server_guard.start(app_handle.clone(), port).await
}

#[tauri::command]
pub async fn stop_api_server() -> Result<(), String> {
  let mut server_guard = API_SERVER.lock().await;
  server_guard.stop().await
}

#[tauri::command]
pub async fn start_api_server(
  port: Option<u16>,
  app_handle: tauri::AppHandle,
) -> Result<u16, String> {
  let actual_port = port.unwrap_or(10108);
  start_api_server_internal(actual_port, &app_handle).await
}

#[tauri::command]
pub async fn get_api_server_status() -> Result<Option<u16>, String> {
  let server_guard = API_SERVER.lock().await;
  Ok(server_guard.get_port())
}

// API Handlers - Profiles
/// Maps a manager-layer error onto a consistent HTTP status: 404 for missing
/// entities, 400 for validation/duplicate/client-input errors, 500 for
/// everything else (IO and other internal failures). The error text passes
/// through as the response body so API clients get a diagnostic instead of a
/// bare status code. Matching is on message content because the managers
/// return plain strings (some are the JSON `{"code": ...}` strings shared
/// with the Tauri commands).
/// Codes meaning "this profile is held by someone else right now".
///
/// Kept as one list so the REST layer, which has no other way to tell a refusal
/// apart from a validation failure, cannot drift from the guards that produce
/// them. `PROFILE_REMOTE_SYNC_PENDING` in particular is temporary by nature: the
/// pull that clears it is already running.
const LAUNCH_CONFLICT_CODES: [&str; 5] = [
  "PROFILE_RUNNING",
  "PROFILE_RUNNING_REMOTELY",
  "PROFILE_REMOTE_SYNC_PENDING",
  "PROFILE_LOCKED_BY_MEMBER",
  "PROFILE_LOCKED_ELSEWHERE",
];

fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) {
  let msg = err.to_string();

  // Structured {"code": ...} errors from the shared managers classify exactly.
  if let Ok(value) = serde_json::from_str::<serde_json::Value>(&msg) {
    if let Some(code) = value.get("code").and_then(|c| c.as_str()) {
      let status = if code.ends_with("_NOT_FOUND") {
        StatusCode::NOT_FOUND
      } else if LAUNCH_CONFLICT_CODES.contains(&code) {
        // Someone or something else holds this profile: another team member, a
        // browser already open, or a remote session whose work has not been
        // pulled back yet. All of them are "try again later", not "your request
        // was malformed", and 400 would tell an automation client to give up.
        StatusCode::CONFLICT
      } else if code == "INTERNAL_ERROR" {
        StatusCode::INTERNAL_SERVER_ERROR
      } else if code == "PROFILE_LOCK_UNAVAILABLE" {
        // The lock service could not be reached. The launch is refused because
        // it cannot be proven safe, which is an upstream failure, not the
        // caller's fault.
        StatusCode::SERVICE_UNAVAILABLE
      } else if code.ends_with("_REQUIRES_PRO") || code.ends_with("_PAYMENT_REQUIRED") {
        // Paid-feature gates (FINGERPRINT_REQUIRES_PRO, PROXY_PAYMENT_REQUIRED).
        // Mapping them here lets the gate live in the shared manager instead of
        // being re-implemented in each handler to get the status right.
        StatusCode::PAYMENT_REQUIRED
      } else {
        // Validation-style codes (NAME_CANNOT_BE_EMPTY, GROUP_ALREADY_EXISTS,
        // WAYFERN_VERSION_NOT_AVAILABLE, ...).
        StatusCode::BAD_REQUEST
      };
      return (status, msg);
    }
  }

  // Plain-text manager messages: match the known phrases narrowly so raw
  // OS/serde/network error text (e.g. "invalid type: ..." from a corrupt
  // store) falls through to 500 instead of masquerading as a client error.
  let lower = msg.to_lowercase();
  let status = if lower.contains("not found") {
    StatusCode::NOT_FOUND
  } else if lower.contains("already exists")
    || lower.contains("cannot set both")
    || lower.contains("cannot edit")
    || lower.contains("cannot delete")
    || lower.contains("cannot open url")
    || lower.contains("invalid browser")
    || lower.contains("invalid profile id")
    || lower.contains("unsupported browser")
    || lower.contains("not supported on your platform")
    || lower.contains("is not downloaded")
    || lower.contains("terms and conditions")
    // Extension-group compatibility: a group holding a Firefox-only add-on,
    // or a browser that takes no extensions at all. Both are a caller pairing
    // two things that don't go together, not a fault of this machine.
    || lower.contains("is not compatible with")
    || lower.contains("not supported for browser")
  {
    StatusCode::BAD_REQUEST
  } else {
    StatusCode::INTERNAL_SERVER_ERROR
  };
  (status, msg)
}

/// Real per-group profile counts, computed from the profile list (the same
/// source of truth the GUI uses).
fn group_profile_counts() -> std::collections::HashMap<String, usize> {
  let mut counts = std::collections::HashMap::new();
  if let Ok(profiles) = ProfileManager::instance().list_profiles() {
    for profile in profiles {
      if let Some(group_id) = profile.group_id {
        *counts.entry(group_id).or_insert(0) += 1;
      }
    }
  }
  counts
}

#[utoipa::path(
  get,
  path = "/v1/profiles",
  responses(
    (status = 200, description = "List of all profiles", body = ApiProfilesResponse),
    (status = 401, description = "Unauthorized"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "profiles"
)]
async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
  let profile_manager = ProfileManager::instance();
  match profile_manager.list_profiles() {
    Ok(profiles) => {
      let api_profiles: Vec<ApiProfile> = profiles.iter().map(ApiProfile::from).collect();

      Ok(Json(ApiProfilesResponse {
        profiles: api_profiles,
        total: profiles.len(),
      }))
    }
    Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
  }
}

#[utoipa::path(
  get,
  path = "/v1/profiles/{id}",
  params(
    ("id" = String, Path, description = "Profile ID")
  ),
  responses(
    (status = 200, description = "Profile details", body = ApiProfileResponse),
    (status = 401, description = "Unauthorized"),
    (status = 404, description = "Profile not found"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "profiles"
)]
async fn get_profile(
  Path(id): Path<String>,
  State(_state): State<ApiServerState>,
) -> Result<Json<ApiProfileResponse>, StatusCode> {
  let profile_manager = ProfileManager::instance();
  match profile_manager.list_profiles() {
    Ok(profiles) => {
      if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == id) {
        Ok(Json(ApiProfileResponse {
          profile: ApiProfile::from(profile),
        }))
      } else {
        Err(StatusCode::NOT_FOUND)
      }
    }
    Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
  }
}

/// Create a profile.
///
/// - `browser` must be `"wayfern"`; any other value is rejected
///   with 400.
/// - `version` is optional: omit it or pass `"latest"` to use the newest
///   already-downloaded version of that browser. The version must be present
///   locally (this endpoint does not download new versions); 400 if none is.
/// - Omitting the matching `wayfern_config`, or passing an
///   empty object `{}`, generates a fresh fingerprint automatically.
/// - `wayfern_config.os` picks the fingerprint OS (`"windows"`, `"macos"`,
///   `"linux"`, `"android"`, `"ios"`). Omit it to match the host. Any other
///   OS is cross-OS spoofing and needs an active Pro plan; 402 otherwise.
///   A `wayfern_config` that fails to parse is a 400, never a silent default.
#[utoipa::path(
  post,
  path = "/v1/profiles",
  request_body = CreateProfileRequest,
  responses(
    (status = 200, description = "Profile created successfully", body = ApiProfileResponse),
    (status = 400, description = "Invalid browser, invalid wayfern_config, or no downloaded version available"),
    (status = 401, description = "Unauthorized"),
    (status = 402, description = "Selected proxy requires payment, or a cross-OS fingerprint requires Pro"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "profiles"
)]
async fn create_profile(
  State(state): State<ApiServerState>,
  Json(request): Json<CreateProfileRequest>,
) -> Result<Json<ApiProfileResponse>, (StatusCode, String)> {
  let profile_manager = ProfileManager::instance();

  // Only Wayfern profiles are launchable; the rest of the system
  // (fingerprint generation, launch, run) supports nothing else. Reject anything
  // else up front — otherwise the profile is created with no fingerprint and an
  // unrecognized browser, then crashes with a 500 on /run. Mirrors the MCP
  // create_profile validation.
  if request.browser != "wayfern" {
    return Err((
      StatusCode::BAD_REQUEST,
      format!(
        "Invalid browser \"{}\". Must be \"wayfern\" (anti-detect Chromium).",
        request.browser
      ),
    ));
  }

  // Resolve the version. Omitted, empty, or "latest" means "newest version
  // already downloaded for this browser". The create path generates the
  // fingerprint by launching that binary, so the version must be present
  // locally — we don't fetch new versions here. 400 if none is downloaded.
  let version = match request.version.as_deref() {
    Some(v) if !v.is_empty() && v != "latest" => v.to_string(),
    _ => {
      let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
      let mut versions = registry.get_downloaded_versions(&request.browser);
      // browsers is a HashMap, so keys are unordered — sort newest-first by
      // semver before taking the latest.
      versions.sort_by(|a, b| crate::api_client::compare_versions(b, a));
      match versions.into_iter().next() {
        Some(v) => v,
        None => {
          return Err((
            StatusCode::BAD_REQUEST,
            format!(
              "No downloaded version of \"{}\" is available. Download the browser in Donut Browser first — this endpoint does not download browsers.",
              request.browser
            ),
          ));
        }
      }
    }
  };

  // Parse wayfern config if provided. A malformed config is a 400, never a
  // silent fallback: swallowing it here produced a host-OS profile from a
  // request that explicitly asked for another OS, with a 200 and no diagnostic.
  let wayfern_config: Option<crate::wayfern_manager::WayfernConfig> = match &request.wayfern_config
  {
    Some(config) => Some(serde_json::from_value(config.clone()).map_err(|e| {
      (
        StatusCode::BAD_REQUEST,
        format!("Invalid wayfern_config: {e}"),
      )
    })?),
    None => None,
  };

  // Cross-OS fingerprints are a paid capability. The Tauri command, the
  // importer and MCP each check this; REST did not, so the restriction was
  // bypassable through this endpoint alone.
  if !crate::cloud_auth::CLOUD_AUTH
    .is_fingerprint_os_allowed(wayfern_config.as_ref().and_then(|c| c.os.as_deref()))
    .await
  {
    return Err((
      StatusCode::PAYMENT_REQUIRED,
      serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string(),
    ));
  }

  // Reject a dead/unreachable proxy or VPN before creating the profile. A 402
  // (expired proxy subscription) maps to 402; anything else is a 400.
  if let Err(err) =
    crate::validate_profile_network(request.proxy_id.as_deref(), request.vpn_id.as_deref()).await
  {
    return Err(if err.contains("PROXY_PAYMENT_REQUIRED") {
      (
        StatusCode::PAYMENT_REQUIRED,
        "The selected proxy requires an active subscription.".to_string(),
      )
    } else {
      (
        StatusCode::BAD_REQUEST,
        format!("Profile network validation failed: {err}"),
      )
    });
  }

  // Create profile using the async create_profile_with_group method
  match profile_manager
    .create_profile_with_group(
      &state.app_handle,
      &request.name,
      &request.browser,
      &version,
      request.release_type.as_deref().unwrap_or("stable"),
      request.proxy_id.clone(),
      request.vpn_id.clone(),
      wayfern_config,
      request.group_id.clone(),
      false,
      None,
      request.launch_hook.clone(),
    )
    .await
  {
    Ok(mut profile) => {
      // Apply tags if provided
      if let Some(tags) = &request.tags {
        if profile_manager
          .update_profile_tags(&state.app_handle, &profile.name, tags.clone())
          .is_err()
        {
          return Err((
            StatusCode::INTERNAL_SERVER_ERROR,
            "Profile created but failed to apply tags.".to_string(),
          ));
        }
        profile.tags = tags.clone();
      }

      // Update tag manager with new tags
      if let Ok(profiles) = profile_manager.list_profiles() {
        let _ = crate::tag_manager::TAG_MANAGER
          .lock()
          .map(|manager| manager.rebuild_from_profiles(&profiles));
      }

      Ok(Json(ApiProfileResponse {
        profile: ApiProfile::from(&profile),
      }))
    }
    Err(e) => Err((
      StatusCode::BAD_REQUEST,
      format!("Failed to create profile: {e}"),
    )),
  }
}

#[utoipa::path(
  put,
  path = "/v1/profiles/{id}",
  params(
    ("id" = String, Path, description = "Profile ID")
  ),
  request_body = UpdateProfileRequest,
  responses(
    (status = 200, description = "Profile updated successfully", body = ApiProfileResponse),
    (status = 400, description = "Bad request"),
    (status = 401, description = "Unauthorized"),
    (status = 404, description = "Profile not found"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "profiles"
)]
async fn update_profile(
  Path(id): Path<String>,
  State(state): State<ApiServerState>,
  Json(request): Json<UpdateProfileRequest>,
) -> Result<Json<ApiProfileResponse>, (StatusCode, String)> {
  let profile_manager = ProfileManager::instance();

  if request.proxy_id.as_deref().is_some_and(|s| !s.is_empty())
    && request.vpn_id.as_deref().is_some_and(|s| !s.is_empty())
  {
    return Err((
      StatusCode::BAD_REQUEST,
      "Cannot set both proxy_id and vpn_id".to_string(),
    ));
  }

  // Update profile fields
  if let Some(new_name) = request.name {
    if let Err(e) = profile_manager.rename_profile(&state.app_handle, &id, &new_name) {
      return Err(manager_error_response(e));
    }
  }

  if let Some(version) = request.version {
    if let Err(e) = profile_manager.update_profile_version(&state.app_handle, &id, &version) {
      return Err(manager_error_response(e));
    }
  }

  if let Some(proxy_id) = request.proxy_id {
    if let Err(e) = profile_manager
      .update_profile_proxy(state.app_handle.clone(), &id, Some(proxy_id))
      .await
    {
      return Err(manager_error_response(e));
    }
  }

  if let Some(vpn_id) = request.vpn_id {
    let normalized = if vpn_id.is_empty() {
      None
    } else {
      Some(vpn_id)
    };
    if let Err(e) = profile_manager
      .update_profile_vpn(state.app_handle.clone(), &id, normalized)
      .await
    {
      return Err(manager_error_response(e));
    }
  }

  if let Some(launch_hook) = request.launch_hook {
    let normalized = if launch_hook.trim().is_empty() {
      None
    } else {
      Some(launch_hook)
    };

    if let Err(e) = profile_manager.update_profile_launch_hook(&state.app_handle, &id, normalized) {
      return Err(manager_error_response(e));
    }
  }

  if let Some(group_id) = request.group_id {
    if let Err(e) =
      profile_manager.assign_profiles_to_group(&state.app_handle, vec![id.clone()], Some(group_id))
    {
      return Err(manager_error_response(e));
    }
  }

  if let Some(tags) = request.tags {
    if let Err(e) = profile_manager.update_profile_tags(&state.app_handle, &id, tags) {
      return Err(manager_error_response(e));
    }

    // Update tag manager with new tags from all profiles
    if let Ok(profiles) = profile_manager.list_profiles() {
      let _ = crate::tag_manager::TAG_MANAGER
        .lock()
        .map(|manager| manager.rebuild_from_profiles(&profiles));
    }
  }

  if let Some(extension_group_id) = request.extension_group_id {
    let ext_group = if extension_group_id.is_empty() {
      None
    } else {
      Some(extension_group_id)
    };
    // Assigning a group that does not exist, or one holding an extension this
    // profile's browser cannot load, fails at launch instead of here unless it
    // is checked now — which is what the Tauri and MCP paths already do.
    if let Some(group_id) = ext_group.as_deref() {
      let browser = {
        let profiles = profile_manager
          .list_profiles()
          .map_err(manager_error_response)?;
        profiles
          .iter()
          .find(|p| p.id.to_string() == id)
          .map(|p| p.browser.clone())
          .ok_or_else(|| {
            (
              StatusCode::NOT_FOUND,
              format!("Profile with ID '{id}' not found"),
            )
          })?
      };
      with_extension_manager(|mgr| mgr.validate_group_compatibility(group_id, &browser))?;
    }
    if let Err(e) = profile_manager.update_profile_extension_group(&id, ext_group) {
      return Err(manager_error_response(e));
    }
  }

  if let Some(proxy_bypass_rules) = request.proxy_bypass_rules {
    if let Err(e) =
      profile_manager.update_profile_proxy_bypass_rules(&state.app_handle, &id, proxy_bypass_rules)
    {
      return Err(manager_error_response(e));
    }
  }

  if let Some(sync_mode) = request.sync_mode {
    if let Err(e) =
      crate::sync::set_profile_sync_mode(state.app_handle.clone(), id.clone(), sync_mode).await
    {
      return Err(manager_error_response(e));
    }
  }

  if let Some(clear_on_close) = request.clear_on_close {
    if let Err(e) =
      profile_manager.update_profile_clear_on_close(&state.app_handle, &id, clear_on_close)
    {
      return Err(manager_error_response(e));
    }
  }

  // Return updated profile
  get_profile(Path(id), State(state))
    .await
    .map_err(|status| (status, String::new()))
}

#[utoipa::path(
  delete,
  path = "/v1/profiles/{id}",
  params(
    ("id" = String, Path, description = "Profile ID")
  ),
  responses(
    (status = 204, description = "Profile deleted successfully"),
    (status = 400, description = "Bad request"),
    (status = 401, description = "Unauthorized"),
    (status = 404, description = "Profile not found"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "profiles"
)]
async fn delete_profile(
  Path(id): Path<String>,
  State(state): State<ApiServerState>,
) -> Result<StatusCode, (StatusCode, String)> {
  let profile_manager = ProfileManager::instance();
  match profile_manager.delete_profile(&state.app_handle, &id) {
    Ok(_) => Ok(StatusCode::NO_CONTENT),
    Err(e) => Err(manager_error_response(e)),
  }
}

// API Handlers - Groups
#[utoipa::path(
  get,
  path = "/v1/groups",
  responses(
    (status = 200, description = "List of all groups", body = Vec<ApiGroupResponse>),
    (status = 401, description = "Unauthorized"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "groups"
)]
async fn get_groups(
  State(_state): State<ApiServerState>,
) -> Result<Json<Vec<ApiGroupResponse>>, StatusCode> {
  match GROUP_MANAGER.lock() {
    Ok(manager) => match manager.get_all_groups() {
      Ok(groups) => {
        let counts = group_profile_counts();
        let api_groups = groups
          .into_iter()
          .map(|group| ApiGroupResponse {
            profile_count: counts.get(&group.id).copied().unwrap_or(0),
            id: group.id,
            name: group.name,
          })
          .collect();
        Ok(Json(api_groups))
      }
      Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
    },
    Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
  }
}

#[utoipa::path(
  get,
  path = "/v1/groups/{id}",
  params(
    ("id" = String, Path, description = "Group ID")
  ),
  responses(
    (status = 200, description = "Group details", body = ApiGroupResponse),
    (status = 401, description = "Unauthorized"),
    (status = 404, description = "Group not found"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "groups"
)]
async fn get_group(
  Path(id): Path<String>,
  State(_state): State<ApiServerState>,
) -> Result<Json<ApiGroupResponse>, StatusCode> {
  match GROUP_MANAGER.lock() {
    Ok(manager) => match manager.get_all_groups() {
      Ok(groups) => {
        if let Some(group) = groups.into_iter().find(|g| g.id == id) {
          Ok(Json(ApiGroupResponse {
            profile_count: group_profile_counts().get(&group.id).copied().unwrap_or(0),
            id: group.id,
            name: group.name,
          }))
        } else {
          Err(StatusCode::NOT_FOUND)
        }
      }
      Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
    },
    Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
  }
}

#[utoipa::path(
  post,
  path = "/v1/groups",
  request_body = CreateGroupRequest,
  responses(
    (status = 200, description = "Group created successfully", body = ApiGroupResponse),
    (status = 400, description = "Bad request"),
    (status = 401, description = "Unauthorized"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "groups"
)]
async fn create_group(
  State(state): State<ApiServerState>,
  Json(request): Json<CreateGroupRequest>,
) -> Result<Json<ApiGroupResponse>, (StatusCode, String)> {
  match GROUP_MANAGER.lock() {
    Ok(manager) => match manager.create_group(&state.app_handle, request.name) {
      Ok(group) => Ok(Json(ApiGroupResponse {
        id: group.id,
        name: group.name,
        profile_count: 0,
      })),
      Err(e) => Err(manager_error_response(e)),
    },
    Err(_) => Err((
      StatusCode::INTERNAL_SERVER_ERROR,
      "group manager unavailable".to_string(),
    )),
  }
}

#[utoipa::path(
  put,
  path = "/v1/groups/{id}",
  params(
    ("id" = String, Path, description = "Group ID")
  ),
  request_body = UpdateGroupRequest,
  responses(
    (status = 200, description = "Group updated successfully", body = ApiGroupResponse),
    (status = 400, description = "Bad request"),
    (status = 401, description = "Unauthorized"),
    (status = 404, description = "Group not found"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "groups"
)]
async fn update_group(
  Path(id): Path<String>,
  State(state): State<ApiServerState>,
  Json(request): Json<UpdateGroupRequest>,
) -> Result<Json<ApiGroupResponse>, (StatusCode, String)> {
  match GROUP_MANAGER.lock() {
    Ok(manager) => match manager.update_group(&state.app_handle, id.clone(), request.name) {
      Ok(group) => Ok(Json(ApiGroupResponse {
        profile_count: group_profile_counts().get(&group.id).copied().unwrap_or(0),
        id: group.id,
        name: group.name,
      })),
      Err(e) => Err(manager_error_response(e)),
    },
    Err(_) => Err((
      StatusCode::INTERNAL_SERVER_ERROR,
      "group manager unavailable".to_string(),
    )),
  }
}

#[utoipa::path(
  delete,
  path = "/v1/groups/{id}",
  params(
    ("id" = String, Path, description = "Group ID")
  ),
  responses(
    (status = 204, description = "Group deleted successfully"),
    (status = 401, description = "Unauthorized"),
    (status = 404, description = "Group not found"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "groups"
)]
async fn delete_group(
  Path(id): Path<String>,
  State(state): State<ApiServerState>,
) -> Result<StatusCode, (StatusCode, String)> {
  match GROUP_MANAGER.lock() {
    Ok(manager) => match manager.delete_group(&state.app_handle, id.clone()) {
      Ok(_) => Ok(StatusCode::NO_CONTENT),
      Err(e) => Err(manager_error_response(e)),
    },
    Err(_) => Err((
      StatusCode::INTERNAL_SERVER_ERROR,
      "group manager unavailable".to_string(),
    )),
  }
}

// API Handlers - Tags
#[utoipa::path(
  get,
  path = "/v1/tags",
  responses(
    (status = 200, description = "List of all tags", body = Vec<String>),
    (status = 401, description = "Unauthorized"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "tags"
)]
async fn get_tags(State(_state): State<ApiServerState>) -> Result<Json<Vec<String>>, StatusCode> {
  match TAG_MANAGER.lock() {
    Ok(manager) => match manager.get_all_tags() {
      Ok(tags) => Ok(Json(tags)),
      Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
    },
    Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
  }
}

// API Handlers - Proxies
#[utoipa::path(
  get,
  path = "/v1/proxies",
  responses(
    (status = 200, description = "List of all proxies", body = Vec<ApiProxyResponse>),
    (status = 401, description = "Unauthorized"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "proxies"
)]
async fn get_proxies(
  State(_state): State<ApiServerState>,
) -> Result<Json<Vec<ApiProxyResponse>>, StatusCode> {
  let proxies = PROXY_MANAGER.get_stored_proxies();
  Ok(Json(
    proxies
      .into_iter()
      .map(|p| ApiProxyResponse {
        id: p.id,
        name: p.name,
        proxy_settings: p.proxy_settings,
      })
      .collect(),
  ))
}

#[utoipa::path(
  get,
  path = "/v1/proxies/{id}",
  params(
    ("id" = String, Path, description = "Proxy ID")
  ),
  responses(
    (status = 200, description = "Proxy details", body = ApiProxyResponse),
    (status = 401, description = "Unauthorized"),
    (status = 404, description = "Proxy not found"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "proxies"
)]
async fn get_proxy(
  Path(id): Path<String>,
  State(_state): State<ApiServerState>,
) -> Result<Json<ApiProxyResponse>, StatusCode> {
  let proxies = PROXY_MANAGER.get_stored_proxies();
  if let Some(proxy) = proxies.into_iter().find(|p| p.id == id) {
    Ok(Json(ApiProxyResponse {
      id: proxy.id,
      name: proxy.name,
      proxy_settings: proxy.proxy_settings,
    }))
  } else {
    Err(StatusCode::NOT_FOUND)
  }
}

#[utoipa::path(
  post,
  path = "/v1/proxies",
  request_body = CreateProxyRequest,
  responses(
    (status = 200, description = "Proxy created successfully", body = ApiProxyResponse),
    (status = 400, description = "Bad request"),
    (status = 401, description = "Unauthorized"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "proxies"
)]
async fn create_proxy(
  State(state): State<ApiServerState>,
  Json(request): Json<CreateProxyRequest>,
) -> Result<Json<ApiProxyResponse>, (StatusCode, String)> {
  let result = PROXY_MANAGER.create_stored_proxy(
    &state.app_handle,
    request.name.clone(),
    request.proxy_settings,
  );

  match result {
    Ok(proxy) => Ok(Json(ApiProxyResponse {
      id: proxy.id,
      name: proxy.name,
      proxy_settings: proxy.proxy_settings,
    })),
    Err(e) => Err(manager_error_response(e)),
  }
}

// API Handler - Bulk-import proxies from a txt list or a Donut JSON export.
// Mirrors the MCP `import_proxies` tool.
#[utoipa::path(
  post,
  path = "/v1/proxies/import",
  request_body = ImportProxiesRequest,
  responses(
    (status = 200, description = "Import completed; inspect counts and per-proxy errors", body = ImportProxiesResponse),
    (status = 400, description = "Invalid format or no valid proxies in content"),
    (status = 401, description = "Unauthorized"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "proxies"
)]
async fn import_proxies_api(
  State(state): State<ApiServerState>,
  Json(request): Json<ImportProxiesRequest>,
) -> Result<Json<ImportProxiesResponse>, (StatusCode, String)> {
  let result = match request.format.as_str() {
    "json" => PROXY_MANAGER
      .import_proxies_json(&state.app_handle, &request.content)
      .map_err(manager_error_response)?,
    "txt" => {
      use crate::proxy_manager::{ProxyManager, ProxyParseResult};

      let parsed: Vec<_> = ProxyManager::parse_txt_proxies(&request.content)
        .into_iter()
        .filter_map(|r| match r {
          ProxyParseResult::Parsed(p) => Some(p),
          _ => None,
        })
        .collect();

      if parsed.is_empty() {
        return Err((
          StatusCode::BAD_REQUEST,
          "No valid proxies found in content".to_string(),
        ));
      }

      PROXY_MANAGER
        .import_proxies_from_parsed(&state.app_handle, parsed, request.name_prefix)
        .map_err(manager_error_response)?
    }
    other => {
      return Err((
        StatusCode::BAD_REQUEST,
        format!("Invalid format \"{other}\", must be \"json\" or \"txt\""),
      ))
    }
  };

  Ok(Json(ImportProxiesResponse {
    imported_count: result.imported_count,
    skipped_count: result.skipped_count,
    errors: result.errors,
    proxies: result
      .proxies
      .into_iter()
      .map(|p| ApiProxyResponse {
        id: p.id,
        name: p.name,
        proxy_settings: p.proxy_settings,
      })
      .collect(),
  }))
}

#[utoipa::path(
  put,
  path = "/v1/proxies/{id}",
  params(
    ("id" = String, Path, description = "Proxy ID")
  ),
  request_body = UpdateProxyRequest,
  responses(
    (status = 200, description = "Proxy updated successfully", body = ApiProxyResponse),
    (status = 400, description = "Bad request"),
    (status = 401, description = "Unauthorized"),
    (status = 404, description = "Proxy not found"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "proxies"
)]
async fn update_proxy(
  Path(id): Path<String>,
  State(state): State<ApiServerState>,
  Json(request): Json<UpdateProxyRequest>,
) -> Result<Json<ApiProxyResponse>, (StatusCode, String)> {
  let result =
    PROXY_MANAGER.update_stored_proxy(&state.app_handle, &id, request.name, request.proxy_settings);

  match result {
    Ok(proxy) => Ok(Json(ApiProxyResponse {
      id: proxy.id,
      name: proxy.name,
      proxy_settings: proxy.proxy_settings,
    })),
    Err(e) => Err(manager_error_response(e)),
  }
}

#[utoipa::path(
  delete,
  path = "/v1/proxies/{id}",
  params(
    ("id" = String, Path, description = "Proxy ID")
  ),
  responses(
    (status = 204, description = "Proxy deleted successfully"),
    (status = 400, description = "Bad request (e.g. cloud-managed proxy)"),
    (status = 401, description = "Unauthorized"),
    (status = 404, description = "Proxy not found"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "proxies"
)]
async fn delete_proxy(
  Path(id): Path<String>,
  State(state): State<ApiServerState>,
) -> Result<StatusCode, (StatusCode, String)> {
  match PROXY_MANAGER.delete_stored_proxy(&state.app_handle, &id) {
    Ok(_) => Ok(StatusCode::NO_CONTENT),
    Err(e) => Err(manager_error_response(e)),
  }
}

// API Handlers - VPNs

fn vpn_to_api_response(c: &crate::vpn::VpnConfig) -> ApiVpnResponse {
  ApiVpnResponse {
    id: c.id.clone(),
    name: c.name.clone(),
    vpn_type: c.vpn_type.to_string(),
    created_at: c.created_at,
    last_used: c.last_used,
  }
}

fn parse_vpn_type(s: &str) -> Option<crate::vpn::VpnType> {
  match s.to_ascii_lowercase().as_str() {
    "wireguard" | "wg" => Some(crate::vpn::VpnType::WireGuard),
    _ => None,
  }
}

#[utoipa::path(
  get,
  path = "/v1/vpns",
  responses(
    (status = 200, description = "List of all VPN configurations", body = Vec<ApiVpnResponse>),
    (status = 401, description = "Unauthorized"),
    (status = 500, description = "Internal server error")
  ),
  security(("bearer_auth" = [])),
  tag = "vpns"
)]
async fn get_vpns(
  State(_state): State<ApiServerState>,
) -> Result<Json<Vec<ApiVpnResponse>>, StatusCode> {
  let storage = crate::vpn::VPN_STORAGE
    .lock()
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
  let configs = storage
    .list_configs()
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
  Ok(Json(configs.iter().map(vpn_to_api_response).collect()))
}

#[utoipa::path(
  get,
  path = "/v1/vpns/{id}",
  params(("id" = String, Path, description = "VPN configuration ID")),
  responses(
    (status = 200, description = "VPN configuration details", body = ApiVpnResponse),
    (status = 401, description = "Unauthorized"),
    (status = 404, description = "VPN configuration not found"),
    (status = 500, description = "Internal server error")
  ),
  security(("bearer_auth" = [])),
  tag = "vpns"
)]
async fn get_vpn(
  Path(id): Path<String>,
  State(_state): State<ApiServerState>,
) -> Result<Json<ApiVpnResponse>, StatusCode> {
  let storage = crate::vpn::VPN_STORAGE
    .lock()
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
  let configs = storage
    .list_configs()
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
  configs
    .iter()
    .find(|c| c.id == id)
    .map(|c| Json(vpn_to_api_response(c)))
    .ok_or(StatusCode::NOT_FOUND)
}

#[utoipa::path(
  get,
  path = "/v1/vpns/{id}/export",
  params(("id" = String, Path, description = "VPN configuration ID")),
  responses(
    (status = 200, description = "Decrypted VPN configuration", body = ApiVpnExportResponse),
    (status = 401, description = "Unauthorized"),
    (status = 404, description = "VPN configuration not found"),
    (status = 500, description = "Internal server error")
  ),
  security(("bearer_auth" = [])),
  tag = "vpns"
)]
async fn export_vpn(
  Path(id): Path<String>,
  State(_state): State<ApiServerState>,
) -> Result<Json<ApiVpnExportResponse>, StatusCode> {
  let storage = crate::vpn::VPN_STORAGE
    .lock()
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
  match storage.load_config(&id) {
    Ok(config) => Ok(Json(ApiVpnExportResponse {
      id: config.id,
      name: config.name,
      vpn_type: config.vpn_type.to_string(),
      config_data: config.config_data,
    })),
    Err(_) => Err(StatusCode::NOT_FOUND),
  }
}

#[utoipa::path(
  post,
  path = "/v1/vpns/import",
  request_body = ImportVpnRequest,
  responses(
    (status = 200, description = "VPN configuration imported successfully", body = ApiVpnResponse),
    (status = 400, description = "Invalid or unrecognized VPN config"),
    (status = 401, description = "Unauthorized"),
    (status = 500, description = "Internal server error")
  ),
  security(("bearer_auth" = [])),
  tag = "vpns"
)]
async fn import_vpn(
  State(_state): State<ApiServerState>,
  Json(request): Json<ImportVpnRequest>,
) -> Result<Json<ApiVpnResponse>, StatusCode> {
  let result = {
    let storage = crate::vpn::VPN_STORAGE
      .lock()
      .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    storage.import_config(&request.content, &request.filename, request.name)
  };
  match result {
    Ok(config) => {
      let _ = events::emit("vpn-configs-changed", ());
      Ok(Json(vpn_to_api_response(&config)))
    }
    Err(_) => Err(StatusCode::BAD_REQUEST),
  }
}

#[utoipa::path(
  post,
  path = "/v1/vpns",
  request_body = CreateVpnRequest,
  responses(
    (status = 200, description = "VPN configuration created successfully", body = ApiVpnResponse),
    (status = 400, description = "Invalid VPN config or unknown vpn_type"),
    (status = 401, description = "Unauthorized"),
    (status = 500, description = "Internal server error")
  ),
  security(("bearer_auth" = [])),
  tag = "vpns"
)]
async fn create_vpn(
  State(_state): State<ApiServerState>,
  Json(request): Json<CreateVpnRequest>,
) -> Result<Json<ApiVpnResponse>, StatusCode> {
  let vpn_type = parse_vpn_type(&request.vpn_type).ok_or(StatusCode::BAD_REQUEST)?;
  let result = {
    let storage = crate::vpn::VPN_STORAGE
      .lock()
      .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    storage.create_config_manual(&request.name, vpn_type, &request.config_data)
  };
  match result {
    Ok(config) => {
      let _ = events::emit("vpn-configs-changed", ());
      Ok(Json(vpn_to_api_response(&config)))
    }
    Err(_) => Err(StatusCode::BAD_REQUEST),
  }
}

#[utoipa::path(
  put,
  path = "/v1/vpns/{id}",
  params(("id" = String, Path, description = "VPN configuration ID")),
  request_body = UpdateVpnRequest,
  responses(
    (status = 200, description = "VPN configuration updated successfully", body = ApiVpnResponse),
    (status = 400, description = "Bad request"),
    (status = 401, description = "Unauthorized"),
    (status = 404, description = "VPN configuration not found"),
    (status = 500, description = "Internal server error")
  ),
  security(("bearer_auth" = [])),
  tag = "vpns"
)]
async fn update_vpn(
  Path(id): Path<String>,
  State(_state): State<ApiServerState>,
  Json(request): Json<UpdateVpnRequest>,
) -> Result<Json<ApiVpnResponse>, StatusCode> {
  let result = {
    let storage = crate::vpn::VPN_STORAGE
      .lock()
      .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    storage.update_config_name(&id, &request.name)
  };
  match result {
    Ok(config) => {
      let _ = events::emit("vpn-configs-changed", ());
      Ok(Json(vpn_to_api_response(&config)))
    }
    Err(_) => Err(StatusCode::NOT_FOUND),
  }
}

#[utoipa::path(
  delete,
  path = "/v1/vpns/{id}",
  params(("id" = String, Path, description = "VPN configuration ID")),
  responses(
    (status = 204, description = "VPN configuration deleted successfully"),
    (status = 401, description = "Unauthorized"),
    (status = 404, description = "VPN configuration not found"),
    (status = 500, description = "Internal server error")
  ),
  security(("bearer_auth" = [])),
  tag = "vpns"
)]
async fn delete_vpn(
  Path(id): Path<String>,
  State(_state): State<ApiServerState>,
) -> Result<StatusCode, StatusCode> {
  let _ = crate::vpn_worker_runner::stop_vpn_worker_by_vpn_id(&id).await;

  let result = {
    let storage = crate::vpn::VPN_STORAGE
      .lock()
      .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    storage.delete_config(&id)
  };
  match result {
    Ok(_) => {
      let _ = events::emit("vpn-configs-changed", ());
      Ok(StatusCode::NO_CONTENT)
    }
    Err(_) => Err(StatusCode::NOT_FOUND),
  }
}

// Extension API endpoints

/// Take the extension store once, in one place, so a poisoned lock answers 500
/// instead of panicking the request thread, and every handler classifies the
/// manager's `{"code": ...}` errors identically.
fn with_extension_manager<T>(
  action: impl FnOnce(
    &crate::extension_manager::ExtensionManager,
  ) -> Result<T, Box<dyn std::error::Error>>,
) -> Result<T, (StatusCode, String)> {
  let manager = crate::extension_manager::EXTENSION_MANAGER
    .lock()
    .map_err(|_| {
      (
        StatusCode::INTERNAL_SERVER_ERROR,
        "extension manager unavailable".to_string(),
      )
    })?;
  action(&manager).map_err(manager_error_response)
}

/// The payload an extension write carries, once the request body has been
/// reduced to the single source it is allowed to name.
#[derive(Debug)]
enum ExtensionSource {
  Upload {
    file_name: String,
    data: Vec<u8>,
  },
  LocalPath {
    path: std::path::PathBuf,
    link: bool,
  },
}

fn extension_request_error(code: &str) -> (StatusCode, String) {
  (
    StatusCode::BAD_REQUEST,
    serde_json::json!({ "code": code }).to_string(),
  )
}

/// Reduce a create/update body to the one source it names. `Ok(None)` means it
/// named none, which only an update (a plain rename) may do.
fn resolve_extension_source(
  file_name: Option<String>,
  file_data_base64: Option<String>,
  source_path: Option<String>,
  link: Option<bool>,
) -> Result<Option<ExtensionSource>, (StatusCode, String)> {
  use base64::Engine as _;

  let upload = match (file_name, file_data_base64) {
    (Some(name), Some(encoded)) => Some((name, encoded)),
    (None, None) => None,
    // Half an upload is not a source: honouring it would mean storing an empty
    // payload, or inventing a file name and with it a file type.
    _ => return Err(extension_request_error("EXTENSION_SOURCE_REQUIRED")),
  };
  let path = source_path.filter(|p| !p.trim().is_empty());
  let link = link.unwrap_or(false);

  match (upload, path) {
    // Two sources is as unanswerable as none: there is no rule for which one
    // the caller meant, so neither is guessed at.
    (Some(_), Some(_)) => Err(extension_request_error("EXTENSION_SOURCE_REQUIRED")),
    (Some((file_name, encoded)), None) => {
      if link {
        // Linking means "keep loading the folder where it already is". An
        // uploaded archive has no folder on this machine to point at.
        return Err(extension_request_error("EXTENSION_LINK_REQUIRES_DIRECTORY"));
      }
      let data = base64::engine::general_purpose::STANDARD
        .decode(encoded)
        .map_err(|_| extension_request_error("EXTENSION_INVALID_BASE64"))?;
      Ok(Some(ExtensionSource::Upload { file_name, data }))
    }
    (None, Some(path)) => Ok(Some(ExtensionSource::LocalPath {
      path: std::path::PathBuf::from(path),
      link,
    })),
    (None, None) => Ok(None),
  }
}

#[utoipa::path(
  get,
  path = "/v1/extensions",
  responses(
    (status = 200, description = "List of extensions", body = Vec<crate::extension_manager::Extension>),
    (status = 401, description = "Unauthorized"),
    (status = 500, description = "Internal server error"),
  ),
  security(("bearer_auth" = [])),
  tag = "extensions"
)]
async fn get_extensions(
  State(_state): State<ApiServerState>,
) -> Result<Json<Vec<crate::extension_manager::Extension>>, (StatusCode, String)> {
  with_extension_manager(|mgr| mgr.list_extensions()).map(Json)
}

/// Add an extension.
///
/// The body names exactly one source:
/// - `file_name` plus `file_data_base64` uploads a `.crx`/`.zip`.
/// - `source_path` reads a `.crx`/`.zip`, or packs an unpacked extension
///   directory, from the machine running Donut. `link: true` loads that
///   directory in place instead, so edits apply on the next browser start;
///   a linked extension is machine-local and never syncs.
///
/// `name` may be omitted: the manifest's own name is preferred anyway, and it
/// is only an error when the manifest has none either.
#[utoipa::path(
  post,
  path = "/v1/extensions",
  request_body = CreateExtensionRequest,
  responses(
    (status = 201, description = "Extension added", body = crate::extension_manager::Extension),
    (status = 400, description = "No source, two sources, undecodable payload, unsupported file type, or an unreadable unpacked directory"),
    (status = 401, description = "Unauthorized"),
    (status = 404, description = "source_path does not exist"),
    (status = 500, description = "Internal server error"),
  ),
  security(("bearer_auth" = [])),
  tag = "extensions"
)]
async fn create_extension_api(
  Json(request): Json<CreateExtensionRequest>,
) -> Result<(StatusCode, Json<crate::extension_manager::Extension>), (StatusCode, String)> {
  let source = resolve_extension_source(
    request.file_name,
    request.file_data_base64,
    request.source_path,
    request.link,
  )?
  .ok_or_else(|| extension_request_error("EXTENSION_SOURCE_REQUIRED"))?;
  let name = request.name.unwrap_or_default();

  let extension = with_extension_manager(|mgr| match source {
    ExtensionSource::Upload { file_name, data } => mgr.add_extension(name, file_name, data),
    ExtensionSource::LocalPath { path, link } => mgr.add_extension_from_path(name, &path, link),
  })?;
  Ok((StatusCode::CREATED, Json(extension)))
}

#[utoipa::path(
  get,
  path = "/v1/extensions/{id}",
  params(("id" = String, Path, description = "Extension ID")),
  responses(
    (status = 200, description = "Extension", body = crate::extension_manager::Extension),
    (status = 401, description = "Unauthorized"),
    (status = 404, description = "Extension not found"),
    (status = 500, description = "Internal server error"),
  ),
  security(("bearer_auth" = [])),
  tag = "extensions"
)]
async fn get_extension_api(
  Path(id): Path<String>,
) -> Result<Json<crate::extension_manager::Extension>, (StatusCode, String)> {
  with_extension_manager(|mgr| mgr.get_extension(&id)).map(Json)
}

/// Replace an extension's payload, rename it, or both.
///
/// Sources are the same as on create, and every field is optional — but a body
/// carrying neither a name nor a source asks for nothing and is refused rather
/// than answered with an unchanged extension.
#[utoipa::path(
  put,
  path = "/v1/extensions/{id}",
  params(("id" = String, Path, description = "Extension ID")),
  request_body = UpdateExtensionRequest,
  responses(
    (status = 200, description = "Extension updated", body = crate::extension_manager::Extension),
    (status = 400, description = "Nothing to change, two sources, undecodable payload, unsupported file type, or an unreadable unpacked directory"),
    (status = 401, description = "Unauthorized"),
    (status = 404, description = "Extension not found, or source_path does not exist"),
    (status = 500, description = "Internal server error"),
  ),
  security(("bearer_auth" = [])),
  tag = "extensions"
)]
async fn update_extension_api(
  Path(id): Path<String>,
  Json(request): Json<UpdateExtensionRequest>,
) -> Result<Json<crate::extension_manager::Extension>, (StatusCode, String)> {
  let name = request.name;
  let source = resolve_extension_source(
    request.file_name,
    request.file_data_base64,
    request.source_path,
    request.link,
  )?;
  if name.is_none() && source.is_none() {
    return Err(extension_request_error("EXTENSION_SOURCE_REQUIRED"));
  }

  with_extension_manager(|mgr| match source {
    Some(ExtensionSource::Upload { file_name, data }) => {
      mgr.update_extension(&id, name, Some(file_name), Some(data))
    }
    Some(ExtensionSource::LocalPath { path, link }) => {
      mgr.update_extension_from_path(&id, name, &path, link)
    }
    None => mgr.update_extension(&id, name, None, None),
  })
  .map(Json)
}

#[utoipa::path(
  delete,
  path = "/v1/extensions/{id}",
  params(("id" = String, Path, description = "Extension ID")),
  responses(
    (status = 204, description = "Extension deleted"),
    (status = 401, description = "Unauthorized"),
    (status = 404, description = "Extension not found"),
    (status = 500, description = "Internal server error"),
  ),
  security(("bearer_auth" = [])),
  tag = "extensions"
)]
async fn delete_extension_api(
  Path(id): Path<String>,
  State(state): State<ApiServerState>,
) -> Result<StatusCode, (StatusCode, String)> {
  with_extension_manager(|mgr| mgr.delete_extension(&state.app_handle, &id))
    .map(|_| StatusCode::NO_CONTENT)
}

#[utoipa::path(
  get,
  path = "/v1/extension-groups",
  responses(
    (status = 200, description = "List of extension groups", body = Vec<crate::extension_manager::ExtensionGroup>),
    (status = 401, description = "Unauthorized"),
    (status = 500, description = "Internal server error"),
  ),
  security(("bearer_auth" = [])),
  tag = "extensions"
)]
async fn get_extension_groups(
  State(_state): State<ApiServerState>,
) -> Result<Json<Vec<crate::extension_manager::ExtensionGroup>>, (StatusCode, String)> {
  with_extension_manager(|mgr| mgr.list_groups()).map(Json)
}

#[utoipa::path(
  post,
  path = "/v1/extension-groups",
  request_body = CreateExtensionGroupRequest,
  responses(
    (status = 201, description = "Extension group created", body = crate::extension_manager::ExtensionGroup),
    (status = 400, description = "Empty or duplicate name"),
    (status = 401, description = "Unauthorized"),
    (status = 500, description = "Internal server error"),
  ),
  security(("bearer_auth" = [])),
  tag = "extensions"
)]
async fn create_extension_group_api(
  Json(request): Json<CreateExtensionGroupRequest>,
) -> Result<(StatusCode, Json<crate::extension_manager::ExtensionGroup>), (StatusCode, String)> {
  let group = with_extension_manager(|mgr| mgr.create_group(request.name))?;
  Ok((StatusCode::CREATED, Json(group)))
}

#[utoipa::path(
  get,
  path = "/v1/extension-groups/{id}",
  params(("id" = String, Path, description = "Extension Group ID")),
  responses(
    (status = 200, description = "Extension group", body = crate::extension_manager::ExtensionGroup),
    (status = 401, description = "Unauthorized"),
    (status = 404, description = "Extension group not found"),
    (status = 500, description = "Internal server error"),
  ),
  security(("bearer_auth" = [])),
  tag = "extensions"
)]
async fn get_extension_group_api(
  Path(id): Path<String>,
) -> Result<Json<crate::extension_manager::ExtensionGroup>, (StatusCode, String)> {
  with_extension_manager(|mgr| mgr.get_group(&id)).map(Json)
}

/// Rename a group, replace its whole membership list, or both. `extension_ids`
/// is a replacement, not an addition — omit it to leave membership alone.
#[utoipa::path(
  put,
  path = "/v1/extension-groups/{id}",
  params(("id" = String, Path, description = "Extension Group ID")),
  request_body = UpdateExtensionGroupRequest,
  responses(
    (status = 200, description = "Extension group updated", body = crate::extension_manager::ExtensionGroup),
    (status = 400, description = "Empty or duplicate name"),
    (status = 401, description = "Unauthorized"),
    (status = 404, description = "Extension group not found"),
    (status = 500, description = "Internal server error"),
  ),
  security(("bearer_auth" = [])),
  tag = "extensions"
)]
async fn update_extension_group_api(
  Path(id): Path<String>,
  Json(request): Json<UpdateExtensionGroupRequest>,
) -> Result<Json<crate::extension_manager::ExtensionGroup>, (StatusCode, String)> {
  with_extension_manager(|mgr| mgr.update_group(&id, request.name, request.extension_ids)).map(Json)
}

#[utoipa::path(
  delete,
  path = "/v1/extension-groups/{id}",
  params(("id" = String, Path, description = "Extension Group ID")),
  responses(
    (status = 204, description = "Extension group deleted"),
    (status = 401, description = "Unauthorized"),
    (status = 404, description = "Extension group not found"),
    (status = 500, description = "Internal server error"),
  ),
  security(("bearer_auth" = [])),
  tag = "extensions"
)]
async fn delete_extension_group_api(
  Path(id): Path<String>,
  State(state): State<ApiServerState>,
) -> Result<StatusCode, (StatusCode, String)> {
  with_extension_manager(|mgr| mgr.delete_group(&state.app_handle, &id))
    .map(|_| StatusCode::NO_CONTENT)
}

/// Add one extension to a group. Adding a member it already has is a no-op,
/// not an error, so a client re-running its setup converges.
#[utoipa::path(
  post,
  path = "/v1/extension-groups/{id}/extensions/{extension_id}",
  params(
    ("id" = String, Path, description = "Extension Group ID"),
    ("extension_id" = String, Path, description = "Extension ID to add"),
  ),
  responses(
    (status = 200, description = "Extension group with the extension added", body = crate::extension_manager::ExtensionGroup),
    (status = 401, description = "Unauthorized"),
    (status = 404, description = "Extension or extension group not found"),
    (status = 500, description = "Internal server error"),
  ),
  security(("bearer_auth" = [])),
  tag = "extensions"
)]
async fn add_extension_to_group_api(
  Path((id, extension_id)): Path<(String, String)>,
) -> Result<Json<crate::extension_manager::ExtensionGroup>, (StatusCode, String)> {
  with_extension_manager(|mgr| mgr.add_extension_to_group(&id, &extension_id)).map(Json)
}

/// Remove one extension from a group. The extension itself is untouched.
#[utoipa::path(
  delete,
  path = "/v1/extension-groups/{id}/extensions/{extension_id}",
  params(
    ("id" = String, Path, description = "Extension Group ID"),
    ("extension_id" = String, Path, description = "Extension ID to remove"),
  ),
  responses(
    (status = 200, description = "Extension group with the extension removed", body = crate::extension_manager::ExtensionGroup),
    (status = 401, description = "Unauthorized"),
    (status = 404, description = "Extension group not found"),
    (status = 500, description = "Internal server error"),
  ),
  security(("bearer_auth" = [])),
  tag = "extensions"
)]
async fn remove_extension_from_group_api(
  Path((id, extension_id)): Path<(String, String)>,
) -> Result<Json<crate::extension_manager::ExtensionGroup>, (StatusCode, String)> {
  with_extension_manager(|mgr| mgr.remove_extension_from_group(&id, &extension_id)).map(Json)
}

// API Handler - Run Profile with Remote Debugging
#[utoipa::path(
  post,
  path = "/v1/profiles/{id}/run",
  params(
    ("id" = String, Path, description = "Profile ID")
  ),
  request_body = RunProfileRequest,
  responses(
    (status = 200, description = "Profile launched successfully", body = RunProfileResponse),
    (status = 400, description = "Cannot launch cross-OS profile"),
    (status = 401, description = "Unauthorized"),
    (status = 402, description = "Active paid plan with browser automation required"),
    (status = 404, description = "Profile not found"),
    (status = 409, description = "Profile is locked by another team member, running on the remote fleet, or waiting for a finished remote session to be pulled back"),
    (status = 429, description = "Automation request rate limit exceeded"),
    (status = 503, description = "The profile lock service could not be reached"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "profiles"
)]
async fn run_profile(
  Path(id): Path<String>,
  State(state): State<ApiServerState>,
  Json(request): Json<RunProfileRequest>,
) -> Result<Json<RunProfileResponse>, (StatusCode, String)> {
  if !crate::cloud_auth::CLOUD_AUTH
    .can_use_browser_automation()
    .await
  {
    return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
  }

  let headless = request.headless.unwrap_or(false);
  let url = request.url;

  let profile_manager = ProfileManager::instance();
  let profiles = profile_manager
    .list_profiles()
    .map_err(manager_error_response)?;

  let profile = profiles
    .iter()
    .find(|p| p.id.to_string() == id)
    .ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?;

  if profile.is_cross_os() {
    return Err((
      StatusCode::BAD_REQUEST,
      "cannot launch a cross-OS profile locally; use /run-remote".to_string(),
    ));
  }

  // Team lock check. Routed through the shared mapper so a profile held by the
  // user's OWN remote session is a 409 that says so, rather than a bare status
  // with no body, which is what an automation client had to guess from.
  crate::team_lock::acquire_team_lock_if_needed(profile)
    .await
    .map_err(manager_error_response)?;

  let remote_debugging_port = {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
      .await
      .map_err(manager_error_response)?;
    let port = listener
      .local_addr()
      .map_err(manager_error_response)?
      .port();
    drop(listener);
    port
  };

  // Use the same launch path as the main app, but force a fresh instance with
  // remote debugging enabled so the returned port is the one the browser binds.
  let updated_profile = crate::browser_runner::launch_browser_profile_impl(
    state.app_handle.clone(),
    profile.clone(),
    url,
    crate::browser_runner::LaunchOptions::automation(Some(remote_debugging_port), headless),
  )
  .await
  .map_err(manager_error_response)?;

  Ok(Json(RunProfileResponse {
    profile_id: updated_profile.id.to_string(),
    remote_debugging_port,
    headless,
  }))
}

// API Handler - Launch this profile on a REMOTE VM of its own operating system
#[utoipa::path(
  post,
  path = "/v1/profiles/{id}/run-remote",
  params(
    ("id" = String, Path, description = "Profile ID")
  ),
  request_body = RunRemoteRequest,
  responses(
    (status = 200, description = "Remote session started", body = RunRemoteResponse),
    (status = 400, description = "Profile does not have cloud sync enabled"),
    (status = 401, description = "Unauthorized"),
    (status = 402, description = "Active paid plan with browser automation required"),
    (status = 404, description = "Profile not found"),
    (status = 409, description = "Profile is locked by another session"),
    (status = 429, description = "Automation request rate limit exceeded"),
    (status = 503, description = "No remote capacity for this operating system"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "profiles"
)]
async fn run_profile_remote(
  Path(id): Path<String>,
  State(state): State<ApiServerState>,
  Json(request): Json<RunRemoteRequest>,
) -> Result<Json<RunRemoteResponse>, (StatusCode, String)> {
  if !crate::cloud_auth::CLOUD_AUTH
    .can_use_browser_automation()
    .await
  {
    return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
  }

  let profile_manager = ProfileManager::instance();
  let profiles = profile_manager
    .list_profiles()
    .map_err(manager_error_response)?;
  let profile = profiles
    .iter()
    .find(|p| p.id.to_string() == id)
    .ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?;

  // The profile must exist in cloud storage before a remote host can open it —
  // the VM pulls it from donut-sync, and a profile that has never synced would
  // launch an empty browser and then push that emptiness back over the real one.
  if let Err(reason) = remote_launch_precondition(profile).await {
    return Err((StatusCode::BAD_REQUEST, reason));
  }

  // Deliberately NO is_cross_os() guard here. Local /run refuses a foreign
  // profile because this machine is the wrong OS; running it remotely on a host
  // of its OWN OS is precisely what this endpoint exists for.
  let outcome =
    crate::remote_session::start_remote_session(state.app_handle.clone(), profile, request.url)
      .await
      .map_err(remote_session_error_response)?;

  Ok(Json(RunRemoteResponse {
    profile_id: profile.id.to_string(),
    session_id: outcome.session_id,
    platform: outcome.platform,
    status: outcome.status,
  }))
}

#[utoipa::path(
  post,
  path = "/v1/profiles/{id}/cloud-sync",
  params(
    ("id" = String, Path, description = "Profile ID")
  ),
  request_body = SetCloudSyncRequest,
  responses(
    (status = 200, description = "Cloud sync mode updated", body = SetCloudSyncResponse),
    (status = 400, description = "Invalid mode, or the profile cannot be synced"),
    (status = 401, description = "Unauthorized"),
    (status = 402, description = "Active paid plan with cloud backup required"),
    (status = 404, description = "Profile not found"),
    (status = 409, description = "Profile is running — stop it before enabling sync"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "profiles"
)]
async fn set_profile_cloud_sync(
  Path(id): Path<String>,
  State(state): State<ApiServerState>,
  Json(request): Json<SetCloudSyncRequest>,
) -> Result<Json<SetCloudSyncResponse>, (StatusCode, String)> {
  // Remote launch requires cloud sync, and until now sync could only be turned
  // on from the GUI — so an automation-only caller could never reach the state
  // that makes /run-remote work.
  let mode = match request.mode.as_str() {
    "Disabled" | "Regular" | "Encrypted" => request.mode.clone(),
    other => {
      return Err((
        StatusCode::BAD_REQUEST,
        format!("invalid sync mode {other:?}; expected Disabled, Regular or Encrypted"),
      ));
    }
  };

  crate::sync::set_profile_sync_mode(state.app_handle.clone(), id.clone(), mode.clone())
    .await
    .map_err(sync_mode_error_response)?;

  let profile_manager = ProfileManager::instance();
  let profiles = profile_manager
    .list_profiles()
    .map_err(manager_error_response)?;
  let profile = profiles
    .iter()
    .find(|p| p.id.to_string() == id)
    .ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?;

  // Reported rather than left for the caller to discover at launch time: the
  // most common reason a caller enables sync is to run the profile remotely,
  // and Encrypted mode silently makes that impossible.
  let blocked = remote_launch_precondition(profile).await.err();
  Ok(Json(SetCloudSyncResponse {
    profile_id: profile.id.to_string(),
    mode,
    remote_launchable: blocked.is_none(),
    remote_blocked_reason: blocked,
  }))
}

/// Map a sync-mode failure onto the status the caller can act on.
///
/// `set_profile_sync_mode` reports a running profile as a JSON body rather than
/// a plain message, because enabling sync under a live browser would race the
/// browser's own writes.
fn sync_mode_error_response(err: String) -> (StatusCode, String) {
  if err.contains("PROFILE_RUNNING") {
    return (
      StatusCode::CONFLICT,
      "profile is running; stop it before changing cloud sync".to_string(),
    );
  }
  if err.contains("cross-OS") || err.contains("ephemeral") {
    return (StatusCode::BAD_REQUEST, err);
  }
  (StatusCode::INTERNAL_SERVER_ERROR, err)
}

/// Whether a profile may be launched on a remote host.
///
/// The one gate between "the user asked" and "a browser opens somewhere else
/// holding their cookies". Adds the live check the pure rules cannot make: a
/// launch that races this profile's own upload hands the host a torn snapshot.
pub async fn remote_launch_precondition(
  profile: &crate::profile::types::BrowserProfile,
) -> Result<(), String> {
  remote_launch_profile_rules(profile)?;

  // The manifest is written last, so a host pulling mid-upload gets files
  // that are about to be replaced and a manifest that does not describe them.
  // The browser then comes up on a profile that never existed on this machine
  // and pushes it back over the real one.
  if let Some(scheduler) = crate::sync::get_global_scheduler() {
    if scheduler
      .is_profile_sync_in_progress(&profile.id.to_string())
      .await
    {
      return Err(serde_json::json!({ "code": "REMOTE_SYNC_IN_PROGRESS" }).to_string());
    }
  }

  Ok(())
}

/// The parts of the rule that depend only on the profile itself.
///
/// Split out so the rules stay unit-testable without a running app or a sync
/// scheduler, and so the live check above cannot be reached without them.
pub fn remote_launch_profile_rules(
  profile: &crate::profile::types::BrowserProfile,
) -> Result<(), String> {
  if !profile.is_sync_enabled() {
    return Err(
      "profile does not have cloud sync enabled; a remote host has no way to \
       obtain it"
        .to_string(),
    );
  }
  if profile.is_encrypted_sync() {
    // The key is derived from a passphrase that never leaves this machine, so
    // the host would download ciphertext, launch Chromium on it, and push the
    // corruption back over the user's real profile.
    return Err(
      "profile uses end-to-end encrypted sync; a remote host cannot decrypt \
       it. Switch the profile to Regular sync to run it remotely."
        .to_string(),
    );
  }
  if profile.resolved_os().is_none() {
    return Err(
      "profile has no recorded operating system, so it cannot be scheduled \
       onto a matching host"
        .to_string(),
    );
  }
  Ok(())
}

// API Handler - Stop a REMOTE session started by run-remote
#[utoipa::path(
  delete,
  path = "/v1/remote-sessions/{id}",
  params(
    ("id" = String, Path, description = "Remote session ID from run-remote")
  ),
  responses(
    (status = 200, description = "Remote session stopped", body = StopRemoteResponse),
    (status = 401, description = "Unauthorized"),
    (status = 404, description = "No such remote session"),
    (status = 429, description = "Automation request rate limit exceeded"),
    (status = 503, description = "The fleet could not be reached; the session is still running"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "profiles"
)]
async fn stop_remote_session(
  Path(id): Path<String>,
) -> Result<Json<StopRemoteResponse>, (StatusCode, String)> {
  // Without this route, `run-remote` hands back a session id nothing can act
  // on: the only thing that ends a session is the fleet's own two-hour cap, so
  // every launch bills 7200s no matter how briefly it ran.
  let outcome = crate::remote_session::end_remote_session(&id)
    .await
    .map_err(remote_session_error_response)?;

  Ok(Json(StopRemoteResponse {
    session_id: outcome.session_id,
    status: outcome.status,
    billed_seconds: outcome.billed_seconds,
  }))
}

fn remote_session_error_response(
  err: crate::remote_session::RemoteSessionError,
) -> (StatusCode, String) {
  use crate::remote_session::RemoteSessionError;
  match err {
    RemoteSessionError::NoCapacity(m) => (StatusCode::SERVICE_UNAVAILABLE, m),
    RemoteSessionError::Conflict(m) => (StatusCode::CONFLICT, m),
    RemoteSessionError::NotAuthorised(m) => (StatusCode::PAYMENT_REQUIRED, m),
    RemoteSessionError::Other(m) => (StatusCode::INTERNAL_SERVER_ERROR, m),
  }
}

/// Map a read of remote state onto a status, and answer with a machine code.
///
/// Separate from `remote_session_error_response` on purpose. That one serves
/// the LAUNCH path, whose documented contract is a plain-English diagnostic and
/// whose only interesting failures are "busy", "already open" and "not on your
/// plan". A read has a different failure set — chiefly "no such session", which
/// the launch mapping would report as a 500 — and it is new, so it can answer
/// with the `{"code":…}` envelope from the start instead of English a client
/// would have to pattern-match.
fn remote_session_read_response(
  err: crate::remote_session::RemoteSessionError,
) -> (StatusCode, String) {
  use crate::remote_session::RemoteSessionError;
  let upstream = match &err {
    RemoteSessionError::NoCapacity(_) => 503,
    RemoteSessionError::Conflict(_) => 409,
    RemoteSessionError::NotAuthorised(_) => 403,
    // The status was consumed on the way in; the code is recovered from the
    // backend's own envelope instead.
    RemoteSessionError::Other(_) => 0,
  };
  let body = err.to_error_json();
  let status = cloud_failure_status(upstream, &error_code_of(&body));
  (status, body)
}

fn cookie_bot_error_response(err: crate::cookie_bot::CookieBotError) -> (StatusCode, String) {
  let status = cloud_failure_status(err.status(), err.code());
  (status, err.to_error_json())
}

/// Read the machine code out of a `{"code":…}` body.
fn error_code_of(body: &str) -> String {
  serde_json::from_str::<serde_json::Value>(body)
    .ok()
    .and_then(|value| {
      value
        .get("code")
        .and_then(serde_json::Value::as_str)
        .map(str::to_string)
    })
    .unwrap_or_default()
}

/// Turn a donutbrowser-infra failure into the status a local client can act on.
///
/// The upstream status is not echoed blindly. A 401 up there means THIS desktop
/// has no cloud session, which has nothing to do with the caller's own bearer
/// token — answering 401 would send an automation client off to rotate a token
/// that is perfectly good. Likewise the cloud's 403 covers two unrelated
/// things: "your plan does not include this", which is the 402 this API uses
/// everywhere else, and "you are not a member of that team", which no payment
/// fixes.
fn cloud_failure_status(upstream: u16, code: &str) -> StatusCode {
  // The disambiguations first, because no status can express them.
  if code == crate::cloud_errors::NOT_SIGNED_IN {
    return StatusCode::FORBIDDEN;
  }
  if code.ends_with("NOT_ENTITLED") || code == "REMOTE_HOURS_EXHAUSTED" {
    return StatusCode::PAYMENT_REQUIRED;
  }
  if code == crate::cloud_errors::RATE_LIMITED {
    return StatusCode::TOO_MANY_REQUESTS;
  }
  if code == crate::cloud_errors::UNREACHABLE || code == crate::cloud_errors::NO_CAPACITY {
    return StatusCode::SERVICE_UNAVAILABLE;
  }

  match upstream {
    400 | 422 => StatusCode::BAD_REQUEST,
    402 => StatusCode::PAYMENT_REQUIRED,
    403 => StatusCode::FORBIDDEN,
    404 => StatusCode::NOT_FOUND,
    409 => StatusCode::CONFLICT,
    429 => StatusCode::TOO_MANY_REQUESTS,
    503 => StatusCode::SERVICE_UNAVAILABLE,
    // Some transports keep only the body, so the status is gone by the time
    // it gets here. Falling straight through to 500 would report every one of
    // those as our fault, including "no such run".
    _ => status_for_code(code),
  }
}

/// The status a machine code implies when the HTTP status did not survive.
fn status_for_code(code: &str) -> StatusCode {
  if code.ends_with("NOT_FOUND") || code == "COOKIE_BOT_NOT_ENROLLED" {
    StatusCode::NOT_FOUND
  } else if code.ends_with("CONFLICT")
    || code == "COOKIE_BOT_RUN_IN_PROGRESS"
    || code == "REMOTE_SYNC_IN_PROGRESS"
  {
    StatusCode::CONFLICT
  } else if code.starts_with("COOKIE_BOT_INVALID")
    || code == "COOKIE_BOT_SITE_LIMIT"
    || code == "REMOTE_SESSION_REFUSED"
  {
    StatusCode::BAD_REQUEST
  } else if code == "NOT_TEAM_MEMBER" {
    StatusCode::FORBIDDEN
  } else {
    StatusCode::INTERNAL_SERVER_ERROR
  }
}

// API Handler - Attach a CDP client (Playwright, Puppeteer, chrome-remote-interface)
// to a remote session.
//
// This is what makes `run-remote` usable. Without it the endpoint hands back a
// session id that nothing outside this app can do anything with: the fleet's
// relay only accepts the user's Donut cloud credential, an automation client
// does not have one, and it must not be given one — an API token is scoped to
// "drive my browsers", not "act as my account".
//
// So the socket is opened here with the credential this process already holds
// and the frames are pumped verbatim in both directions. The caller presents
// the ordinary API bearer token and gets a browser-level CDP endpoint at
// `ws://127.0.0.1:<api port>/v1/remote-sessions/{id}/cdp`:
//
//     const browser = await chromium.connectOverCDP({
//       endpointURL: `ws://127.0.0.1:10108/v1/remote-sessions/${id}/cdp`,
//       headers: { Authorization: `Bearer ${API_TOKEN}` },
//     });
//
// Nothing is attached to a page first, deliberately: Playwright drives
// `Target.setAutoAttach` and builds its own session map, and a socket already
// bound to one page would hide every other target from it.
#[utoipa::path(
  get,
  path = "/v1/remote-sessions/{id}/cdp",
  params(
    ("id" = String, Path, description = "Remote session ID from run-remote")
  ),
  responses(
    (status = 101, description = "Switching Protocols; a browser-level CDP WebSocket follows"),
    (status = 401, description = "Unauthorized"),
    (status = 402, description = "Active paid plan with browser automation required"),
    (status = 404, description = "No such remote session, or it is not attachable yet"),
    (status = 502, description = "The relay could not be reached"),
    (status = 426, description = "Not a WebSocket upgrade request")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "remote-sessions"
)]
async fn remote_session_cdp(
  Path(id): Path<String>,
  upgrade: WebSocketUpgrade,
) -> Result<Response, (StatusCode, String)> {
  if !crate::cloud_auth::CLOUD_AUTH
    .can_use_browser_automation()
    .await
  {
    return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
  }

  // Dialled BEFORE the upgrade is accepted, so a session that is not attachable
  // is an HTTP status the client can read. Accepting the upgrade first would
  // turn every such failure into a socket that opens and immediately closes,
  // which is what a CDP client reports as "browser closed unexpectedly".
  let upstream = crate::cdp_target::open_relay_socket(&id)
    .await
    .map_err(cdp_error_response)?;

  Ok(
    upgrade
      .max_message_size(crate::cdp_target::MAX_RELAY_MESSAGE_BYTES)
      .max_frame_size(crate::cdp_target::MAX_RELAY_MESSAGE_BYTES)
      .on_upgrade(move |client| pump_cdp(id, client, upstream)),
  )
}

fn cdp_error_response(err: crate::cdp_target::CdpError) -> (StatusCode, String) {
  use crate::cdp_target::CdpError;
  let status = match err {
    CdpError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
    // "Not drivable" covers a session that is still provisioning and one that
    // is not the caller's. Both are 404 to a CDP client: there is no browser at
    // this address right now.
    CdpError::NotDrivable(_) => StatusCode::NOT_FOUND,
    CdpError::Unreachable(_) => StatusCode::BAD_GATEWAY,
    CdpError::Transport(_) | CdpError::Protocol(_) => StatusCode::BAD_GATEWAY,
  };
  (status, err.to_string())
}

/// Copy CDP frames between the local client and the fleet relay until either
/// side hangs up.
///
/// Verbatim in both directions. This proxy deliberately understands nothing
/// about CDP: a client that speaks a newer protocol, or a target type this
/// build has never heard of, must keep working without a Donut release.
async fn pump_cdp(session_id: String, client: WebSocket, upstream: crate::cdp_target::RelaySocket) {
  use futures_util::{SinkExt, StreamExt};
  use tokio_tungstenite::tungstenite::Message as RelayMessage;

  let (mut client_tx, mut client_rx) = client.split();
  let (mut relay_tx, mut relay_rx) = upstream.split();

  let to_relay = async {
    while let Some(Ok(message)) = client_rx.next().await {
      let forwarded = match message {
        WsMessage::Text(text) => RelayMessage::Text(text.as_str().into()),
        WsMessage::Binary(bytes) => RelayMessage::Binary(bytes),
        WsMessage::Ping(bytes) => RelayMessage::Ping(bytes),
        WsMessage::Pong(bytes) => RelayMessage::Pong(bytes),
        WsMessage::Close(_) => break,
      };
      if relay_tx.send(forwarded).await.is_err() {
        break;
      }
    }
    let _ = relay_tx.close().await;
  };

  let to_client = async {
    while let Some(Ok(message)) = relay_rx.next().await {
      let forwarded = match message {
        RelayMessage::Text(text) => WsMessage::Text(text.as_str().into()),
        RelayMessage::Binary(bytes) => WsMessage::Binary(bytes),
        RelayMessage::Ping(bytes) => WsMessage::Ping(bytes),
        RelayMessage::Pong(bytes) => WsMessage::Pong(bytes),
        // A relay close carries the only diagnosis the server gives (1008 is a
        // rejected credential, 1013 is "not up yet"), so it is passed through
        // rather than swallowed into a bare disconnect.
        RelayMessage::Close(frame) => {
          let _ = client_tx
            .send(WsMessage::Close(frame.map(|f| {
              axum::extract::ws::CloseFrame {
                code: u16::from(f.code),
                reason: f.reason.as_str().into(),
              }
            })))
            .await;
          return;
        }
        RelayMessage::Frame(_) => continue,
      };
      if client_tx.send(forwarded).await.is_err() {
        break;
      }
    }
    let _ = client_tx.close().await;
  };

  // Either direction ending means the conversation is over. Waiting for both
  // would hold a relay socket open — and one of the session's four allowed
  // attachments with it — after the client had gone.
  tokio::select! {
    () = to_relay => {}
    () = to_client => {}
  }
  log::info!("CDP proxy for remote session {session_id} closed");
}

// API Handler - Every remote session this account currently owns
#[utoipa::path(
  get,
  path = "/v1/remote-sessions",
  responses(
    (status = 200, description = "Sessions owned by the signed-in account", body = ApiRemoteSessionsResponse),
    (status = 401, description = "Unauthorized"),
    (status = 402, description = "Active paid plan with browser automation required"),
    (status = 403, description = "This desktop is not signed in to Donut cloud"),
    (status = 503, description = "Donut cloud could not be reached"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "remote-sessions"
)]
async fn list_remote_sessions_api() -> Result<Json<ApiRemoteSessionsResponse>, (StatusCode, String)>
{
  let sessions = crate::remote_session::list_remote_sessions()
    .await
    .map_err(remote_session_read_response)?;
  Ok(Json(ApiRemoteSessionsResponse { sessions }))
}

// API Handler - One remote session's real state
#[utoipa::path(
  get,
  path = "/v1/remote-sessions/{id}",
  params(
    ("id" = String, Path, description = "Remote session ID from run-remote")
  ),
  responses(
    (status = 200, description = "Current session state", body = crate::remote_session::RemoteSessionState),
    (status = 401, description = "Unauthorized"),
    (status = 402, description = "Active paid plan with browser automation required"),
    (status = 403, description = "This desktop is not signed in to Donut cloud"),
    (status = 404, description = "No such remote session"),
    (status = 503, description = "Donut cloud could not be reached"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "remote-sessions"
)]
async fn get_remote_session_api(
  Path(id): Path<String>,
) -> Result<Json<crate::remote_session::RemoteSessionState>, (StatusCode, String)> {
  // `run-remote` answers `provisioning` and nothing more. Until this route
  // existed, an automation client had no way to learn a session had become
  // usable other than repeatedly trying to drive it.
  crate::remote_session::get_remote_session(&id)
    .await
    .map(Json)
    .map_err(remote_session_read_response)
}

// API Handler - The pooled remote-hour budget
#[utoipa::path(
  get,
  path = "/v1/remote-hours",
  responses(
    (status = 200, description = "Pooled remote-hour budget and its breakdown", body = crate::cookie_bot::RemoteHoursQuota),
    (status = 401, description = "Unauthorized"),
    (status = 403, description = "This desktop is not signed in to Donut cloud"),
    (status = 503, description = "Donut cloud could not be reached"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "remote-sessions"
)]
async fn get_remote_hours(
) -> Result<Json<crate::cookie_bot::RemoteHoursQuota>, (StatusCode, String)> {
  // Bot runs and interactive remote sessions spend one pool. Being refused a
  // launch should not be the only way to find out how much of it is left.
  crate::cookie_bot::remote_hours_quota()
    .await
    .map(Json)
    .map_err(cookie_bot_error_response)
}

// --- Cookie bot -------------------------------------------------------------
//
// Thin proxies onto donutbrowser-infra, which owns the schedule, the calendar
// arithmetic, the browsing model and the pooled hour budget. Nothing here
// decides when a run happens or what it does. What this file DOES decide is
// which profiles may be offered to it at all.

/// Resolve a profile the cookie bot is allowed to touch.
///
/// The bot exists only on the leased fleet: a run materialises the profile on a
/// remote host from cloud sync, warms it, and pushes it back. A profile that
/// cannot make that round trip — never synced, encrypted with a key that never
/// leaves this machine, no recorded OS, an OS the fleet cannot lease, or no
/// proxy or VPN to egress through — has no path to a run and must never reach
/// an enrolment, a quota check or a leased host.
///
/// Every cookie-bot WRITE on this server goes through here, so there is no
/// surface on which a local-only profile can be pointed at the bot. The server
/// re-checks all of it; this exists so the refusal happens at the moment the
/// caller asks rather than silently at 02:00.
fn cookie_bot_eligible_profile(
  profile_id: &str,
) -> Result<crate::profile::types::BrowserProfile, (StatusCode, String)> {
  let profiles = ProfileManager::instance()
    .list_profiles()
    .map_err(manager_error_response)?;
  let profile = profiles
    .into_iter()
    .find(|p| p.id.to_string() == profile_id)
    .ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?;

  crate::cookie_bot::bot_precondition(&profile, &crate::cookie_bot::exit_reachability(&profile))
    .map_err(|reason| (StatusCode::BAD_REQUEST, reason))?;
  Ok(profile)
}

// API Handler - Every cookie-bot enrolment the caller can see
#[utoipa::path(
  get,
  path = "/v1/cookie-bot/schedules",
  params(
    ("scope" = Option<String>, Query, description = "`mine` (default) or `team`")
  ),
  responses(
    (status = 200, description = "Enrolled profiles", body = crate::cookie_bot::CookieBotScheduleList),
    (status = 401, description = "Unauthorized"),
    (status = 402, description = "Plan does not include the cookie bot"),
    (status = 403, description = "Not signed in, or scope=team from a non-member"),
    (status = 503, description = "Donut cloud could not be reached"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "cookie-bot"
)]
async fn list_cookie_bot_schedules(
  Query(query): Query<CookieBotScopeQuery>,
) -> Result<Json<crate::cookie_bot::CookieBotScheduleList>, (StatusCode, String)> {
  crate::cookie_bot::list_schedules(query.scope.as_deref())
    .await
    .map(Json)
    .map_err(cookie_bot_error_response)
}

// API Handler - One profile's enrolment
#[utoipa::path(
  get,
  path = "/v1/cookie-bot/schedules/{profile_id}",
  params(
    ("profile_id" = String, Path, description = "Profile ID")
  ),
  responses(
    (status = 200, description = "The profile's enrolment", body = crate::cookie_bot::CookieBotSchedule),
    (status = 401, description = "Unauthorized"),
    (status = 403, description = "This desktop is not signed in to Donut cloud"),
    (status = 404, description = "This profile is not enrolled"),
    (status = 503, description = "Donut cloud could not be reached"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "cookie-bot"
)]
async fn get_cookie_bot_schedule(
  Path(profile_id): Path<String>,
) -> Result<Json<crate::cookie_bot::CookieBotSchedule>, (StatusCode, String)> {
  // Deliberately NOT gated on eligibility: a profile whose sync was turned off
  // after it was enrolled must still be able to show what it is enrolled as,
  // otherwise the only way to see the schedule is to be allowed to run it.
  match crate::cookie_bot::get_schedule(&profile_id)
    .await
    .map_err(cookie_bot_error_response)?
  {
    Some(schedule) => Ok(Json(schedule)),
    None => Err((
      StatusCode::NOT_FOUND,
      serde_json::json!({ "code": "COOKIE_BOT_NOT_ENROLLED" }).to_string(),
    )),
  }
}

// API Handler - Enrol a profile, or replace its enrolment
#[utoipa::path(
  put,
  path = "/v1/cookie-bot/schedules/{profile_id}",
  params(
    ("profile_id" = String, Path, description = "Profile ID")
  ),
  request_body = SetCookieBotScheduleRequest,
  responses(
    (status = 200, description = "Enrolment saved", body = crate::cookie_bot::CookieBotScheduleSaved),
    (status = 400, description = "Invalid schedule, or a profile the bot cannot run"),
    (status = 401, description = "Unauthorized"),
    (status = 402, description = "Plan does not include the cookie bot"),
    (status = 403, description = "This desktop is not signed in to Donut cloud"),
    (status = 404, description = "Profile not found"),
    (status = 409, description = "A teammate already enrols this profile; retry with acknowledge_conflict"),
    (status = 503, description = "Donut cloud could not be reached"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "cookie-bot"
)]
async fn set_cookie_bot_schedule(
  Path(profile_id): Path<String>,
  Json(request): Json<SetCookieBotScheduleRequest>,
) -> Result<Json<crate::cookie_bot::CookieBotScheduleSaved>, (StatusCode, String)> {
  let profile = cookie_bot_eligible_profile(&profile_id)?;
  // `bot_precondition` already proved the profile has a resolvable OS the
  // fleet can lease, so this cannot fail; taking it from the profile rather
  // than the request is what stops a caller enrolling a macOS profile onto a
  // Windows host.
  let platform = profile
    .resolved_os()
    .ok_or((
      StatusCode::BAD_REQUEST,
      serde_json::json!({ "code": "COOKIE_BOT_UNKNOWN_PLATFORM" }).to_string(),
    ))?
    .to_string();

  if let Some(requested) = request.platform.as_deref() {
    if requested != platform {
      return Err((
        StatusCode::BAD_REQUEST,
        serde_json::json!({
          "code": "COOKIE_BOT_UNSUPPORTED_PLATFORM",
          "params": { "platform": requested }
        })
        .to_string(),
      ));
    }
  }

  let input = crate::cookie_bot::CookieBotScheduleInput {
    profile_name: request.profile_name.unwrap_or_else(|| profile.name.clone()),
    platform,
    enabled: request.enabled,
    run_at_minute: request.run_at_minute,
    days_mask: request.days_mask,
    timezone: request.timezone,
    preset: request.preset,
    max_minutes: request.max_minutes,
    sites: request.sites,
    jitter_seconds: request.jitter_seconds,
    ..Default::default()
  }
  // The server requires these and cannot read them itself — the profile lives
  // in the user's sync namespace, not its database.
  .with_profile_state(crate::cookie_bot::profile_state(&profile));

  crate::cookie_bot::save_schedule(&profile_id, &input, request.acknowledge_conflict)
    .await
    .map(Json)
    .map_err(cookie_bot_error_response)
}

// API Handler - Turn the bot off for a profile
#[utoipa::path(
  delete,
  path = "/v1/cookie-bot/schedules/{profile_id}",
  params(
    ("profile_id" = String, Path, description = "Profile ID")
  ),
  responses(
    (status = 200, description = "Enrolment removed, or there was none", body = crate::cookie_bot::CookieBotScheduleDeleted),
    (status = 401, description = "Unauthorized"),
    (status = 403, description = "This desktop is not signed in to Donut cloud"),
    (status = 503, description = "Donut cloud could not be reached"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "cookie-bot"
)]
async fn delete_cookie_bot_schedule(
  Path(profile_id): Path<String>,
) -> Result<Json<crate::cookie_bot::CookieBotScheduleDeleted>, (StatusCode, String)> {
  // No eligibility gate and no 404: "turn the bot off" must be safe to repeat,
  // and a profile that has since become ineligible is exactly the one a caller
  // most needs to be able to unenrol.
  crate::cookie_bot::delete_schedule(&profile_id)
    .await
    .map(Json)
    .map_err(cookie_bot_error_response)
}

// API Handler - Who else already warms this profile
#[utoipa::path(
  get,
  path = "/v1/cookie-bot/conflicts",
  params(
    ("profile_id" = String, Query, description = "Profile ID"),
    ("run_at_minute" = Option<u16>, Query, description = "Proposed minute past local midnight"),
    ("timezone" = Option<String>, Query, description = "Proposed IANA zone"),
    ("days_mask" = Option<u8>, Query, description = "Proposed weekday bitmask, bit 0 = Monday")
  ),
  responses(
    (status = 200, description = "Teammates enrolling the same profile", body = crate::cookie_bot::CookieBotConflictCheck),
    (status = 400, description = "profile_id missing"),
    (status = 401, description = "Unauthorized"),
    (status = 403, description = "This desktop is not signed in to Donut cloud"),
    (status = 503, description = "Donut cloud could not be reached"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "cookie-bot"
)]
async fn get_cookie_bot_conflicts(
  Query(query): Query<CookieBotConflictsQuery>,
) -> Result<Json<crate::cookie_bot::CookieBotConflictCheck>, (StatusCode, String)> {
  // A dry run that writes nothing, so an automation client can find a
  // collision before it makes one instead of after two operators have quietly
  // scheduled the same profile against itself.
  crate::cookie_bot::check_conflicts(
    &query.profile_id,
    query.run_at_minute,
    query.timezone.as_deref(),
    query.days_mask,
  )
  .await
  .map(Json)
  .map_err(cookie_bot_error_response)
}

// API Handler - Run history
#[utoipa::path(
  get,
  path = "/v1/cookie-bot/runs",
  params(
    ("profile_id" = Option<String>, Query, description = "Restrict to one profile"),
    ("scope" = Option<String>, Query, description = "`mine` (default) or `team`"),
    ("limit" = Option<u32>, Query, description = "Page size, 1..100 (default 30)"),
    ("before" = Option<String>, Query, description = "Keyset cursor from a previous page's next_before")
  ),
  responses(
    (status = 200, description = "One page of runs, newest first", body = crate::cookie_bot::CookieBotRunPage),
    (status = 400, description = "limit out of range or malformed cursor"),
    (status = 401, description = "Unauthorized"),
    (status = 403, description = "Not signed in, or scope=team from a non-member"),
    (status = 503, description = "Donut cloud could not be reached"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "cookie-bot"
)]
async fn list_cookie_bot_runs(
  Query(query): Query<CookieBotRunsQuery>,
) -> Result<Json<crate::cookie_bot::CookieBotRunPage>, (StatusCode, String)> {
  crate::cookie_bot::list_runs(
    query.profile_id.as_deref(),
    query.scope.as_deref(),
    query.limit,
    query.before.as_deref(),
  )
  .await
  .map(Json)
  .map_err(cookie_bot_error_response)
}

// API Handler - Warm a profile now instead of waiting for tonight
#[utoipa::path(
  post,
  path = "/v1/cookie-bot/runs",
  request_body = StartCookieBotRunRequest,
  responses(
    (status = 202, description = "Run accepted; it keeps executing for minutes after this response", body = crate::cookie_bot::CookieBotRunStarted),
    (status = 400, description = "A profile the bot cannot run"),
    (status = 401, description = "Unauthorized"),
    (status = 402, description = "Plan does not include the cookie bot, or the pooled hours are spent"),
    (status = 403, description = "This desktop is not signed in to Donut cloud"),
    (status = 404, description = "Profile not found, or not enrolled"),
    (status = 409, description = "A run or remote session already holds this profile"),
    (status = 429, description = "Automation request rate limit exceeded"),
    (status = 503, description = "No host of that operating system has a free slot"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "cookie-bot"
)]
async fn start_cookie_bot_run(
  Json(request): Json<StartCookieBotRunRequest>,
) -> Result<(StatusCode, Json<crate::cookie_bot::CookieBotRunStarted>), (StatusCode, String)> {
  if !crate::cloud_auth::CLOUD_AUTH.can_use_cookie_bot().await {
    return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
  }

  cookie_bot_eligible_profile(&request.profile_id)?;

  let started = crate::cookie_bot::run_now(&request.profile_id, request.max_minutes)
    .await
    .map_err(cookie_bot_error_response)?;

  // 202, not 200: the fleet is still browsing when this returns. Answering 200
  // would tell a client the work is done when it has barely started.
  Ok((StatusCode::ACCEPTED, Json(started)))
}

// API Handler - Stop a run that is still going
#[utoipa::path(
  delete,
  path = "/v1/cookie-bot/runs/{run_id}",
  params(
    ("run_id" = String, Path, description = "Run ID")
  ),
  responses(
    (status = 200, description = "The run, cancelled (or unchanged if it had already finished)", body = crate::cookie_bot::CookieBotRun),
    (status = 401, description = "Unauthorized"),
    (status = 403, description = "This desktop is not signed in to Donut cloud"),
    (status = 404, description = "No such run for this account"),
    (status = 429, description = "Automation request rate limit exceeded"),
    (status = 503, description = "The fleet could not be reached; the run is still live"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "cookie-bot"
)]
async fn cancel_cookie_bot_run(
  Path(run_id): Path<String>,
) -> Result<Json<crate::cookie_bot::CookieBotRun>, (StatusCode, String)> {
  // No entitlement gate. A lapsed plan must never be the reason a user cannot
  // stop something that is spending their hours.
  crate::cookie_bot::cancel_run(&run_id)
    .await
    .map(Json)
    .map_err(cookie_bot_error_response)
}

// API Handler - The intensities the server offers
#[utoipa::path(
  get,
  path = "/v1/cookie-bot/presets",
  responses(
    (status = 200, description = "Selectable presets", body = crate::cookie_bot::CookieBotPresetList),
    (status = 401, description = "Unauthorized"),
    (status = 403, description = "This desktop is not signed in to Donut cloud"),
    (status = 503, description = "Donut cloud could not be reached"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "cookie-bot"
)]
async fn list_cookie_bot_presets(
) -> Result<Json<crate::cookie_bot::CookieBotPresetList>, (StatusCode, String)> {
  // Ids and a rough duration only. What a preset expands to — the site
  // ordering, the dwell model, the scroll and click programme — is the
  // server's, and stays there.
  crate::cookie_bot::list_presets()
    .await
    .map(Json)
    .map_err(cookie_bot_error_response)
}

// API Handler - Who spent what, for a calendar month
#[utoipa::path(
  get,
  path = "/v1/cookie-bot/usage",
  params(
    ("period" = Option<String>, Query, description = "`YYYY-MM`; defaults to the current UTC month")
  ),
  responses(
    (status = 200, description = "Per-member and per-profile spend", body = crate::cookie_bot::CookieBotUsage),
    (status = 400, description = "Malformed period"),
    (status = 401, description = "Unauthorized"),
    (status = 403, description = "Not signed in, or not a member of that team"),
    (status = 503, description = "Donut cloud could not be reached"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "cookie-bot"
)]
async fn get_cookie_bot_usage(
  Query(query): Query<CookieBotUsageQuery>,
) -> Result<Json<crate::cookie_bot::CookieBotUsage>, (StatusCode, String)> {
  // Reporting, never enforcement: the pooled budget is spent against by the
  // server, and this is how an owner finds out where it went.
  crate::cookie_bot::team_usage(query.period.as_deref())
    .await
    .map(Json)
    .map_err(cookie_bot_error_response)
}

// API Handler - Open URL in existing browser
//
// Works against a profile running here OR one running on the leased fleet: a
// remote session is navigated over the same CDP path the automation tools use,
// so a caller does not have to know where the browser is. The cross-OS refusal
// therefore only applies to a profile that would have to be launched locally.
#[utoipa::path(
  post,
  path = "/v1/profiles/{id}/open-url",
  params(
    ("id" = String, Path, description = "Profile ID")
  ),
  request_body = OpenUrlRequest,
  responses(
    (status = 200, description = "URL opened successfully, locally or on the profile's remote session"),
    (status = 400, description = "Cannot open URL with a cross-OS profile that is not running remotely"),
    (status = 401, description = "Unauthorized"),
    (status = 402, description = "Active paid plan with browser automation required"),
    (status = 404, description = "Profile not found"),
    (status = 409, description = "Profile is locked by another team member, or waiting for a finished remote session to be pulled back"),
    (status = 429, description = "Automation request rate limit exceeded"),
    (status = 503, description = "The profile lock service could not be reached"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "profiles"
)]
async fn open_url_in_profile(
  Path(id): Path<String>,
  State(state): State<ApiServerState>,
  Json(request): Json<OpenUrlRequest>,
) -> Result<StatusCode, (StatusCode, String)> {
  if !crate::cloud_auth::CLOUD_AUTH
    .can_use_browser_automation()
    .await
  {
    return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
  }

  let browser_runner = crate::browser_runner::BrowserRunner::instance();

  browser_runner
    .open_url_with_profile(
      state.app_handle.clone(),
      id,
      request.url,
      crate::launch_gate::FingerprintGate::Advisory,
    )
    .await
    .map_err(manager_error_response)?;

  Ok(StatusCode::OK)
}

// API Handler - Kill browser process
//
// Stops the browser wherever it is. A profile open on the leased fleet is ended
// through the backend, which is what makes this endpoint mean "stop this
// profile" rather than "stop this profile if it happens to be on this machine" —
// the latter reported success, killed nothing, and left the session billing to
// its two-hour cap.
#[utoipa::path(
  post,
  path = "/v1/profiles/{id}/kill",
  params(
    ("id" = String, Path, description = "Profile ID")
  ),
  responses(
    (status = 204, description = "Browser stopped, locally or on the profile's remote session"),
    (status = 401, description = "Unauthorized"),
    (status = 402, description = "Active paid plan required"),
    (status = 404, description = "Profile not found"),
    (status = 429, description = "Automation request rate limit exceeded"),
    (status = 503, description = "The fleet could not be reached; the remote browser is still running"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "profiles"
)]
async fn kill_profile(
  Path(id): Path<String>,
  State(state): State<ApiServerState>,
) -> Result<StatusCode, (StatusCode, String)> {
  // Programmatically launching and stopping profiles is a paid feature; the
  // run/open-url handlers gate the same way.
  if !crate::cloud_auth::CLOUD_AUTH
    .can_use_browser_automation()
    .await
  {
    return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
  }

  let profile_manager = ProfileManager::instance();
  let profiles = profile_manager
    .list_profiles()
    .map_err(manager_error_response)?;

  let profile = profiles
    .iter()
    .find(|p| p.id.to_string() == id)
    .ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?;

  let browser_runner = crate::browser_runner::BrowserRunner::instance();
  browser_runner
    .kill_browser_process(state.app_handle.clone(), profile)
    .await
    .map_err(|e| {
      let message = e.to_string();
      // The backend refuses to retire a session it could not stop on the fleet.
      // Reporting that as a 500 invites a retry loop against a browser that is
      // still running; 503 says "it is still up, try again".
      if message.contains("REMOTE_") {
        (StatusCode::SERVICE_UNAVAILABLE, message)
      } else {
        (StatusCode::INTERNAL_SERVER_ERROR, message)
      }
    })?;

  crate::team_lock::release_team_lock_if_needed(profile).await;

  Ok(StatusCode::NO_CONTENT)
}

// API Handler - Batch run profiles (paid: browser automation). Mirrors the
// single `/run` gate; never breaks the batch on a single profile's failure —
// each profile gets its own result entry.
#[utoipa::path(
  post,
  path = "/v1/profiles/batch/run",
  request_body = BatchRunRequest,
  responses(
    (status = 200, description = "Batch launch completed; inspect per-profile results", body = BatchRunResponse),
    (status = 401, description = "Unauthorized"),
    (status = 402, description = "Active paid plan with browser automation required"),
    (status = 429, description = "Automation request rate limit exceeded"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "profiles"
)]
async fn batch_run_profiles(
  State(state): State<ApiServerState>,
  Json(request): Json<BatchRunRequest>,
) -> Result<Json<BatchRunResponse>, StatusCode> {
  if !crate::cloud_auth::CLOUD_AUTH
    .can_use_browser_automation()
    .await
  {
    return Err(StatusCode::PAYMENT_REQUIRED);
  }

  let headless = request.headless.unwrap_or(false);
  let profile_manager = ProfileManager::instance();
  let profiles = profile_manager
    .list_profiles()
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

  let mut results = Vec::with_capacity(request.profile_ids.len());
  for profile_id in &request.profile_ids {
    let fail = |error: &str| BatchRunResult {
      profile_id: profile_id.clone(),
      ok: false,
      remote_debugging_port: None,
      error: Some(error.to_string()),
    };

    let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else {
      results.push(fail("profile not found"));
      continue;
    };
    if profile.is_cross_os() {
      results.push(fail("cross-OS profiles cannot be launched"));
      continue;
    }
    if crate::team_lock::acquire_team_lock_if_needed(profile)
      .await
      .is_err()
    {
      results.push(fail("profile is locked by another team member"));
      continue;
    }

    let port = match tokio::net::TcpListener::bind("127.0.0.1:0").await {
      Ok(listener) => match listener.local_addr() {
        Ok(addr) => addr.port(),
        Err(_) => {
          results.push(fail("failed to allocate debugging port"));
          continue;
        }
      },
      Err(_) => {
        results.push(fail("failed to allocate debugging port"));
        continue;
      }
    };

    match crate::browser_runner::launch_browser_profile_impl(
      state.app_handle.clone(),
      profile.clone(),
      request.url.clone(),
      crate::browser_runner::LaunchOptions::automation(Some(port), headless),
    )
    .await
    {
      Ok(_) => results.push(BatchRunResult {
        profile_id: profile_id.clone(),
        ok: true,
        remote_debugging_port: Some(port),
        error: None,
      }),
      Err(e) => results.push(fail(&format!("launch failed: {e}"))),
    }
  }

  Ok(Json(BatchRunResponse { results }))
}

// API Handler - Batch stop profiles (paid: browser automation).
#[utoipa::path(
  post,
  path = "/v1/profiles/batch/stop",
  request_body = BatchStopRequest,
  responses(
    (status = 200, description = "Batch stop completed; inspect per-profile results", body = BatchStopResponse),
    (status = 401, description = "Unauthorized"),
    (status = 402, description = "Active paid plan with browser automation required"),
    (status = 429, description = "Automation request rate limit exceeded"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "profiles"
)]
async fn batch_stop_profiles(
  State(state): State<ApiServerState>,
  Json(request): Json<BatchStopRequest>,
) -> Result<Json<BatchStopResponse>, StatusCode> {
  if !crate::cloud_auth::CLOUD_AUTH
    .can_use_browser_automation()
    .await
  {
    return Err(StatusCode::PAYMENT_REQUIRED);
  }

  let profile_manager = ProfileManager::instance();
  let profiles = profile_manager
    .list_profiles()
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
  let browser_runner = crate::browser_runner::BrowserRunner::instance();

  let mut results = Vec::with_capacity(request.profile_ids.len());
  for profile_id in &request.profile_ids {
    let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else {
      results.push(BatchStopResult {
        profile_id: profile_id.clone(),
        ok: false,
        error: Some("profile not found".to_string()),
      });
      continue;
    };

    match browser_runner
      .kill_browser_process(state.app_handle.clone(), profile)
      .await
    {
      Ok(_) => {
        crate::team_lock::release_team_lock_if_needed(profile).await;
        results.push(BatchStopResult {
          profile_id: profile_id.clone(),
          ok: true,
          error: None,
        });
      }
      Err(e) => results.push(BatchStopResult {
        profile_id: profile_id.clone(),
        ok: false,
        error: Some(format!("stop failed: {e}")),
      }),
    }
  }

  Ok(Json(BatchStopResponse { results }))
}

// API Handler - Detect importable browser profiles on this machine, or scan a
// custom folder. Free: importing is not gated behind browser automation.
#[utoipa::path(
  get,
  path = "/v1/profiles/import/detect",
  params(
    ("folder" = Option<String>, Query, description = "Optional folder to scan instead of the default browser locations. Accepts a single profile dir, a Chromium user-data dir, or a folder holding one profile dir per child.")
  ),
  responses(
    (status = 200, description = "Detected importable profiles", body = DetectedProfilesResponse),
    (status = 401, description = "Unauthorized"),
    (status = 404, description = "Folder not found"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "profiles"
)]
async fn detect_import_profiles(
  Query(query): Query<DetectImportQuery>,
  State(_state): State<ApiServerState>,
) -> Result<Json<DetectedProfilesResponse>, (StatusCode, String)> {
  let importer = crate::profile_importer::ProfileImporter::instance();
  let profiles = match query.folder.as_deref() {
    Some(folder) => importer.scan_folder(std::path::Path::new(folder)),
    None => importer.detect_existing_profiles(),
  }
  .map_err(manager_error_response)?;
  let total = profiles.len();
  Ok(Json(DetectedProfilesResponse { profiles, total }))
}

// API Handler - Bulk-import browser profiles from on-disk profile folders.
// Free (parity with create_profile); only fingerprint OS spoofing is Pro.
// Items are isolated — one failure doesn't stop the rest.
#[utoipa::path(
  post,
  path = "/v1/profiles/import",
  request_body = ImportProfilesRequest,
  responses(
    (status = 200, description = "Batch import completed; inspect per-item results", body = crate::profile_importer::ProfileImportBatchResult),
    (status = 400, description = "No items, or invalid input"),
    (status = 401, description = "Unauthorized"),
    (status = 402, description = "Fingerprint OS spoofing requires an active Pro subscription"),
    (status = 404, description = "Group not found"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "profiles"
)]
async fn import_profiles_api(
  State(state): State<ApiServerState>,
  Json(request): Json<ImportProfilesRequest>,
) -> Result<Json<crate::profile_importer::ProfileImportBatchResult>, (StatusCode, String)> {
  // A malformed config is a 400. Dropping it silently also dropped the `os`
  // it carried, which made `is_fingerprint_os_allowed(None)` return true and
  // bypassed the Pro gate below while generating host-OS fingerprints.
  let wayfern_config: Option<crate::wayfern_manager::WayfernConfig> =
    match request.wayfern_config.as_ref() {
      Some(config) => Some(serde_json::from_value(config.clone()).map_err(|e| {
        (
          StatusCode::BAD_REQUEST,
          format!("Invalid wayfern_config: {e}"),
        )
      })?),
      None => None,
    };

  // The Pro gate for fingerprint OS spoofing lives inside import_profiles, so
  // every surface inherits it; manager_error_response maps the code to 402.
  let importer = crate::profile_importer::ProfileImporter::instance();
  importer
    .import_profiles(
      &state.app_handle,
      request.items,
      request.group_id,
      request.duplicate_strategy.unwrap_or_default(),
      wayfern_config,
    )
    .await
    .map(Json)
    .map_err(manager_error_response)
}

#[utoipa::path(
  post,
  path = "/v1/profiles/{id}/cookies/import",
  params(
    ("id" = String, Path, description = "Profile ID")
  ),
  request_body = ImportCookiesRequest,
  responses(
    (status = 200, description = "Cookies imported successfully", body = ImportCookiesResponse),
    (status = 400, description = "Invalid cookie file or unsupported browser"),
    (status = 401, description = "Unauthorized"),
    (status = 404, description = "Profile not found"),
    (status = 409, description = "Browser is currently running"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "cookies"
)]
async fn import_profile_cookies(
  Path(id): Path<String>,
  State(state): State<ApiServerState>,
  Json(request): Json<ImportCookiesRequest>,
) -> Result<Json<ImportCookiesResponse>, StatusCode> {
  let profile_manager = ProfileManager::instance();
  let profiles = profile_manager
    .list_profiles()
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

  if !profiles.iter().any(|p| p.id.to_string() == id) {
    return Err(StatusCode::NOT_FOUND);
  }

  match crate::cookie_manager::CookieManager::import_cookies(
    &state.app_handle,
    &id,
    &request.content,
  )
  .await
  {
    Ok(result) => {
      if let Some(scheduler) = crate::sync::get_global_scheduler() {
        if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == id) {
          if profile.is_sync_enabled() {
            let pid = id.clone();
            tauri::async_runtime::spawn(async move {
              scheduler.queue_profile_sync(pid).await;
            });
          }
        }
      }
      Ok(Json(ImportCookiesResponse {
        cookies_imported: result.cookies_imported,
        cookies_replaced: result.cookies_replaced,
        errors: result.errors,
      }))
    }
    Err(e) => {
      let msg = e.to_lowercase();
      if msg.contains("running") {
        Err(StatusCode::CONFLICT)
      } else if msg.contains("no valid cookies") || msg.contains("unsupported browser") {
        Err(StatusCode::BAD_REQUEST)
      } else {
        Err(StatusCode::INTERNAL_SERVER_ERROR)
      }
    }
  }
}

// API Handler - Download Browser
#[utoipa::path(
  post,
  path = "/v1/browsers/download",
  request_body = DownloadBrowserRequest,
  responses(
    (status = 200, description = "Browser downloaded (or already present)", body = DownloadBrowserResponse),
    (status = 400, description = "Invalid browser or version not available for download"),
    (status = 401, description = "Unauthorized"),
    (status = 409, description = "This browser version is already being downloaded"),
    (status = 500, description = "Internal server error (e.g. network failure)")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "browsers"
)]
async fn download_browser_api(
  State(state): State<ApiServerState>,
  Json(request): Json<DownloadBrowserRequest>,
) -> Result<Json<DownloadBrowserResponse>, (StatusCode, String)> {
  match crate::downloader::download_browser(
    state.app_handle.clone(),
    request.browser.clone(),
    request.version,
  )
  .await
  {
    // Echo the version the downloader actually installed, not the requested one.
    Ok(version) => Ok(Json(DownloadBrowserResponse {
      browser: request.browser,
      version,
      status: "downloaded".to_string(),
    })),
    Err(e) => {
      if e.contains("already being downloaded") {
        Err((StatusCode::CONFLICT, e))
      } else {
        Err(manager_error_response(e))
      }
    }
  }
}

// API Handler - Get Browser Versions
#[utoipa::path(
  get,
  path = "/v1/browsers/{browser}/versions",
  params(
    ("browser" = String, Path, description = "Browser name")
  ),
  responses(
    (status = 200, description = "List of available browser versions", body = Vec<String>),
    (status = 400, description = "Unsupported browser"),
    (status = 401, description = "Unauthorized"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "browsers"
)]
async fn get_browser_versions(
  Path(browser): Path<String>,
  State(_state): State<ApiServerState>,
) -> Result<Json<Vec<String>>, (StatusCode, String)> {
  let version_manager = crate::browser_version_manager::BrowserVersionManager::instance();

  match version_manager
    .fetch_browser_versions_with_count(&browser, false)
    .await
  {
    Ok(result) => Ok(Json(result.versions)),
    Err(e) => Err(manager_error_response(e)),
  }
}

// API Handler - Check if Browser is Downloaded
#[utoipa::path(
  get,
  path = "/v1/browsers/{browser}/versions/{version}/downloaded",
  params(
    ("browser" = String, Path, description = "Browser name"),
    ("version" = String, Path, description = "Browser version")
  ),
  responses(
    (status = 200, description = "Browser download status", body = bool),
    (status = 401, description = "Unauthorized"),
    (status = 500, description = "Internal server error")
  ),
  security(
    ("bearer_auth" = [])
  ),
  tag = "browsers"
)]
async fn check_browser_downloaded(
  Path((browser, version)): Path<(String, String)>,
  State(_state): State<ApiServerState>,
) -> Result<Json<bool>, StatusCode> {
  let is_downloaded = crate::downloaded_browsers_registry::is_browser_downloaded(browser, version);
  Ok(Json(is_downloaded))
}

#[cfg(test)]
mod tests {
  use super::*;
  use crate::profile::types::{BrowserProfile, SyncMode};

  fn profile_with(sync_mode: SyncMode, host_os: Option<&str>) -> BrowserProfile {
    BrowserProfile {
      id: uuid::Uuid::nil(),
      name: "p".to_string(),
      browser: "wayfern".to_string(),
      version: "latest".to_string(),
      sync_mode,
      host_os: host_os.map(|s| s.to_string()),
      ..Default::default()
    }
  }

  // Cloud sync has been settable through PUT /v1/profiles/{id} but was absent
  // from every profile RESPONSE, so a caller could turn it on and never
  // confirm it. A remote-launch caller must be able to see this before it can
  // decide whether the profile exists in cloud storage at all.
  // /run-remote exists precisely so a profile can run on a host of ITS OWN OS
  // when this machine is the wrong one. The gate is cloud sync: a remote host
  // obtains the profile from donut-sync, so a profile that has never synced
  // would launch an empty browser and push that emptiness over the real one.
  #[test]
  fn remote_launch_requires_cloud_sync() {
    let err = remote_launch_profile_rules(&profile_with(SyncMode::Disabled, Some("macos")))
      .expect_err("a non-synced profile must be refused");
    assert!(err.contains("cloud sync"), "unhelpful message: {err}");

    assert!(
      remote_launch_profile_rules(&profile_with(SyncMode::Regular, Some("macos"))).is_ok(),
      "a synced profile must be allowed"
    );
  }

  #[test]
  fn remote_launch_refuses_an_end_to_end_encrypted_profile() {
    // The key is derived from a passphrase that never leaves this machine, so
    // a remote host downloads ciphertext, launches Chromium on it, and pushes
    // the corruption back over the user's real profile. Refusing here also
    // saves taking the profile lock and a slot on leased hardware for a
    // session that cannot possibly work.
    let err = remote_launch_profile_rules(&profile_with(SyncMode::Encrypted, Some("macos")))
      .expect_err("an encrypted profile must be refused");
    assert!(
      err.contains("encrypted") && err.contains("Regular"),
      "the message must say what to change: {err}"
    );
  }

  #[test]
  fn remote_launch_requires_a_known_operating_system() {
    // Without one there is no way to pick a matching host, and guessing would
    // be the cross-OS mismatch this whole design exists to prevent.
    assert!(remote_launch_profile_rules(&profile_with(SyncMode::Regular, None)).is_err());
  }

  #[test]
  fn remote_launch_allows_a_cross_os_profile() {
    let host = crate::profile::types::get_host_os();
    let other = if host == "windows" {
      "macos"
    } else {
      "windows"
    };
    let foreign = profile_with(SyncMode::Regular, Some(other));

    assert!(
      foreign.is_cross_os(),
      "test setup: profile should be foreign"
    );
    // Local /run refuses this; running it remotely on a host of its own OS is
    // exactly what /run-remote is for.
    assert!(remote_launch_profile_rules(&foreign).is_ok());
  }

  #[tokio::test]
  async fn remote_launch_is_refused_while_the_profile_is_mid_upload() {
    // The manifest is written last. A host that pulls during the upload gets
    // files that are about to be replaced described by a manifest that does
    // not match them, launches Chromium on that, and pushes the result back
    // over the real profile.
    let scheduler = std::sync::Arc::new(crate::sync::SyncScheduler::new());
    crate::sync::set_global_scheduler(scheduler.clone());

    let mut profile = profile_with(SyncMode::Regular, Some("macos"));
    profile.id = uuid::Uuid::new_v4();
    assert!(
      remote_launch_precondition(&profile).await.is_ok(),
      "an idle profile must be launchable"
    );

    scheduler.queue_profile_sync(profile.id.to_string()).await;
    let err = remote_launch_precondition(&profile)
      .await
      .expect_err("a profile mid-upload must be refused");
    assert!(
      err.contains("REMOTE_SYNC_IN_PROGRESS"),
      "the refusal must be a code the frontend can translate: {err}"
    );
  }

  #[test]
  fn api_profile_exposes_cloud_sync_state() {
    let disabled = ApiProfile::from(&profile_with(SyncMode::Disabled, None));
    assert_eq!(disabled.sync_mode, "Disabled");
    assert!(!disabled.cloud_sync_enabled);

    let regular = ApiProfile::from(&profile_with(SyncMode::Regular, None));
    assert_eq!(regular.sync_mode, "Regular");
    assert!(regular.cloud_sync_enabled);

    let encrypted = ApiProfile::from(&profile_with(SyncMode::Encrypted, None));
    assert_eq!(encrypted.sync_mode, "Encrypted");
    assert!(encrypted.cloud_sync_enabled);
  }

  // A profile must only ever run on its own operating system: Chromium's
  // on-disk state is OS-specific, so replaying a macOS profile on Windows is a
  // mismatch no amount of user-agent spoofing repairs.
  #[test]
  fn api_profile_reports_its_operating_system() {
    let host = crate::profile::types::get_host_os();
    let same = ApiProfile::from(&profile_with(SyncMode::Regular, Some(&host)));
    assert_eq!(same.host_os.as_deref(), Some(host.as_str()));
    assert!(!same.is_cross_os);

    let other = if host == "windows" {
      "macos"
    } else {
      "windows"
    };
    let foreign = ApiProfile::from(&profile_with(SyncMode::Regular, Some(other)));
    assert_eq!(foreign.host_os.as_deref(), Some(other));
    assert!(foreign.is_cross_os);
  }

  #[test]
  fn api_profile_without_a_recorded_os_is_not_cross_os() {
    // An older profile that predates host_os must stay locally launchable
    // rather than being treated as foreign.
    let unknown = ApiProfile::from(&profile_with(SyncMode::Disabled, None));
    assert_eq!(unknown.host_os, None);
    assert!(!unknown.is_cross_os);
  }

  // Removing `browser` from UpdateProfileRequest, and rejecting invalid
  // `browser` values on create, must NOT make the API reject requests that
  // carry extra/unknown fields — old clients still send them. serde ignores
  // unknown fields by default; these tests lock that in so a future
  // `#[serde(deny_unknown_fields)]` can't silently break compatibility.
  #[test]
  fn update_profile_request_ignores_unknown_fields() {
    // `browser` is no longer a field, plus a wholly unknown field. Both must
    // be accepted and ignored, not rejected.
    let json = r#"{"name": "p", "browser": "wayfern", "totally_unknown": 123}"#;
    let parsed: UpdateProfileRequest =
      serde_json::from_str(json).expect("unknown fields must be ignored, not rejected");
    assert_eq!(parsed.name.as_deref(), Some("p"));
  }

  #[test]
  fn create_profile_request_ignores_unknown_fields() {
    let json = r#"{"name": "p", "browser": "wayfern", "version": "latest", "future_field": true}"#;
    let parsed: CreateProfileRequest =
      serde_json::from_str(json).expect("unknown fields must be ignored, not rejected");
    assert_eq!(parsed.browser, "wayfern");
  }

  #[test]
  fn create_profile_request_allows_omitting_version_and_configs() {
    // Minimal body: no version, no wayfern_config. Must
    // deserialize (version resolves to latest-downloaded at the handler; an
    // absent config triggers fresh-fingerprint generation).
    let json = r#"{"name": "p", "browser": "wayfern"}"#;
    let parsed: CreateProfileRequest =
      serde_json::from_str(json).expect("version and configs are optional");
    assert_eq!(parsed.browser, "wayfern");
    assert!(parsed.version.is_none());
    assert!(parsed.wayfern_config.is_none());
  }

  #[test]
  fn wayfern_config_os_survives_the_untyped_request_field() {
    // `wayfern_config` arrives as an untyped Value and is only turned into a
    // WayfernConfig inside the handler. That second hop is where an `os` used
    // to be lost, so assert it round-trips.
    let json = r#"{"name": "p", "browser": "wayfern", "wayfern_config": {"os": "android"}}"#;
    let parsed: CreateProfileRequest = serde_json::from_str(json).expect("body must parse");
    let config: crate::wayfern_manager::WayfernConfig =
      serde_json::from_value(parsed.wayfern_config.expect("config present"))
        .expect("a well-formed config must parse");
    assert_eq!(config.os.as_deref(), Some("android"));
  }

  #[test]
  fn malformed_wayfern_config_is_an_error_not_a_default() {
    // `fingerprint` is a JSON-encoded string, so passing an object fails to
    // parse. The handler must surface that as a 400: previously `.ok()` threw
    // the whole config away, dropping the caller's `os` with it and returning
    // a host-OS profile with 200 and no diagnostic.
    let json = r#"{"os": "android", "fingerprint": {"platform": "Linux armv81"}}"#;
    let value: serde_json::Value = serde_json::from_str(json).expect("value parses");
    let parsed = serde_json::from_value::<crate::wayfern_manager::WayfernConfig>(value);
    assert!(
      parsed.is_err(),
      "an object fingerprint must not silently deserialize"
    );
  }

  #[test]
  fn api_profile_exposes_the_fingerprint_os_separately_from_host_os() {
    // host_os is the machine; fingerprint_os is what the browser reports. A
    // cross-OS profile has to be distinguishable through the API alone.
    let spec = ApiDoc::openapi();
    let spec = serde_json::to_value(&spec).expect("spec serializes");
    let props = &spec["components"]["schemas"]["ApiProfile"]["properties"];
    assert!(
      props.get("fingerprint_os").is_some(),
      "ApiProfile must publish fingerprint_os"
    );
    let required = spec["components"]["schemas"]["ApiProfile"]["required"]
      .as_array()
      .cloned()
      .unwrap_or_default();
    assert!(
      !required.iter().any(|r| r == "fingerprint_os"),
      "fingerprint_os is nullable and must stay optional"
    );
  }

  #[test]
  fn create_profile_browser_validation_matches_supported_engines() {
    // The handler rejects anything that isn't a launchable engine; this is the
    // same predicate it uses, kept in lockstep with MCP's create_profile.
    let is_valid = |b: &str| b == "wayfern";
    assert!(is_valid("wayfern"));
    assert!(!is_valid("chromium"));
    assert!(!is_valid(""));
  }

  #[test]
  fn rate_limit_only_classifies_browser_automation_routes() {
    for path in [
      "/v1/profiles/profile-id/run",
      "/v1/profiles/profile-id/open-url",
      "/v1/profiles/profile-id/kill",
      // Launching on leased remote hardware is the most expensive automation
      // action there is; it went unmetered because `run-remote` is its own
      // path segment and never matched `run`.
      "/v1/profiles/profile-id/run-remote",
      "/v1/profiles/batch/run",
      "/v1/profiles/batch/stop",
      // Starting a bot run leases a host for up to two hours and spends the
      // account's pooled remote-hour budget.
      "/v1/cookie-bot/runs",
    ] {
      assert!(
        is_automation_request(&Method::POST, path),
        "automation route was not limited: {path}"
      );
    }

    // Stopping a remote session is a DELETE, and its handler declares a 429.
    // Cancelling a bot run reaches the same fleet and is metered the same way.
    for path in [
      "/v1/remote-sessions/session-id",
      "/v1/cookie-bot/runs/run-id",
    ] {
      assert!(
        is_automation_request(&Method::DELETE, path),
        "metered stop was not limited: {path}"
      );
    }

    for (method, path) in [
      (Method::GET, "/v1/profiles/profile-id/run"),
      (Method::POST, "/v1/profiles"),
      (Method::POST, "/v1/profiles/import"),
      (Method::GET, "/v1/profiles"),
      (Method::GET, "/openapi.json"),
      // Only the single-session DELETE is automation; the collection is not a
      // route, and a GET of one never launches anything.
      (Method::DELETE, "/v1/remote-sessions/"),
      (Method::GET, "/v1/remote-sessions/session-id"),
      (Method::GET, "/v1/remote-sessions"),
      // Enrolling a profile writes one row on the server and leases nothing.
      // Metering it would 429 a client setting up a fleet of profiles, while
      // the budget that actually protects the hardware is spent per RUN and
      // enforced server-side however the run was scheduled.
      (Method::PUT, "/v1/cookie-bot/schedules/profile-id"),
      (Method::DELETE, "/v1/cookie-bot/schedules/profile-id"),
      (Method::GET, "/v1/cookie-bot/schedules"),
      (Method::GET, "/v1/cookie-bot/runs"),
      (Method::GET, "/v1/cookie-bot/usage"),
      (Method::GET, "/v1/remote-hours"),
      // A run id is required; the collection DELETE is not a route.
      (Method::DELETE, "/v1/cookie-bot/runs/"),
      // Extension writes touch this machine's own store. They start no
      // browser, so metering them would spend an automation client's quota on
      // uploading a `.crx`.
      (Method::POST, "/v1/extensions"),
      (Method::PUT, "/v1/extensions/extension-id"),
      (Method::DELETE, "/v1/extensions/extension-id"),
      (Method::POST, "/v1/extension-groups"),
      (
        Method::POST,
        "/v1/extension-groups/group-id/extensions/extension-id",
      ),
      (
        Method::DELETE,
        "/v1/extension-groups/group-id/extensions/extension-id",
      ),
    ] {
      assert!(
        !is_automation_request(&method, path),
        "free or non-mutating route was limited: {method} {path}"
      );
    }
  }

  // The bot exists only on the leased fleet. Every write surface resolves the
  // profile through `bot_precondition` first, so there is no request shape on
  // this server that points it at a profile which could never make the round
  // trip to a remote host and back.
  #[test]
  fn a_profile_the_bot_could_never_run_is_refused_before_the_cloud_is_asked() {
    let mut local_only = profile_with(SyncMode::Disabled, Some("macos"));
    local_only.proxy_id = Some("proxy-1".to_string());
    assert!(
      crate::cookie_bot::bot_precondition(
        &local_only,
        &crate::remote_exit::ExitReachability::Remote
      )
      .is_err(),
      "a profile with no cloud copy has nothing for a host to open"
    );

    let mut encrypted = profile_with(SyncMode::Encrypted, Some("macos"));
    encrypted.proxy_id = Some("proxy-1".to_string());
    assert!(
      crate::cookie_bot::bot_precondition(
        &encrypted,
        &crate::remote_exit::ExitReachability::Remote
      )
      .is_err(),
      "a host cannot decrypt a profile whose key never leaves this machine"
    );

    let mut datacenter_egress = profile_with(SyncMode::Regular, Some("macos"));
    datacenter_egress.proxy_id = None;
    datacenter_egress.vpn_id = None;
    assert!(
      crate::cookie_bot::bot_precondition(
        &datacenter_egress,
        &crate::remote_exit::ExitReachability::None
      )
      .is_err(),
      "hours of traffic from a hosting ASN damages the identity being warmed"
    );

    let mut eligible = profile_with(SyncMode::Regular, Some("macos"));
    eligible.proxy_id = Some("proxy-1".to_string());
    assert!(crate::cookie_bot::bot_precondition(
      &eligible,
      &crate::remote_exit::ExitReachability::Remote
    )
    .is_ok());
  }

  #[test]
  fn a_cloud_401_is_not_reported_as_the_callers_own_token_being_wrong() {
    // The caller's bearer token was accepted — the auth middleware ran. It is
    // THIS desktop that has no cloud session, and answering 401 would send an
    // automation client off to rotate a token that is perfectly good.
    assert_eq!(
      cloud_failure_status(401, crate::cloud_errors::NOT_SIGNED_IN),
      StatusCode::FORBIDDEN
    );
  }

  #[test]
  fn the_clouds_403_splits_into_the_two_things_it_means() {
    // "Your plan does not include this" is the 402 this API uses everywhere
    // else; "you are not in that team" is not something a payment fixes.
    assert_eq!(
      cloud_failure_status(403, "COOKIE_BOT_NOT_ENTITLED"),
      StatusCode::PAYMENT_REQUIRED
    );
    assert_eq!(
      cloud_failure_status(403, "NOT_TEAM_MEMBER"),
      StatusCode::FORBIDDEN
    );
  }

  #[test]
  fn spending_the_pooled_hours_is_a_payment_problem_not_a_server_fault() {
    // It arrives as a 403 with a code. Reporting it as a plain forbidden would
    // hide the one thing the user can act on.
    assert_eq!(
      cloud_failure_status(403, "REMOTE_HOURS_EXHAUSTED"),
      StatusCode::PAYMENT_REQUIRED
    );
  }

  #[test]
  fn a_busy_or_unreachable_fleet_is_never_reported_as_broken() {
    // 503 means "try again shortly". Turning it into a 500 tells the user
    // their automation is broken when nothing is.
    assert_eq!(
      cloud_failure_status(503, crate::cloud_errors::NO_CAPACITY),
      StatusCode::SERVICE_UNAVAILABLE
    );
    assert_eq!(
      cloud_failure_status(0, crate::cloud_errors::UNREACHABLE),
      StatusCode::SERVICE_UNAVAILABLE
    );
    assert_eq!(
      cloud_failure_status(429, crate::cloud_errors::RATE_LIMITED),
      StatusCode::TOO_MANY_REQUESTS
    );
  }

  #[test]
  fn a_missing_schedule_and_a_missing_run_both_stay_a_404() {
    assert_eq!(
      cloud_failure_status(404, "COOKIE_BOT_NOT_ENROLLED"),
      StatusCode::NOT_FOUND
    );
    assert_eq!(
      cloud_failure_status(404, "COOKIE_BOT_RUN_NOT_FOUND"),
      StatusCode::NOT_FOUND
    );
    assert_eq!(
      cloud_failure_status(409, "COOKIE_BOT_SCHEDULE_CONFLICT"),
      StatusCode::CONFLICT
    );
    assert_eq!(
      cloud_failure_status(400, "COOKIE_BOT_INVALID_SCHEDULE"),
      StatusCode::BAD_REQUEST
    );
  }

  #[test]
  fn a_read_of_a_session_that_does_not_exist_is_a_404_not_a_500() {
    // The launch mapping folds every unrecognised status into 500, which for a
    // read means "no such session" is indistinguishable from "our backend
    // fell over".
    let missing =
      crate::remote_session::classify_backend_status(404, r#"{"code":"REMOTE_SESSION_NOT_FOUND"}"#);
    let (status, body) = remote_session_read_response(missing);
    assert_eq!(status, StatusCode::NOT_FOUND);
    assert_eq!(error_code_of(&body), "REMOTE_SESSION_NOT_FOUND");
  }

  #[test]
  fn a_read_answers_with_a_code_rather_than_the_backends_english() {
    let busy = crate::remote_session::classify_backend_status(503, "no macos host has a free slot");
    let (status, body) = remote_session_read_response(busy);
    assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
    assert_eq!(error_code_of(&body), crate::cloud_errors::NO_CAPACITY);
  }

  // Axum panics when two handlers claim one path, and the router is only built
  // when the API server is switched on — so a conflict introduced here would
  // ship as an app that dies the first time a user enables the API. Both
  // `/v1/remote-sessions/{id}` and `/v1/cookie-bot/schedules/{profile_id}` now
  // carry several methods, which is exactly the shape that trips it.
  #[test]
  fn every_v1_route_can_be_registered_together() {
    let _router: Router<ApiServerState> = build_v1_router();
  }

  fn schema_required(spec: &serde_json::Value, schema: &str) -> Vec<String> {
    spec["components"]["schemas"][schema]["required"]
      .as_array()
      .map(|a| {
        a.iter()
          .filter_map(|v| v.as_str().map(str::to_string))
          .collect()
      })
      .unwrap_or_default()
  }

  // `#[schema(value_type = Object)]` on an `Option<T>` erases the optionality
  // and marks the field required in the served spec; these fields must stay
  // optional so generated clients aren't forced to send them.
  #[test]
  fn openapi_optional_fields_are_not_required() {
    let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");

    let create_profile = schema_required(&spec, "CreateProfileRequest");
    assert!(
      !create_profile.iter().any(|f| f == "wayfern_config"),
      "wayfern_config must be optional, required list: {create_profile:?}"
    );

    // `ApiProfile` is the response body of every profile-returning route, so a
    // wrongly-required `group_id` makes generated clients assume a group is
    // always present on an ungrouped profile.
    let api_profile = schema_required(&spec, "ApiProfile");
    assert!(
      !api_profile.iter().any(|f| f == "group_id"),
      "group_id must be optional on ApiProfile, required list: {api_profile:?}"
    );
    assert_eq!(
      spec["components"]["schemas"]["ApiProfile"]["properties"]["group_id"]["type"],
      serde_json::json!(["string", "null"]),
      "group_id must be a nullable string, not a free-form object"
    );

    let update_profile = schema_required(&spec, "UpdateProfileRequest");
    assert!(
      !update_profile.iter().any(|f| f == "group_id"),
      "group_id must be optional, required list: {update_profile:?}"
    );

    let update_proxy = schema_required(&spec, "UpdateProxyRequest");
    assert!(
      !update_proxy.iter().any(|f| f == "proxy_settings"),
      "proxy_settings must be optional on update, required list: {update_proxy:?}"
    );

    let proxy_settings = schema_required(&spec, "ProxySettings");
    for field in ["username", "password", "vless_uri"] {
      assert!(
        !proxy_settings.iter().any(|candidate| candidate == field),
        "{field} must be optional in proxy settings, required list: {proxy_settings:?}"
      );
      assert!(
        spec["components"]["schemas"]["ProxySettings"]["properties"][field].is_object(),
        "{field} must be present in the served ProxySettings schema"
      );
    }

    let import_profiles = schema_required(&spec, "ImportProfilesRequest");
    for field in ["group_id", "duplicate_strategy", "wayfern_config"] {
      assert!(
        !import_profiles.iter().any(|f| f == field),
        "{field} must be optional on profile import, required list: {import_profiles:?}"
      );
    }

    let import_item = schema_required(&spec, "ImportProfileItem");
    for field in ["proxy_id", "vpn_id", "browser_type", "allow_running"] {
      assert!(
        !import_item.iter().any(|f| f == field),
        "{field} must be optional on import items, required list: {import_item:?}"
      );
    }

    // The per-item report only exists for items that actually imported.
    let import_result = schema_required(&spec, "ProfileImportItemResult");
    assert!(
      !import_result.iter().any(|f| f == "report"),
      "report must be optional on import results, required list: {import_result:?}"
    );

    // `ProfileImportItemResult` references it, so a missing registration would
    // leave a dangling $ref in the served spec.
    assert!(
      spec
        .pointer("/components/schemas/ProfileImportReport")
        .is_some(),
      "ProfileImportReport must be registered in ApiDoc components"
    );

    // A remote launch with no URL just opens the browser; forcing generated
    // clients to send one would make the common case the awkward one.
    let run_remote = schema_required(&spec, "RunRemoteRequest");
    assert!(
      !run_remote.iter().any(|f| f == "url"),
      "url must be optional on a remote launch, required list: {run_remote:?}"
    );

    // A run-now with no cap inherits the schedule's own.
    let start_run = schema_required(&spec, "StartCookieBotRunRequest");
    assert!(
      start_run.iter().any(|f| f == "profile_id"),
      "profile_id is the one thing a run cannot infer, required list: {start_run:?}"
    );
    assert!(
      !start_run.iter().any(|f| f == "max_minutes"),
      "max_minutes must be optional, required list: {start_run:?}"
    );

    // This machine already knows the profile's name and operating system, and
    // a caller-supplied platform that disagrees is refused rather than
    // honoured — so neither may be marked required.
    let set_schedule = schema_required(&spec, "SetCookieBotScheduleRequest");
    for field in [
      "profile_name",
      "platform",
      "jitter_seconds",
      "sites",
      "acknowledge_conflict",
    ] {
      assert!(
        !set_schedule.iter().any(|f| f == field),
        "{field} must be optional when enrolling, required list: {set_schedule:?}"
      );
    }

    // A freshly created enrolment has never run, so every one of these is
    // absent on the first read. Marking them required would make a generated
    // client reject the response it gets immediately after enrolling.
    let schedule = schema_required(&spec, "CookieBotSchedule");
    for field in ["next_run_at", "last_run_at", "last_run_id", "updated_at"] {
      assert!(
        !schedule.iter().any(|f| f == field),
        "{field} must be optional on a schedule, required list: {schedule:?}"
      );
    }

    let run = schema_required(&spec, "CookieBotRun");
    for field in ["started_at", "ended_at", "outcome_code", "session_id"] {
      assert!(
        !run.iter().any(|f| f == field),
        "{field} must be optional on a run, required list: {run:?}"
      );
    }

    // Only `session_id` and `status` are guaranteed while a session is still
    // provisioning; everything else arrives as the session progresses.
    let session = schema_required(&spec, "RemoteSessionState");
    for field in [
      "profile_id",
      "platform",
      "kind",
      "run_id",
      "started_at",
      "ready_at",
      "closed_at",
      "close_reason",
      "billed_seconds",
    ] {
      assert!(
        !session.iter().any(|f| f == field),
        "{field} must be optional on a session, required list: {session:?}"
      );
    }

    // The route predates the pooled budget and returned only two keys. A
    // deployment that has not rolled forward must still satisfy the spec.
    let quota = schema_required(&spec, "RemoteHoursQuota");
    for field in ["members", "breakdown", "scope", "team_id", "seats"] {
      assert!(
        !quota.iter().any(|f| f == field),
        "{field} must be optional on the quota, required list: {quota:?}"
      );
    }

    // An extension arrives either as an upload or as a path, and its name is
    // usually read from the manifest. Marking any one of these required would
    // make a generated client send a field that contradicts the source it
    // actually has.
    for request in ["CreateExtensionRequest", "UpdateExtensionRequest"] {
      let fields = schema_required(&spec, request);
      assert!(
        fields.is_empty(),
        "every field of {request} must be optional, required list: {fields:?}"
      );
    }

    let update_extension_group = schema_required(&spec, "UpdateExtensionGroupRequest");
    for field in ["name", "extension_ids"] {
      assert!(
        !update_extension_group.iter().any(|f| f == field),
        "{field} must be optional on a group update, required list: {update_extension_group:?}"
      );
    }

    // A group cannot be created without one.
    let create_extension_group = schema_required(&spec, "CreateExtensionGroupRequest");
    assert!(
      create_extension_group.iter().any(|f| f == "name"),
      "name is required to create a group, required list: {create_extension_group:?}"
    );

    // `linked_path` is only set for an extension loaded in place, and every
    // extension stored before unpacked support has neither it nor a
    // `source_kind`, so a required marking would break reading them back.
    let extension = schema_required(&spec, "Extension");
    for field in ["linked_path", "version", "description", "author"] {
      assert!(
        !extension.iter().any(|f| f == field),
        "{field} must be optional on an extension, required list: {extension:?}"
      );
    }
  }

  #[test]
  fn import_profiles_request_allows_minimal_body() {
    // Only items with source_path + new_profile_name are required; everything
    // else has defaults.
    let json = r#"{"items": [{"source_path": "/tmp/p", "new_profile_name": "Imported"}]}"#;
    let parsed: ImportProfilesRequest =
      serde_json::from_str(json).expect("minimal import body must deserialize");
    assert_eq!(parsed.items.len(), 1);
    assert!(parsed.group_id.is_none());
    assert!(parsed.duplicate_strategy.is_none());
    assert_eq!(parsed.items[0].browser_type, "chromium");
    assert_eq!(parsed.items[0].allow_running, None);
  }

  // The served /openapi.json comes from the hand-maintained ApiDoc `paths(...)`
  // list, not from the router — endpoints registered on the router but missing
  // from ApiDoc silently disappear from the spec. Lock in the ones that were
  // once dropped, and that removed endpoints stay gone.
  #[test]
  fn a_profile_held_elsewhere_is_a_conflict_not_a_bad_request() {
    // These four refusals all mean "come back in a moment". Answering 400 tells
    // an automation client its request was malformed and to stop retrying, and
    // that is what every one of them did before they had codes at all.
    for code in [
      "PROFILE_RUNNING_REMOTELY",
      "PROFILE_REMOTE_SYNC_PENDING",
      "PROFILE_LOCKED_BY_MEMBER",
      "PROFILE_LOCKED_ELSEWHERE",
    ] {
      let (status, body) = manager_error_response(serde_json::json!({ "code": code }).to_string());
      assert_eq!(status, StatusCode::CONFLICT, "{code} must be a 409");
      assert!(body.contains(code), "{code} must reach the caller");
    }
  }

  #[test]
  fn an_unreachable_lock_service_is_not_the_callers_fault() {
    let (status, _) =
      manager_error_response(serde_json::json!({ "code": "PROFILE_LOCK_UNAVAILABLE" }).to_string());
    assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
  }

  #[test]
  fn a_rejected_extension_upload_is_the_callers_problem_not_a_server_fault() {
    // Every one of these is something about the request: the wrong file type, a
    // folder with no readable manifest, a link asked for on a file. Answering
    // 500 would tell a client to retry a body that can never be accepted.
    for code in [
      "EXTENSION_UNSUPPORTED_FILE_TYPE",
      "EXTENSION_NOT_A_DIRECTORY",
      "EXTENSION_MANIFEST_MISSING",
      "EXTENSION_MANIFEST_INVALID",
      "EXTENSION_DIR_TOO_LARGE",
      "EXTENSION_PATH_HAS_COMMA",
      "EXTENSION_LINK_REQUIRES_DIRECTORY",
      "NAME_CANNOT_BE_EMPTY",
    ] {
      let (status, body) = manager_error_response(serde_json::json!({ "code": code }).to_string());
      assert_eq!(status, StatusCode::BAD_REQUEST, "{code} must be a 400");
      assert!(body.contains(code), "{code} must reach the caller");
    }

    // A path that is not there is the one refusal that names a missing thing.
    let (status, _) =
      manager_error_response(serde_json::json!({ "code": "EXTENSION_DIR_NOT_FOUND" }).to_string());
    assert_eq!(status, StatusCode::NOT_FOUND);
  }

  #[test]
  fn an_incompatible_extension_group_is_refused_rather_than_reported_as_broken() {
    // `validate_group_compatibility` answers in prose, so both of its refusals
    // fell through to 500 — which reads as "this server is broken" for what is
    // really a group the caller cannot put on that profile.
    for message in [
      "Extension 'uBlock' (crx) is not compatible with gecko browsers",
      "Extensions are not supported for browser 'firefox'",
    ] {
      let (status, body) = manager_error_response(message);
      assert_eq!(status, StatusCode::BAD_REQUEST, "{message} must be a 400");
      assert_eq!(body, message, "the diagnostic must reach the caller");
    }

    // A group that is simply absent stays a 404.
    let (status, _) = manager_error_response("Extension group with id 'gone' not found");
    assert_eq!(status, StatusCode::NOT_FOUND);
  }

  #[test]
  fn an_extension_write_names_exactly_one_source() {
    use base64::Engine as _;

    let encoded = base64::engine::general_purpose::STANDARD.encode(b"PK\x03\x04");

    // An upload decodes to the bytes the caller sent.
    match resolve_extension_source(
      Some("ublock.crx".to_string()),
      Some(encoded.clone()),
      None,
      None,
    ) {
      Ok(Some(ExtensionSource::Upload { file_name, data })) => {
        assert_eq!(file_name, "ublock.crx");
        assert_eq!(data, b"PK\x03\x04");
      }
      other => panic!("an upload must resolve to its bytes: {other:?}"),
    }

    // A path is taken as-is, and `link` rides with it.
    match resolve_extension_source(None, None, Some("/srv/ext".to_string()), Some(true)) {
      Ok(Some(ExtensionSource::LocalPath { path, link })) => {
        assert_eq!(path, std::path::PathBuf::from("/srv/ext"));
        assert!(link);
      }
      other => panic!("a path must resolve to a path: {other:?}"),
    }

    // Naming both sources has no answer: neither one is guessed at.
    let both = resolve_extension_source(
      Some("ublock.crx".to_string()),
      Some(encoded.clone()),
      Some("/srv/ext".to_string()),
      None,
    );
    assert_eq!(
      both.expect_err("two sources must be refused"),
      extension_request_error("EXTENSION_SOURCE_REQUIRED")
    );

    // Half an upload is not a source. Storing an empty payload, or inventing a
    // file name, would both produce an extension that never loads.
    for half in [
      (Some("ublock.crx".to_string()), None),
      (None, Some(encoded.clone())),
    ] {
      let (file_name, data) = half;
      assert_eq!(
        resolve_extension_source(file_name, data, None, None)
          .expect_err("half an upload must be refused"),
        extension_request_error("EXTENSION_SOURCE_REQUIRED")
      );
    }

    // Linking means "load the folder where it is"; an upload has no folder.
    assert_eq!(
      resolve_extension_source(
        Some("ublock.crx".to_string()),
        Some(encoded),
        None,
        Some(true)
      )
      .expect_err("a linked upload must be refused"),
      extension_request_error("EXTENSION_LINK_REQUIRES_DIRECTORY")
    );

    // Undecodable base64 is named as such rather than reaching the store as an
    // empty or truncated archive.
    assert_eq!(
      resolve_extension_source(
        Some("ublock.crx".to_string()),
        Some("not base64!!".to_string()),
        None,
        None
      )
      .expect_err("undecodable base64 must be refused"),
      extension_request_error("EXTENSION_INVALID_BASE64")
    );

    // No source at all is legal on the wire — it is a rename, and only the
    // update handler accepts it.
    assert!(resolve_extension_source(None, None, None, None)
      .expect("naming no source is not an error here")
      .is_none());

    // An empty string is not a path.
    assert!(
      resolve_extension_source(None, None, Some("  ".to_string()), None)
        .expect("a blank path is not an error here")
        .is_none()
    );
  }

  #[test]
  fn an_extension_update_that_asks_for_nothing_is_refused() {
    // Every field is optional, so an empty body parses. Answering 200 with an
    // untouched extension would tell a client its rename landed.
    let empty: UpdateExtensionRequest =
      serde_json::from_str("{}").expect("an empty update body must deserialize");
    assert!(empty.name.is_none());
    assert!(resolve_extension_source(
      empty.file_name,
      empty.file_data_base64,
      empty.source_path,
      empty.link
    )
    .expect("no source is not an error on update")
    .is_none());
  }

  #[test]
  fn creating_an_extension_needs_neither_a_name_nor_a_link() {
    // The manifest's own name is preferred, so a caller uploading a `.crx`
    // sends two fields and nothing else.
    let minimal: CreateExtensionRequest =
      serde_json::from_str(r#"{"file_name": "ublock.crx", "file_data_base64": "UEsDBA=="}"#)
        .expect("a minimal create body must deserialize");
    assert!(minimal.name.is_none());
    assert!(minimal.link.is_none());
    assert_eq!(minimal.file_name.as_deref(), Some("ublock.crx"));
  }

  #[test]
  fn a_remote_session_exposes_a_cdp_endpoint_an_external_client_can_attach_to() {
    // Without this route `run-remote` hands back a session id that nothing
    // outside the app can use: the fleet relay accepts only the user's cloud
    // credential, which an API consumer does not have and must not be given.
    // A Playwright user reads the spec to find this, so it has to be in it.
    let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
    let operation = &spec["paths"]["/v1/remote-sessions/{id}/cdp"]["get"];
    assert!(
      operation.is_object(),
      "the CDP attach endpoint must be in the served spec"
    );
    assert!(
      operation["responses"].get("101").is_some(),
      "a WebSocket endpoint must document its upgrade"
    );
    assert_eq!(operation["tags"][0], "remote-sessions");
  }

  #[test]
  fn a_cdp_attach_failure_is_not_reported_as_a_broken_relay() {
    // A CDP client retries a 502 and gives up on a 404. Reporting "this session
    // is not up yet" as a gateway failure sends it into a loop against a
    // session that is doing exactly what it should.
    use crate::cdp_target::CdpError;
    assert_eq!(
      cdp_error_response(CdpError::NotDrivable("provisioning".into())).0,
      StatusCode::NOT_FOUND
    );
    assert_eq!(
      cdp_error_response(CdpError::Unauthorized("no token".into())).0,
      StatusCode::UNAUTHORIZED
    );
    assert_eq!(
      cdp_error_response(CdpError::Unreachable("dns".into())).0,
      StatusCode::BAD_GATEWAY
    );
  }

  #[test]
  fn the_kill_route_documents_that_it_can_fail_to_stop_a_remote_browser() {
    // The backend refuses to retire a session it could not stop on the fleet, so
    // stopping can genuinely fail with the browser still running. A spec that
    // only lists 204 tells a client that never happens.
    let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
    let responses = &spec["paths"]["/v1/profiles/{id}/kill"]["post"]["responses"];
    assert!(
      responses.get("503").is_some(),
      "kill must document that the fleet may be unreachable"
    );
  }

  #[test]
  fn the_local_launch_routes_document_their_conflict() {
    // A profile waiting on a finished remote session refuses a local launch.
    // Undocumented, that reaches an integrator as an unexplained 409.
    let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
    for path in ["/v1/profiles/{id}/run", "/v1/profiles/{id}/open-url"] {
      let responses = &spec["paths"][path]["post"]["responses"];
      assert!(
        responses.get("409").is_some(),
        "{path} must document its conflict"
      );
    }
  }

  #[test]
  fn openapi_spec_covers_registered_routes() {
    let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
    let paths = spec["paths"].as_object().expect("paths object");

    for path in [
      "/v1/vpns/{id}/export",
      "/v1/extensions",
      "/v1/extension-groups",
      "/v1/extensions/{id}",
      "/v1/extension-groups/{id}",
      "/v1/extension-groups/{id}/extensions/{extension_id}",
      "/v1/profiles/import",
      "/v1/profiles/import/detect",
      "/v1/proxies/import",
      // The whole remote-execution surface was registered on the router but
      // absent from ApiDoc, so it never appeared in the served spec. This list
      // is a hand-maintained allowlist, which is exactly why that drift went
      // unnoticed — every route added here must also be added below.
      "/v1/profiles/{id}/run-remote",
      "/v1/profiles/{id}/cloud-sync",
      "/v1/remote-sessions/{id}",
      // Remote-session observability and the whole cookie-bot surface. Same
      // hazard, so the same guard: registered on the router is not registered
      // in the spec, and the spec is what an automation client is written from.
      "/v1/remote-sessions",
      "/v1/remote-hours",
      "/v1/cookie-bot/schedules",
      "/v1/cookie-bot/schedules/{profile_id}",
      "/v1/cookie-bot/conflicts",
      "/v1/cookie-bot/runs",
      "/v1/cookie-bot/runs/{run_id}",
      "/v1/cookie-bot/presets",
      "/v1/cookie-bot/usage",
    ] {
      assert!(paths.contains_key(path), "missing from ApiDoc: {path}");
    }

    // Every method of every shared path must survive. Registering two handlers
    // on one path in separate `routes!` calls silently drops one of them, and
    // the spec is where that shows up.
    for (path, method) in [
      ("/v1/remote-sessions/{id}", "get"),
      ("/v1/remote-sessions/{id}", "delete"),
      ("/v1/cookie-bot/schedules/{profile_id}", "get"),
      ("/v1/cookie-bot/schedules/{profile_id}", "put"),
      ("/v1/cookie-bot/schedules/{profile_id}", "delete"),
      ("/v1/cookie-bot/runs", "get"),
      ("/v1/cookie-bot/runs", "post"),
      ("/v1/cookie-bot/runs/{run_id}", "delete"),
      // The extension surface is five paths carrying eleven methods, so it is
      // the densest place in the router for one `routes!` to swallow another.
      ("/v1/extensions", "get"),
      ("/v1/extensions", "post"),
      ("/v1/extensions/{id}", "get"),
      ("/v1/extensions/{id}", "put"),
      ("/v1/extensions/{id}", "delete"),
      ("/v1/extension-groups", "get"),
      ("/v1/extension-groups", "post"),
      ("/v1/extension-groups/{id}", "get"),
      ("/v1/extension-groups/{id}", "put"),
      ("/v1/extension-groups/{id}", "delete"),
      (
        "/v1/extension-groups/{id}/extensions/{extension_id}",
        "post",
      ),
      (
        "/v1/extension-groups/{id}/extensions/{extension_id}",
        "delete",
      ),
    ] {
      assert!(
        paths[path].get(method).is_some(),
        "missing from ApiDoc: {method} {path}"
      );
    }

    // Every cookie-bot operation must be findable by tag, or it is invisible in
    // a generated client's grouping even though the path exists.
    for (path, method) in [
      ("/v1/cookie-bot/schedules", "get"),
      ("/v1/cookie-bot/schedules/{profile_id}", "put"),
      ("/v1/cookie-bot/runs", "post"),
      ("/v1/cookie-bot/usage", "get"),
    ] {
      let tags = paths[path][method]["tags"]
        .as_array()
        .unwrap_or_else(|| panic!("{method} {path} has no tags"));
      assert!(
        tags.iter().any(|tag| tag == "cookie-bot"),
        "{method} {path} is not tagged cookie-bot: {tags:?}"
      );
    }

    // A bot run is accepted, not completed: the fleet browses for minutes
    // after the response. A 200 here would be a lie the client acts on.
    assert!(
      paths["/v1/cookie-bot/runs"]["post"]["responses"]
        .get("202")
        .is_some(),
      "starting a bot run must declare 202 Accepted"
    );

    assert!(
      !paths.keys().any(|p| p.contains("wayfern-token")),
      "wayfern-token endpoints were removed and must stay out of the spec"
    );

    // A path with a body that resolves to nothing is worse than a missing
    // path: a generator emits a client for it and the response type is empty.
    // These live in other modules, so `components(schemas(...))` is the only
    // thing pulling them in.
    for schema in [
      "RemoteSessionState",
      "ApiRemoteSessionsResponse",
      "SetCookieBotScheduleRequest",
      "StartCookieBotRunRequest",
      "CookieBotSchedule",
      "CookieBotScheduleList",
      "CookieBotScheduleSaved",
      "CookieBotScheduleDeleted",
      "CookieBotConflict",
      "CookieBotConflictCheck",
      "CookieBotRun",
      "CookieBotRunPage",
      "CookieBotRunStarted",
      "CookieBotPreset",
      "CookieBotPresetList",
      "CookieBotUsage",
      "CookieBotUsageMember",
      "CookieBotUsageProfile",
      "RemoteHoursQuota",
      "RemoteHoursMember",
      "RemoteHoursBreakdown",
      // Neither extension type was registered while only the list and delete
      // routes existed, so every extension response resolved to nothing.
      "Extension",
      "ExtensionGroup",
      "CreateExtensionRequest",
      "UpdateExtensionRequest",
      "CreateExtensionGroupRequest",
      "UpdateExtensionGroupRequest",
    ] {
      assert!(
        spec["components"]["schemas"][schema]["properties"].is_object(),
        "schema is missing from the served spec: {schema}"
      );
    }

    // A response body declared as a path outside this module must resolve to
    // the component that path registered, not to a dangling or inlined name.
    for (path, method, status, schema) in [
      (
        "/v1/cookie-bot/schedules",
        "get",
        "200",
        "CookieBotScheduleList",
      ),
      ("/v1/cookie-bot/runs", "post", "202", "CookieBotRunStarted"),
      (
        "/v1/cookie-bot/runs/{run_id}",
        "delete",
        "200",
        "CookieBotRun",
      ),
      (
        "/v1/remote-sessions/{id}",
        "get",
        "200",
        "RemoteSessionState",
      ),
      ("/v1/remote-hours", "get", "200", "RemoteHoursQuota"),
      ("/v1/extensions", "post", "201", "Extension"),
      ("/v1/extensions/{id}", "put", "200", "Extension"),
      ("/v1/extension-groups", "post", "201", "ExtensionGroup"),
      (
        "/v1/extension-groups/{id}/extensions/{extension_id}",
        "post",
        "200",
        "ExtensionGroup",
      ),
    ] {
      let reference =
        &paths[path][method]["responses"][status]["content"]["application/json"]["schema"]["$ref"];
      assert_eq!(
        reference.as_str(),
        Some(format!("#/components/schemas/{schema}").as_str()),
        "{method} {path} {status} does not reference {schema}: {reference:?}"
      );
    }

    // Both extension lists declared a 200 with no body at all, so a generated
    // client got a call that returns nothing from a route that returns
    // everything. Each must be an array of the same component its single-item
    // route resolves to.
    for (path, schema) in [
      ("/v1/extensions", "Extension"),
      ("/v1/extension-groups", "ExtensionGroup"),
    ] {
      let item = &paths[path]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
        ["items"]["$ref"];
      assert_eq!(
        item.as_str(),
        Some(format!("#/components/schemas/{schema}").as_str()),
        "get {path} 200 is not a list of {schema}: {item:?}"
      );
    }

    // The presets a client may choose from must never carry the behaviour they
    // expand to. A site list, a dwell range or a step programme appearing here
    // would mean the browsing model had leaked out of the server.
    let preset_properties = spec["components"]["schemas"]["CookieBotPreset"]["properties"]
      .as_object()
      .expect("preset properties");
    for leaked in [
      "sites",
      "dwell",
      "dwell_seconds",
      "steps",
      "actions",
      "corpus",
    ] {
      assert!(
        !preset_properties.contains_key(leaked),
        "the browsing model leaked into the client contract: {leaked}"
      );
    }

    for path in [
      "/v1/profiles/{id}/run",
      "/v1/profiles/{id}/open-url",
      "/v1/profiles/{id}/kill",
      "/v1/profiles/{id}/run-remote",
      "/v1/profiles/batch/run",
      "/v1/profiles/batch/stop",
    ] {
      assert!(
        paths[path]["post"]["responses"].get("429").is_some(),
        "automation route is missing its 429 response: {path}"
      );
    }

    assert!(
      paths["/v1/cookie-bot/runs"]["post"]["responses"]
        .get("429")
        .is_some(),
      "starting a bot run is metered and must declare its 429"
    );

    // The automation routes that are not POSTs. Both declared a 429 that
    // `is_automation_request` could never produce, because that function
    // returned early for every non-POST method.
    for path in ["/v1/remote-sessions/{id}", "/v1/cookie-bot/runs/{run_id}"] {
      assert!(
        paths[path]["delete"]["responses"].get("429").is_some(),
        "metered stop route is missing its 429 response: {path}"
      );
    }

    // Schedule writes are configuration, not automation. Declaring a 429 they
    // can never return would send a client building retry logic for a status
    // it will never see.
    for method in ["put", "delete"] {
      assert!(
        paths["/v1/cookie-bot/schedules/{profile_id}"][method]["responses"]
          .get("429")
          .is_none(),
        "a schedule write must not declare a 429: {method}"
      );
    }

    // Extension writes launch nothing and lease nothing, so the limiter never
    // sees them. A declared 429 would be a status the server cannot produce.
    for (path, method) in [
      ("/v1/extensions", "post"),
      ("/v1/extensions/{id}", "put"),
      ("/v1/extensions/{id}", "delete"),
      ("/v1/extension-groups", "post"),
      ("/v1/extension-groups/{id}", "put"),
      (
        "/v1/extension-groups/{id}/extensions/{extension_id}",
        "post",
      ),
      (
        "/v1/extension-groups/{id}/extensions/{extension_id}",
        "delete",
      ),
    ] {
      assert!(
        paths[path][method]["responses"].get("429").is_none(),
        "an extension write must not declare a 429: {method} {path}"
      );
    }
  }
}
