#![expect(
    clippy::disallowed_methods,
    reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path"
)]

use std::fmt::Write as _;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration;

use axum::body::Body;
use axum::http::{Request, StatusCode};
use fabro_config::{ServerSettingsBuilder, Storage, envfile};
use fabro_install::OBJECT_STORE_MANAGED_COMMENT;
use fabro_model::ProviderId;
use fabro_server::install::{
    InstallAppState, InstallFinishHook, InstallFinishInfo, build_install_router,
};
use fabro_server::test_support::test_environment_from_storage_dir;
use fabro_util::Home;
use fabro_vault::Vault;
use httpmock::Method::GET;
use httpmock::MockServer;
use tokio::time::sleep;
use tower::ServiceExt;
use tracing::field::{Field, Visit};
use tracing::{Event, Subscriber};
use tracing_subscriber::layer::{Context, SubscriberExt};
use tracing_subscriber::{Layer, Registry};

use crate::helpers::{checked_response, response_json, response_status, response_text};

fn spa_fixture_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/spa")
}

async fn load_secret_snapshot(storage_dir: &std::path::Path) -> Vault {
    let storage = Storage::new(storage_dir);
    fabro_vault::SecretStore::open_snapshot(storage.sqlite_path(), storage.secrets_path())
        .await
        .expect("test secret snapshot should load")
        .into_vault()
}

fn assert_sandbox_provider_policy(
    settings: &str,
    local_enabled: bool,
    docker_enabled: bool,
    daytona_enabled: bool,
) {
    assert!(settings.contains("[server.sandbox.providers.local]"));
    assert!(settings.contains("[server.sandbox.providers.docker]"));
    assert!(settings.contains("[server.sandbox.providers.daytona]"));

    let resolved = ServerSettingsBuilder::from_toml(settings)
        .expect("settings should resolve")
        .server
        .sandbox
        .providers;
    assert_eq!(resolved.local.enabled, local_enabled);
    assert_eq!(resolved.docker.enabled, docker_enabled);
    assert_eq!(resolved.daytona.enabled, daytona_enabled);
}

async fn seeded_default_environment(
    temp_dir: &tempfile::TempDir,
) -> fabro_environment::Environment {
    test_environment_from_storage_dir(temp_dir.path(), "default")
        .await
        .expect("install test environment store should load")
        .expect("default environment should be seeded")
}

fn assert_no_legacy_environment_dir(temp_dir: &tempfile::TempDir) {
    assert!(
        !temp_dir.path().join("environments").exists(),
        "install should not write legacy environments/*.toml files"
    );
}

async fn mock_daytona_auth_probe(server: &MockServer) -> httpmock::Mock<'_> {
    server
        .mock_async(|when, then| {
            when.method(GET)
                .path("/sandbox/paginated")
                .query_param("page", "1")
                .query_param("limit", "1");
            then.status(200)
                .header("content-type", "application/json")
                .json_body(serde_json::json!({
                    "items": [],
                    "total": 0,
                    "page": 1,
                    "totalPages": 0
                }));
        })
        .await
}

async fn mock_daytona_current_key<'a>(
    server: &'a MockServer,
    permissions: Vec<&'static str>,
) -> httpmock::Mock<'a> {
    server
        .mock_async(move |when, then| {
            when.method(GET)
                .path("/api-keys/current")
                .header("authorization", "Bearer dtn_test");
            then.status(200)
                .header("content-type", "application/json")
                .json_body(serde_json::json!({
                    "name": "delete-only",
                    "value": "dtn_****",
                    "createdAt": "2026-05-01T00:00:00Z",
                    "permissions": permissions,
                    "lastUsedAt": null,
                    "expiresAt": null,
                    "userId": "user_123"
                }));
        })
        .await
}

async fn mock_anthropic_install_validation(server: &MockServer) -> httpmock::Mock<'_> {
    server
        .mock_async(|when, then| {
            when.method("POST")
                .path("/v1/messages")
                .header("x-api-key", "anthropic-test-key");
            then.status(200)
                .header("content-type", "application/json")
                .json_body(serde_json::json!({
                    "id": "msg_test_123",
                    "type": "message",
                    "role": "assistant",
                    "model": "claude-sonnet-4-5",
                    "content": [{"type": "text", "text": "OK"}],
                    "stop_reason": "end_turn",
                    "usage": {"input_tokens": 10, "output_tokens": 1}
                }));
        })
        .await
}

async fn mock_moonshot_install_validation(server: &MockServer) -> httpmock::Mock<'_> {
    server
        .mock_async(|when, then| {
            when.method("POST")
                .path("/v1/chat/completions")
                .header("authorization", "Bearer kimi-test-key");
            then.status(200)
                .header("content-type", "application/json")
                .json_body(serde_json::json!({
                    "id": "chatcmpl_test_123",
                    "model": "kimi-k2.5",
                    "choices": [
                        {
                            "message": {"content": "OK"},
                            "finish_reason": "stop"
                        }
                    ],
                    "usage": {"prompt_tokens": 10, "completion_tokens": 1}
                }));
        })
        .await
}

#[derive(Default)]
struct EventCapture {
    fields: Vec<(String, String)>,
}

impl Visit for EventCapture {
    fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
        self.fields
            .push((field.name().to_string(), format!("{value:?}")));
    }

    fn record_str(&mut self, field: &Field, value: &str) {
        self.fields
            .push((field.name().to_string(), value.to_string()));
    }

    fn record_bool(&mut self, field: &Field, value: bool) {
        self.fields
            .push((field.name().to_string(), value.to_string()));
    }
}

struct CaptureLayer {
    lines: Arc<StdMutex<Vec<String>>>,
}

impl<S: Subscriber> Layer<S> for CaptureLayer {
    fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
        if !event
            .metadata()
            .target()
            .starts_with("fabro_server::install")
        {
            return;
        }

        let mut capture = EventCapture::default();
        event.record(&mut capture);

        let mut line = event.metadata().level().to_string();
        for (field, value) in capture.fields {
            let _ = write!(line, " {field}={value}");
        }
        self.lines.lock().unwrap().push(line);
    }
}

async fn put_install_server(app: &axum::Router, token: &str, canonical_url: &str) {
    let response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri("/install/server")
                .header("authorization", format!("Bearer {token}"))
                .header("content-type", "application/json")
                .body(Body::from(format!(
                    r#"{{"canonical_url":"{canonical_url}"}}"#
                )))
                .expect("server install request should build"),
        )
        .await
        .unwrap();
    response_status(response, StatusCode::NO_CONTENT, "PUT /install/server").await;
}

async fn put_install_llm(app: &axum::Router, token: &str) {
    let response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri("/install/llm")
                .header("authorization", format!("Bearer {token}"))
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"providers":[{"provider":"anthropic","api_key":"anthropic-test-key"}]}"#,
                ))
                .expect("LLM install request should build"),
        )
        .await
        .unwrap();
    response_status(response, StatusCode::NO_CONTENT, "PUT /install/llm").await;
}

async fn put_install_llm_skipped(app: &axum::Router, token: &str) {
    let response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri("/install/llm")
                .header("authorization", format!("Bearer {token}"))
                .header("content-type", "application/json")
                .body(Body::from(r#"{"providers":[]}"#))
                .expect("skipped LLM install request should build"),
        )
        .await
        .unwrap();
    response_status(response, StatusCode::NO_CONTENT, "PUT /install/llm").await;
}

async fn put_install_github_token(app: &axum::Router, token: &str, username: &str) {
    let response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri("/install/github/token")
                .header("authorization", format!("Bearer {token}"))
                .header("content-type", "application/json")
                .body(Body::from(format!(
                    r#"{{"token":"ghp_test_token","username":"{username}"}}"#
                )))
                .expect("GitHub token install request should build"),
        )
        .await
        .unwrap();
    response_status(
        response,
        StatusCode::NO_CONTENT,
        "PUT /install/github/token",
    )
    .await;
}

async fn put_install_object_store(app: &axum::Router, token: &str, body: &str) {
    let response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri("/install/object-store")
                .header("authorization", format!("Bearer {token}"))
                .header("content-type", "application/json")
                .body(Body::from(body.to_string()))
                .expect("object-store install request should build"),
        )
        .await
        .unwrap();
    response_status(
        response,
        StatusCode::NO_CONTENT,
        "PUT /install/object-store",
    )
    .await;
}

async fn put_install_object_store_local(app: &axum::Router, token: &str) {
    put_install_object_store(app, token, r#"{"provider":"local"}"#).await;
}

async fn put_install_sandbox(app: &axum::Router, token: &str, body: &str) {
    let response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri("/install/sandbox")
                .header("authorization", format!("Bearer {token}"))
                .header("content-type", "application/json")
                .body(Body::from(body.to_string()))
                .expect("sandbox install request should build"),
        )
        .await
        .unwrap();
    response_status(response, StatusCode::NO_CONTENT, "PUT /install/sandbox").await;
}

async fn put_install_sandbox_docker(app: &axum::Router, token: &str) {
    put_install_sandbox(app, token, r#"{"provider":"docker"}"#).await;
}

async fn configure_token_install(app: &axum::Router, token: &str) {
    put_install_server(app, token, "https://fabro.example.com").await;
    put_install_object_store_local(app, token).await;
    put_install_sandbox_docker(app, token).await;
    put_install_llm(app, token).await;
    put_install_github_token(app, token, "brynary").await;
}

#[tokio::test]
async fn install_router_isolated_from_normal_api_surface() {
    let app = build_install_router(
        InstallAppState::for_test("test-install-token").with_static_asset_root(spa_fixture_root()),
    );

    let health_response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/health")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let health_body = response_json(health_response, StatusCode::OK, "GET /health").await;
    assert_eq!(health_body["status"], "ok");
    assert_eq!(health_body["mode"], "install");

    let root_response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/")
                .header("accept", "text/html,application/xhtml+xml")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let root_html = response_text(root_response, StatusCode::OK, "GET /").await;
    assert!(
        root_html.contains("__FABRO_MODE__ = \"install\""),
        "install shell should mark the SPA boot mode"
    );

    let api_response = app
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/api/v1/auth/me")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    response_status(api_response, StatusCode::NOT_FOUND, "GET /api/v1/auth/me").await;
}

#[tokio::test]
async fn install_session_requires_valid_install_token() {
    let app = build_install_router(InstallAppState::for_test("test-install-token"));

    let unauthorized = app
        .clone()
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/install/session")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    response_status(
        unauthorized,
        StatusCode::UNAUTHORIZED,
        "GET /install/session",
    )
    .await;

    let authorized = app
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/install/session")
                .header("authorization", "Bearer test-install-token")
                .header("x-forwarded-proto", "https")
                .header("x-forwarded-host", "fabro.example.com")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let body = response_json(authorized, StatusCode::OK, "GET /install/session").await;
    assert_eq!(
        body["prefill"]["canonical_url"],
        "https://fabro.example.com"
    );
}

#[tokio::test]
async fn install_session_sanitizes_wildcard_host_prefill() {
    let app = build_install_router(InstallAppState::for_test("test-install-token"));

    let response = app
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/install/session")
                .header("authorization", "Bearer test-install-token")
                .header("host", "0.0.0.0:32276")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    let body = response_json(response, StatusCode::OK, "GET /install/session").await;
    assert_eq!(body["prefill"]["canonical_url"], "http://localhost:32276");
}

#[tokio::test]
async fn install_endpoints_reject_missing_and_wrong_tokens() {
    let app = build_install_router(InstallAppState::for_test("test-install-token"));
    let cases = [
        ("GET", "/install/session", None),
        (
            "POST",
            "/install/llm/test",
            Some(r#"{"provider":"anthropic","api_key":"anthropic-test-key"}"#),
        ),
        (
            "PUT",
            "/install/llm",
            Some(r#"{"providers":[{"provider":"anthropic","api_key":"anthropic-test-key"}]}"#),
        ),
        (
            "PUT",
            "/install/server",
            Some(r#"{"canonical_url":"https://fabro.example.com"}"#),
        ),
        (
            "POST",
            "/install/object-store/test",
            Some(r#"{"provider":"local"}"#),
        ),
        (
            "PUT",
            "/install/object-store",
            Some(r#"{"provider":"local"}"#),
        ),
        (
            "POST",
            "/install/sandbox/test",
            Some(r#"{"provider":"docker"}"#),
        ),
        ("PUT", "/install/sandbox", Some(r#"{"provider":"docker"}"#)),
        (
            "POST",
            "/install/github/token/test",
            Some(r#"{"token":"ghp_test_token"}"#),
        ),
        (
            "PUT",
            "/install/github/token",
            Some(r#"{"token":"ghp_test_token","username":"octocat"}"#),
        ),
        (
            "POST",
            "/install/github/app/manifest",
            Some(
                r#"{"owner":{"kind":"personal"},"app_name":"Fabro","allowed_username":"octocat"}"#,
            ),
        ),
        ("POST", "/install/finish", None),
    ];

    for (method, path, body) in cases {
        let mut missing_token = Request::builder().method(method).uri(path);
        let mut wrong_token = Request::builder()
            .method(method)
            .uri(path)
            .header("authorization", "Bearer wrong-token");
        if body.is_some() {
            missing_token = missing_token.header("content-type", "application/json");
            wrong_token = wrong_token.header("content-type", "application/json");
        }

        let missing_token_response = app
            .clone()
            .oneshot(
                missing_token
                    .body(Body::from(body.unwrap_or_default().to_string()))
                    .unwrap(),
            )
            .await
            .unwrap();
        response_status(
            missing_token_response,
            StatusCode::UNAUTHORIZED,
            format!("{method} {path} without install token"),
        )
        .await;

        let wrong_token_response = app
            .clone()
            .oneshot(
                wrong_token
                    .body(Body::from(body.unwrap_or_default().to_string()))
                    .unwrap(),
            )
            .await
            .unwrap();
        response_status(
            wrong_token_response,
            StatusCode::UNAUTHORIZED,
            format!("{method} {path} with wrong install token"),
        )
        .await;
    }
}

#[tokio::test]
async fn install_endpoints_accept_query_token_when_authorization_header_is_wrong() {
    let app = build_install_router(InstallAppState::for_test("test-install-token"));

    let response = app
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/install/session?token=test-install-token")
                .header("authorization", "Bearer wrong-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    response_status(response, StatusCode::OK, "GET /install/session?token=...").await;
}

#[tokio::test]
async fn object_store_local_validation_and_save_update_install_session() {
    let app = build_install_router(InstallAppState::for_test("test-install-token"));

    let validation_response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/object-store/test")
                .header("authorization", "Bearer test-install-token")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"provider":"local"}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    let validation_body = response_json(
        validation_response,
        StatusCode::OK,
        "POST /install/object-store/test",
    )
    .await;
    assert_eq!(validation_body["ok"], true);

    put_install_object_store_local(&app, "test-install-token").await;

    let session_response = app
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/install/session")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let session_body =
        response_json(session_response, StatusCode::OK, "GET /install/session").await;
    assert_eq!(session_body["object_store"]["provider"], "local");
    assert!(
        session_body["completed_steps"]
            .as_array()
            .unwrap()
            .iter()
            .any(|value| value == "object_store")
    );
}

#[tokio::test]
async fn object_store_validation_rejects_runtime_mode_access_keys_without_echoing_secrets() {
    let app = build_install_router(InstallAppState::for_test("test-install-token"));
    let access_key_id = "AKIA_RUNTIME_SHOULD_NOT_LEAK";
    let secret_access_key = "runtime-secret-should-not-leak";

    let validation_response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/object-store/test")
                .header("authorization", "Bearer test-install-token")
                .header("content-type", "application/json")
                .body(Body::from(format!(
                    r#"{{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"runtime","access_key_id":"{access_key_id}","secret_access_key":"{secret_access_key}"}}"#
                )))
                .unwrap(),
        )
        .await
        .unwrap();
    let validation_body = response_json(
        validation_response,
        StatusCode::UNPROCESSABLE_ENTITY,
        "POST /install/object-store/test",
    )
    .await;

    assert_eq!(
        validation_body["errors"][0]["detail"],
        "AWS access key fields are only allowed when using manual AWS access key credentials."
    );
    let rendered = validation_body.to_string();
    assert!(!rendered.contains(access_key_id));
    assert!(!rendered.contains(secret_access_key));
}

#[tokio::test]
async fn install_finish_requires_object_store_step() {
    let app = build_install_router(InstallAppState::for_test("test-install-token"));

    put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
    put_install_llm(&app, "test-install-token").await;
    put_install_github_token(&app, "test-install-token", "brynary").await;

    let finish_response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/finish")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let finish_body = response_json(
        finish_response,
        StatusCode::UNPROCESSABLE_ENTITY,
        "POST /install/finish",
    )
    .await;
    assert_eq!(
        finish_body["errors"][0]["detail"],
        "install step 'object_store' is incomplete"
    );
}

#[tokio::test]
async fn manual_object_store_session_is_redacted_and_blank_resubmit_preserves_credentials() {
    let temp_dir = tempfile::tempdir().unwrap();
    let config_path = temp_dir.path().join("settings.toml");
    let app = build_install_router(InstallAppState::for_test_with_paths(
        "test-install-token",
        temp_dir.path(),
        &config_path,
    ));

    put_install_object_store(
        &app,
        "test-install-token",
        r#"{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"access_key","access_key_id":"AKIA_TEST_VALUE","secret_access_key":"secret-test-value"}"#,
    )
    .await;

    let session_response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/install/session")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let session_body =
        response_json(session_response, StatusCode::OK, "GET /install/session").await;
    assert_eq!(session_body["object_store"]["provider"], "s3");
    assert_eq!(session_body["object_store"]["bucket"], "fabro-data");
    assert_eq!(session_body["object_store"]["region"], "us-east-1");
    assert_eq!(
        session_body["object_store"]["credential_mode"],
        "access_key"
    );
    assert_eq!(
        session_body["object_store"]["manual_credentials_saved"],
        true
    );
    let rendered_session = session_body.to_string();
    assert!(!rendered_session.contains("AKIA_TEST_VALUE"));
    assert!(!rendered_session.contains("secret-test-value"));

    put_install_object_store(
        &app,
        "test-install-token",
        r#"{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"access_key"}"#,
    )
    .await;
    put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
    put_install_sandbox_docker(&app, "test-install-token").await;
    put_install_llm(&app, "test-install-token").await;
    put_install_github_token(&app, "test-install-token", "brynary").await;

    let finish_response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/finish")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    response_status(
        finish_response,
        StatusCode::ACCEPTED,
        "POST /install/finish",
    )
    .await;

    let server_env =
        std::fs::read_to_string(Storage::new(temp_dir.path()).runtime_directory().env_path())
            .unwrap();
    assert!(server_env.contains("AWS_ACCESS_KEY_ID=AKIA_TEST_VALUE"));
    assert!(server_env.contains("AWS_SECRET_ACCESS_KEY=secret-test-value"));
}

#[tokio::test]
async fn switching_object_store_from_manual_to_runtime_clears_saved_manual_credentials() {
    let temp_dir = tempfile::tempdir().unwrap();
    let config_path = temp_dir.path().join("settings.toml");
    let app = build_install_router(InstallAppState::for_test_with_paths(
        "test-install-token",
        temp_dir.path(),
        &config_path,
    ));

    put_install_object_store(
        &app,
        "test-install-token",
        r#"{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"access_key","access_key_id":"AKIA_SWITCH_ME","secret_access_key":"switch-secret-value"}"#,
    )
    .await;
    put_install_object_store(
        &app,
        "test-install-token",
        r#"{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"runtime"}"#,
    )
    .await;

    let session_response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/install/session")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let session_body =
        response_json(session_response, StatusCode::OK, "GET /install/session").await;
    assert_eq!(session_body["object_store"]["provider"], "s3");
    assert_eq!(session_body["object_store"]["credential_mode"], "runtime");
    assert_eq!(
        session_body["object_store"]["manual_credentials_saved"],
        false
    );
    let rendered_session = session_body.to_string();
    assert!(!rendered_session.contains("AKIA_SWITCH_ME"));
    assert!(!rendered_session.contains("switch-secret-value"));

    put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
    put_install_sandbox_docker(&app, "test-install-token").await;
    put_install_llm(&app, "test-install-token").await;
    put_install_github_token(&app, "test-install-token", "brynary").await;

    let finish_response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/finish")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    response_status(
        finish_response,
        StatusCode::ACCEPTED,
        "POST /install/finish",
    )
    .await;

    let server_env =
        std::fs::read_to_string(Storage::new(temp_dir.path()).runtime_directory().env_path())
            .unwrap();
    assert!(!server_env.contains("AWS_ACCESS_KEY_ID="));
    assert!(!server_env.contains("AWS_SECRET_ACCESS_KEY="));
}

#[tokio::test]
async fn runtime_object_store_finish_removes_managed_aws_keys_but_keeps_unmarked_entries() {
    let temp_dir = tempfile::tempdir().unwrap();
    let config_path = temp_dir.path().join("settings.toml");
    let storage = Storage::new(temp_dir.path());
    std::fs::write(
        storage.runtime_directory().env_path(),
        format!(
            "AWS_ACCESS_KEY_ID=operator-id\n# {OBJECT_STORE_MANAGED_COMMENT}\nAWS_SECRET_ACCESS_KEY=managed-secret\nKEEP_ME=1\n"
        ),
    )
    .unwrap();

    let app = build_install_router(InstallAppState::for_test_with_paths(
        "test-install-token",
        temp_dir.path(),
        &config_path,
    ));

    put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
    put_install_object_store(
        &app,
        "test-install-token",
        r#"{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"runtime"}"#,
    )
    .await;
    put_install_sandbox_docker(&app, "test-install-token").await;
    put_install_llm(&app, "test-install-token").await;
    put_install_github_token(&app, "test-install-token", "brynary").await;

    let finish_response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/finish")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    response_status(
        finish_response,
        StatusCode::ACCEPTED,
        "POST /install/finish",
    )
    .await;

    let server_env = std::fs::read_to_string(storage.runtime_directory().env_path()).unwrap();
    assert!(server_env.contains("AWS_ACCESS_KEY_ID=operator-id"));
    assert!(!server_env.contains("managed-secret"));
    assert!(server_env.contains("KEEP_ME=1"));
}

#[tokio::test]
async fn token_install_finish_persists_settings_env_and_vault() {
    let temp_dir = tempfile::tempdir().unwrap();
    let config_path = temp_dir.path().join("settings.toml");
    let app = build_install_router(InstallAppState::for_test_with_paths(
        "test-install-token",
        temp_dir.path(),
        &config_path,
    ));
    configure_token_install(&app, "test-install-token").await;

    let finish_response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/finish")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let finish_body = response_json(
        finish_response,
        StatusCode::ACCEPTED,
        "POST /install/finish",
    )
    .await;
    assert_eq!(finish_body["status"], "completing");
    assert_eq!(finish_body["restart_url"], "https://fabro.example.com");
    assert!(
        finish_body["dev_token"]
            .as_str()
            .is_some_and(|value| !value.is_empty())
    );

    let settings = std::fs::read_to_string(&config_path).unwrap();
    assert!(settings.contains("https://fabro.example.com"));
    assert!(settings.contains("strategy = \"token\""));
    assert!(
        settings.contains("[run.environment]"),
        "settings.toml should contain [run.environment]"
    );
    assert!(
        !settings.contains("[environments"),
        "settings.toml should not contain environment catalog entries"
    );
    assert_sandbox_provider_policy(&settings, true, true, false);
    assert_no_legacy_environment_dir(&temp_dir);
    let default_environment = seeded_default_environment(&temp_dir).await;
    assert_eq!(default_environment.settings.provider.to_string(), "docker");
    assert_eq!(
        default_environment.settings.image.docker.as_deref(),
        Some("buildpack-deps:noble")
    );
    let resolved = ServerSettingsBuilder::from_toml(&settings)
        .expect("settings should resolve")
        .server;
    assert_eq!(
        match resolved.listen {
            fabro_types::settings::server::ServerListenSettings::Tcp { address, .. } => {
                address.to_string()
            }
            fabro_types::settings::server::ServerListenSettings::Unix { .. } => {
                String::new()
            }
        },
        "127.0.0.1:32276"
    );

    let storage = fabro_config::Storage::new(temp_dir.path());
    let server_env = envfile::read_env_file(&storage.runtime_directory().env_path()).unwrap();
    assert!(server_env.contains_key(fabro_static::EnvVars::SESSION_SECRET));
    assert!(server_env.contains_key(fabro_static::EnvVars::FABRO_DEV_TOKEN));
    assert!(!server_env.contains_key(fabro_static::EnvVars::AWS_ACCESS_KEY_ID));
    assert!(!server_env.contains_key(fabro_static::EnvVars::AWS_SECRET_ACCESS_KEY));
    let finish_dev_token = finish_body["dev_token"]
        .as_str()
        .expect("token install should return a dev token");
    let storage_dev_token =
        fabro_util::dev_token::read_dev_token_file(&storage.runtime_directory().dev_token_path())
            .expect("token install should write the storage dev token");
    assert_eq!(storage_dev_token, finish_dev_token);
    assert_eq!(
        server_env
            .get(fabro_static::EnvVars::FABRO_DEV_TOKEN)
            .map(String::as_str),
        Some(finish_dev_token)
    );

    let vault = load_secret_snapshot(temp_dir.path()).await;
    assert!(vault.get("ANTHROPIC_API_KEY").is_some());
    assert_eq!(vault.get("GITHUB_TOKEN"), Some("ghp_test_token"));
}

#[tokio::test]
async fn install_llm_accepts_empty_providers_as_explicit_skip() {
    let app = build_install_router(InstallAppState::for_test("test-install-token"));

    put_install_llm_skipped(&app, "test-install-token").await;

    let session_response = app
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/install/session")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let session_body =
        response_json(session_response, StatusCode::OK, "GET /install/session").await;
    assert!(
        session_body["completed_steps"]
            .as_array()
            .unwrap()
            .iter()
            .any(|value| value == "llm"),
        "skipped LLM step should still count as completed"
    );
    assert_eq!(
        session_body["llm"]["providers"],
        serde_json::json!([]),
        "skipped LLM step should expose an empty providers list"
    );
}

#[tokio::test]
async fn browser_install_finish_with_skipped_llm_persists_no_llm_credentials() {
    let temp_dir = tempfile::tempdir().unwrap();
    let config_path = temp_dir.path().join("settings.toml");
    let app = build_install_router(InstallAppState::for_test_with_paths(
        "test-install-token",
        temp_dir.path(),
        &config_path,
    ));
    put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
    put_install_object_store_local(&app, "test-install-token").await;
    put_install_sandbox_docker(&app, "test-install-token").await;
    put_install_llm_skipped(&app, "test-install-token").await;
    put_install_github_token(&app, "test-install-token", "brynary").await;

    let finish_response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/finish")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let finish_body = response_json(
        finish_response,
        StatusCode::ACCEPTED,
        "POST /install/finish",
    )
    .await;
    assert_eq!(finish_body["status"], "completing");

    let settings = std::fs::read_to_string(&config_path).unwrap();
    assert!(settings.contains("https://fabro.example.com"));
    assert!(settings.contains("strategy = \"token\""));

    let server_env = std::fs::read_to_string(
        fabro_config::Storage::new(temp_dir.path())
            .runtime_directory()
            .env_path(),
    )
    .unwrap();
    assert!(server_env.contains("SESSION_SECRET="));
    assert!(server_env.contains("FABRO_DEV_TOKEN="));

    let vault = load_secret_snapshot(temp_dir.path()).await;
    assert!(
        vault.get("OPENAI_API_KEY").is_none() && vault.get("OPENAI_CODEX").is_none(),
        "skipped LLM install should not write any OpenAI vault entries"
    );
    assert_eq!(
        vault.get("GITHUB_TOKEN"),
        Some("ghp_test_token"),
        "GitHub secrets still persist when the LLM step is skipped"
    );
}

#[tokio::test]
async fn token_install_finish_invokes_finish_hook_before_response_returns() {
    let temp_dir = tempfile::tempdir().unwrap();
    let config_path = temp_dir.path().join("settings.toml");
    let observed = Arc::new(StdMutex::new(None));
    let observed_for_hook = Arc::clone(&observed);
    let hook: InstallFinishHook = Arc::new(move |info: &InstallFinishInfo| {
        *observed_for_hook.lock().unwrap() =
            Some((info.canonical_url.clone(), info.dev_token.clone()));
        Ok(())
    });
    let app = build_install_router(
        InstallAppState::for_test_with_paths("test-install-token", temp_dir.path(), &config_path)
            .with_finish_hook(hook),
    );
    configure_token_install(&app, "test-install-token").await;

    let finish_response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/finish")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let finish_body = response_json(
        finish_response,
        StatusCode::ACCEPTED,
        "POST /install/finish",
    )
    .await;

    let Some((canonical_url, dev_token)) = observed.lock().unwrap().clone() else {
        panic!("finish hook should run before response returns");
    };
    assert_eq!(canonical_url, "https://fabro.example.com");
    assert_eq!(
        dev_token.as_deref(),
        finish_body["dev_token"].as_str(),
        "hook receives the same token returned to the browser"
    );
}

#[tokio::test]
async fn app_install_finish_omits_dev_token_and_does_not_write_it() {
    let temp_dir = tempfile::tempdir().unwrap();
    let home_root = tempfile::tempdir().unwrap();
    let home = Home::new(home_root.path().join(".fabro"));
    let config_path = temp_dir.path().join("settings.toml");
    let github_mock = MockServer::start_async().await;
    github_mock
        .mock_async(|when, then| {
            when.method("POST")
                .path("/app-manifests/stub-code/conversions");
            then.status(200)
                .header("content-type", "application/json")
                .body(
                    r#"{
                        "id": 42,
                        "slug": "fabro-test-app",
                        "client_id": "Iv1.test-client-id",
                        "client_secret": "test-client-secret",
                        "webhook_secret": "test-webhook-secret",
                        "pem": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n"
                    }"#,
                );
        })
        .await;
    let app = build_install_router(
        InstallAppState::for_test_with_paths("test-install-token", temp_dir.path(), &config_path)
            .with_home(home.clone())
            .with_github_api_base_url(github_mock.url("")),
    );

    let llm_response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri("/install/llm")
                .header("authorization", "Bearer test-install-token")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"providers":[{"provider":"anthropic","api_key":"anthropic-test-key"}]}"#,
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    response_status(llm_response, StatusCode::NO_CONTENT, "PUT /install/llm").await;

    let server_response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri("/install/server")
                .header("authorization", "Bearer test-install-token")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"canonical_url":"https://fabro.example.com"}"#,
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    response_status(
        server_response,
        StatusCode::NO_CONTENT,
        "PUT /install/server",
    )
    .await;

    put_install_object_store_local(&app, "test-install-token").await;
    put_install_sandbox_docker(&app, "test-install-token").await;

    let manifest_response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/github/app/manifest")
                .header("authorization", "Bearer test-install-token")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"owner":{"kind":"personal"},"app_name":"Fabro Test","allowed_username":"octocat"}"#,
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    let manifest_body = response_json(
        manifest_response,
        StatusCode::OK,
        "POST /install/github/app/manifest",
    )
    .await;
    let state = manifest_body["state"]
        .as_str()
        .expect("state should be present on manifest response")
        .to_owned();
    assert_eq!(
        manifest_body["manifest"]["redirect_url"],
        "https://fabro.example.com/install/github/app/redirect",
        "redirect_url must not carry a query string — GitHub rejects manifests whose redirect_url has one"
    );

    let callback_response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("GET")
                .uri(format!(
                    "/install/github/app/redirect?code=stub-code&state={state}"
                ))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    response_status(
        callback_response,
        StatusCode::FOUND,
        "GET /install/github/app/redirect?code=stub-code&state=...",
    )
    .await;

    let finish_response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/finish")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let finish_body = response_json(
        finish_response,
        StatusCode::ACCEPTED,
        "POST /install/finish",
    )
    .await;
    assert_eq!(finish_body["status"], "completing");
    assert_eq!(finish_body["restart_url"], "https://fabro.example.com");
    assert!(
        finish_body.get("dev_token").is_none(),
        "App installs must not expose a dev token"
    );

    let server_env =
        std::fs::read_to_string(Storage::new(temp_dir.path()).runtime_directory().env_path())
            .unwrap();
    assert!(!server_env.contains("FABRO_DEV_TOKEN="));

    assert!(
        !home.root().join("dev-token").exists(),
        "home dev token file should not be created for App installs"
    );
    assert!(
        !Storage::new(temp_dir.path())
            .runtime_directory()
            .dev_token_path()
            .exists(),
        "storage dev token file should not be created for App installs"
    );
}

#[tokio::test]
async fn token_install_finish_invokes_shutdown_callback_after_accepting() {
    let temp_dir = tempfile::tempdir().unwrap();
    let home_root = tempfile::tempdir().unwrap();
    let config_path = temp_dir.path().join("settings.toml");
    let callback_invoked = Arc::new(AtomicBool::new(false));
    let callback_flag = Arc::clone(&callback_invoked);
    let app = build_install_router(
        InstallAppState::for_test_with_paths("test-install-token", temp_dir.path(), &config_path)
            .with_home(Home::new(home_root.path().join(".fabro")))
            .with_finish_callback(Arc::new(move || {
                callback_flag.store(true, Ordering::Release);
            })),
    );

    configure_token_install(&app, "test-install-token").await;

    let finish_response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/finish")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    response_status(
        finish_response,
        StatusCode::ACCEPTED,
        "POST /install/finish",
    )
    .await;
    assert!(!callback_invoked.load(Ordering::Acquire));

    sleep(Duration::from_millis(650)).await;
    assert!(callback_invoked.load(Ordering::Acquire));
}

#[tokio::test]
async fn install_llm_accepts_catalog_openai_compatible_provider() {
    let llm_mock = MockServer::start_async().await;
    mock_moonshot_install_validation(&llm_mock).await;

    let app = build_install_router(
        InstallAppState::for_test("test-install-token").with_provider_base_url(
            ProviderId::new("moonshot"),
            format!("{}/v1", llm_mock.url("")),
        ),
    );

    let test_response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/llm/test")
                .header("authorization", "Bearer test-install-token")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"provider":"moonshot","api_key":"kimi-test-key"}"#,
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    let body = response_json(test_response, StatusCode::OK, "POST /install/llm/test").await;
    assert_eq!(body["ok"], true);

    let put_response = app
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri("/install/llm")
                .header("authorization", "Bearer test-install-token")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"providers":[{"provider":"moonshot","api_key":"kimi-test-key"}]}"#,
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    response_status(put_response, StatusCode::NO_CONTENT, "PUT /install/llm").await;
}

#[tokio::test]
async fn install_validation_endpoints_validate_credentials_and_github_token() {
    let llm_mock = MockServer::start_async().await;
    mock_anthropic_install_validation(&llm_mock).await;
    let github_mock = MockServer::start_async().await;
    github_mock
        .mock_async(|when, then| {
            when.method("GET").path("/user");
            then.status(200)
                .header("content-type", "application/json")
                .body(r#"{"login":"octocat"}"#);
        })
        .await;

    let app = build_install_router(
        InstallAppState::for_test("test-install-token")
            .with_provider_base_url(ProviderId::anthropic(), format!("{}/v1", llm_mock.url("")))
            .with_github_api_base_url(github_mock.url("")),
    );

    let llm_response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/llm/test")
                .header("authorization", "Bearer test-install-token")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"provider":"anthropic","api_key":"anthropic-test-key"}"#,
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    let llm_body = response_json(llm_response, StatusCode::OK, "POST /install/llm/test").await;
    assert_eq!(llm_body["ok"], true);

    let github_response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/github/token/test")
                .header("authorization", "Bearer test-install-token")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"token":"ghp_test_token"}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    let github_body = response_json(
        github_response,
        StatusCode::OK,
        "POST /install/github/token/test",
    )
    .await;
    assert_eq!(github_body["username"], "octocat");
}

#[tokio::test]
async fn github_app_manifest_round_trip_updates_install_session() {
    let github_mock = MockServer::start_async().await;
    let conversion_mock = github_mock
        .mock_async(|when, then| {
            when.method("POST")
                .path("/app-manifests/stub-code/conversions");
            then.status(200)
                .header("content-type", "application/json")
                .body(
                    r#"{
                        "id": 42,
                        "slug": "fabro-test-app",
                        "client_id": "Iv1.test-client-id",
                        "client_secret": "test-client-secret",
                        "webhook_secret": "test-webhook-secret",
                        "pem": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n"
                    }"#,
                );
        })
        .await;
    let app = build_install_router(
        InstallAppState::for_test("test-install-token")
            .with_github_api_base_url(github_mock.url("")),
    );

    let server_response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri("/install/server")
                .header("authorization", "Bearer test-install-token")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"canonical_url":"https://fabro.example.com"}"#,
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    response_status(
        server_response,
        StatusCode::NO_CONTENT,
        "PUT /install/server",
    )
    .await;

    let manifest_response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/github/app/manifest")
                .header("authorization", "Bearer test-install-token")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"owner":{"kind":"personal"},"app_name":"Fabro Test","allowed_username":"octocat"}"#,
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    let manifest_body = response_json(
        manifest_response,
        StatusCode::OK,
        "POST /install/github/app/manifest",
    )
    .await;
    assert_eq!(
        manifest_body["github_form_action"],
        "https://github.com/settings/apps/new"
    );
    assert_eq!(
        manifest_body["manifest"]["callback_urls"][0],
        "https://fabro.example.com/auth/callback/github"
    );

    let state = manifest_body["state"]
        .as_str()
        .expect("state should be present on manifest response")
        .to_owned();
    assert_eq!(
        manifest_body["manifest"]["redirect_url"],
        "https://fabro.example.com/install/github/app/redirect",
        "redirect_url must not carry a query string — GitHub rejects manifests whose redirect_url has one"
    );

    let callback_response = checked_response(
        app.clone()
            .oneshot(
                Request::builder()
                    .method("GET")
                    .uri(format!(
                        "/install/github/app/redirect?code=stub-code&state={state}"
                    ))
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap(),
        StatusCode::FOUND,
        "GET /install/github/app/redirect?code=stub-code&state=...",
    )
    .await;
    assert_eq!(
        callback_response
            .headers()
            .get("location")
            .and_then(|value| value.to_str().ok()),
        Some("/install/github/done?token=test-install-token")
    );
    conversion_mock.assert_async().await;

    let session_response = app
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/install/session")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let session_body =
        response_json(session_response, StatusCode::OK, "GET /install/session").await;
    assert_eq!(session_body["github"]["strategy"], "app");
    assert_eq!(session_body["github"]["slug"], "fabro-test-app");
    assert_eq!(session_body["github"]["allowed_username"], "octocat");
    assert!(
        session_body["completed_steps"]
            .as_array()
            .unwrap()
            .iter()
            .any(|value| value == "github")
    );
}

#[tokio::test]
async fn github_app_manifest_retry_replaces_pending_and_preserves_prior_token_strategy() {
    let app = build_install_router(InstallAppState::for_test("test-install-token"));

    let server_response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri("/install/server")
                .header("authorization", "Bearer test-install-token")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"canonical_url":"https://fabro.example.com"}"#,
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    response_status(
        server_response,
        StatusCode::NO_CONTENT,
        "PUT /install/server",
    )
    .await;

    let github_response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri("/install/github/token")
                .header("authorization", "Bearer test-install-token")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"token":"ghp_test_token","username":"brynary"}"#,
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    response_status(
        github_response,
        StatusCode::NO_CONTENT,
        "PUT /install/github/token",
    )
    .await;

    let first_response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/github/app/manifest")
                .header("authorization", "Bearer test-install-token")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"owner":{"kind":"personal"},"app_name":"Fabro Test","allowed_username":"octocat"}"#,
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    let first_body = response_json(
        first_response,
        StatusCode::OK,
        "POST /install/github/app/manifest (initial)",
    )
    .await;
    let first_state = first_body["state"]
        .as_str()
        .expect("state should be present on manifest response")
        .to_owned();

    let retry_response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/github/app/manifest")
                .header("authorization", "Bearer test-install-token")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"owner":{"kind":"personal"},"app_name":"Fabro Retry","allowed_username":"octocat"}"#,
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    let retry_body = response_json(
        retry_response,
        StatusCode::OK,
        "POST /install/github/app/manifest (retry)",
    )
    .await;
    let retry_state = retry_body["state"]
        .as_str()
        .expect("state should be present on retry manifest response")
        .to_owned();
    assert_ne!(
        first_state, retry_state,
        "retry must mint a fresh state token so the old callback is invalidated"
    );

    // A late callback using the now-discarded first-attempt state must not
    // complete the install.
    let stale_callback = app
        .clone()
        .oneshot(
            Request::builder()
                .method("GET")
                .uri(format!(
                    "/install/github/app/redirect?code=stub-code&state={first_state}"
                ))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    response_status(
        stale_callback,
        StatusCode::FOUND,
        "GET /install/github/app/redirect with stale state",
    )
    .await;

    let session_response = app
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/install/session")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let session_body =
        response_json(session_response, StatusCode::OK, "GET /install/session").await;
    assert_eq!(session_body["github"]["strategy"], "token");
    assert_eq!(session_body["github"]["username"], "brynary");
    assert!(
        session_body["completed_steps"]
            .as_array()
            .unwrap()
            .iter()
            .any(|value| value == "github")
    );
}

#[tokio::test]
async fn github_app_redirect_rejects_invalid_or_missing_state_without_mutating_session() {
    let github_mock = MockServer::start_async().await;
    let conversion_mock = github_mock
        .mock_async(|when, then| {
            when.method("POST")
                .path("/app-manifests/stub-code/conversions");
            then.status(200)
                .header("content-type", "application/json")
                .body(
                    r#"{
                        "id": 42,
                        "slug": "fabro-test-app",
                        "client_id": "Iv1.test-client-id",
                        "client_secret": "test-client-secret",
                        "webhook_secret": "test-webhook-secret",
                        "pem": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n"
                    }"#,
                );
        })
        .await;
    let app = build_install_router(
        InstallAppState::for_test("test-install-token")
            .with_github_api_base_url(github_mock.url("")),
    );

    let server_response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri("/install/server")
                .header("authorization", "Bearer test-install-token")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"canonical_url":"https://fabro.example.com"}"#,
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    response_status(
        server_response,
        StatusCode::NO_CONTENT,
        "PUT /install/server",
    )
    .await;

    let manifest_response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/github/app/manifest")
                .header("authorization", "Bearer test-install-token")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"owner":{"kind":"personal"},"app_name":"Fabro Test","allowed_username":"octocat"}"#,
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    let manifest_body = response_json(
        manifest_response,
        StatusCode::OK,
        "POST /install/github/app/manifest",
    )
    .await;
    let state = manifest_body["state"]
        .as_str()
        .expect("state should be present on manifest response")
        .to_owned();
    assert_eq!(
        manifest_body["manifest"]["redirect_url"],
        "https://fabro.example.com/install/github/app/redirect",
        "redirect_url must not carry a query string — GitHub rejects manifests whose redirect_url has one"
    );

    let wrong_state_response = checked_response(
        app.clone()
            .oneshot(
                Request::builder()
                    .method("GET")
                    .uri("/install/github/app/redirect?code=stub-code&state=wrong-state")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap(),
        StatusCode::FOUND,
        "GET /install/github/app/redirect?code=stub-code&state=wrong-state",
    )
    .await;
    assert_eq!(
        wrong_state_response
            .headers()
            .get("location")
            .and_then(|value| value.to_str().ok()),
        Some("/install/github?token=test-install-token&error=invalid-install-github-app-state")
    );
    conversion_mock.assert_calls_async(0).await;

    let session_response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/install/session")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let session_body =
        response_json(session_response, StatusCode::OK, "GET /install/session").await;
    assert!(session_body["github"].is_null());
    assert!(
        !session_body["completed_steps"]
            .as_array()
            .unwrap()
            .iter()
            .any(|value| value == "github")
    );

    let missing_state_response = checked_response(
        app.clone()
            .oneshot(
                Request::builder()
                    .method("GET")
                    .uri("/install/github/app/redirect?code=stub-code")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap(),
        StatusCode::FOUND,
        "GET /install/github/app/redirect?code=stub-code",
    )
    .await;
    assert_eq!(
        missing_state_response
            .headers()
            .get("location")
            .and_then(|value| value.to_str().ok()),
        Some("/install/github?token=test-install-token&error=missing-install-github-app-state")
    );
    conversion_mock.assert_calls_async(0).await;

    let valid_state_response = app
        .oneshot(
            Request::builder()
                .method("GET")
                .uri(format!(
                    "/install/github/app/redirect?code=stub-code&state={state}"
                ))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    response_status(
        valid_state_response,
        StatusCode::FOUND,
        "GET /install/github/app/redirect?code=stub-code&state=...",
    )
    .await;
    conversion_mock.assert_calls_async(1).await;
}

#[tokio::test]
async fn github_app_redirect_exchange_failure_returns_to_wizard_and_keeps_pending_state() {
    let github_mock = MockServer::start_async().await;
    let conversion_mock = github_mock
        .mock_async(|when, then| {
            when.method("POST")
                .path("/app-manifests/stub-code/conversions");
            then.status(502).body("upstream exploded");
        })
        .await;
    let app = build_install_router(
        InstallAppState::for_test("test-install-token")
            .with_github_api_base_url(github_mock.url("")),
    );

    let server_response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri("/install/server")
                .header("authorization", "Bearer test-install-token")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"canonical_url":"https://fabro.example.com"}"#,
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    response_status(
        server_response,
        StatusCode::NO_CONTENT,
        "PUT /install/server",
    )
    .await;

    let manifest_response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/github/app/manifest")
                .header("authorization", "Bearer test-install-token")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"owner":{"kind":"personal"},"app_name":"Fabro Test","allowed_username":"octocat"}"#,
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    let manifest_body = response_json(
        manifest_response,
        StatusCode::OK,
        "POST /install/github/app/manifest",
    )
    .await;
    let state = manifest_body["state"]
        .as_str()
        .expect("state should be present on manifest response")
        .to_owned();
    assert_eq!(
        manifest_body["manifest"]["redirect_url"],
        "https://fabro.example.com/install/github/app/redirect",
        "redirect_url must not carry a query string — GitHub rejects manifests whose redirect_url has one"
    );

    let callback_response = checked_response(
        app.clone()
            .oneshot(
                Request::builder()
                    .method("GET")
                    .uri(format!(
                        "/install/github/app/redirect?code=stub-code&state={state}"
                    ))
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap(),
        StatusCode::FOUND,
        "GET /install/github/app/redirect?code=stub-code&state=...",
    )
    .await;
    assert_eq!(
        callback_response
            .headers()
            .get("location")
            .and_then(|value| value.to_str().ok()),
        Some(
            "/install/github?token=test-install-token&error=github-app-manifest-conversion-failed"
        )
    );
    conversion_mock.assert_calls_async(1).await;

    let retry_response = app
        .oneshot(
            Request::builder()
                .method("GET")
                .uri(format!(
                    "/install/github/app/redirect?code=stub-code&state={state}"
                ))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    response_status(
        retry_response,
        StatusCode::FOUND,
        "GET /install/github/app/redirect?code=stub-code&state=...",
    )
    .await;
    conversion_mock.assert_calls_async(2).await;
}

#[tokio::test]
async fn install_server_rejects_trailing_slash_canonical_urls() {
    let app = build_install_router(InstallAppState::for_test("test-install-token"));

    let response = app
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri("/install/server")
                .header("authorization", "Bearer test-install-token")
                .header("content-type", "application/json")
                .body(Body::from(
                    r#"{"canonical_url":"https://fabro.example.com/"}"#,
                ))
                .unwrap(),
        )
        .await
        .unwrap();

    let body = response_json(
        response,
        StatusCode::UNPROCESSABLE_ENTITY,
        "PUT /install/server",
    )
    .await;
    assert_eq!(
        body["errors"][0]["detail"],
        "canonical_url must not end with a trailing slash"
    );
}

#[tokio::test]
async fn install_server_rejects_wildcard_canonical_urls() {
    let app = build_install_router(InstallAppState::for_test("test-install-token"));

    for canonical_url in [
        "http://0.0.0.0:32276",
        "http://[::]:32276",
        "http://0:32276",
    ] {
        let response = app
            .clone()
            .oneshot(
                Request::builder()
                    .method("PUT")
                    .uri("/install/server")
                    .header("authorization", "Bearer test-install-token")
                    .header("content-type", "application/json")
                    .body(Body::from(format!(
                        r#"{{"canonical_url":"{canonical_url}"}}"#
                    )))
                    .unwrap(),
            )
            .await
            .unwrap();

        let body = response_json(
            response,
            StatusCode::UNPROCESSABLE_ENTITY,
            "PUT /install/server",
        )
        .await;
        assert_eq!(
            body["errors"][0]["detail"],
            "canonical_url must not use a wildcard host"
        );
    }
}

#[tokio::test]
async fn install_finish_failure_restores_settings_and_vault_but_leaves_env_keys() {
    let temp_dir = tempfile::tempdir().unwrap();
    let home_root = tempfile::tempdir().unwrap();
    let config_path = temp_dir.path().join("settings.toml");
    std::fs::write(&config_path, "_version = 1\n[project]\nname = \"keep\"\n").unwrap();

    let storage = Storage::new(temp_dir.path());
    let vault_path = storage.secrets_path();
    std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
    std::fs::write(&vault_path, "{ not valid json").unwrap();
    let callback_invoked = Arc::new(AtomicBool::new(false));
    let callback_flag = Arc::clone(&callback_invoked);

    let app = build_install_router(
        InstallAppState::for_test_with_paths("test-install-token", temp_dir.path(), &config_path)
            .with_home(Home::new(home_root.path().join(".fabro")))
            .with_finish_callback(Arc::new(move || {
                callback_flag.store(true, Ordering::Release);
            })),
    );

    configure_token_install(&app, "test-install-token").await;

    let finish_response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/finish")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let finish_body = response_json(
        finish_response,
        StatusCode::INTERNAL_SERVER_ERROR,
        "POST /install/finish",
    )
    .await;
    assert!(
        finish_body["errors"][0]["detail"]
            .as_str()
            .is_some_and(|value| value.contains("persisting install outputs directly"))
    );
    let leftover_env_keys = finish_body["leftover_env_keys"]
        .as_array()
        .expect("leftover_env_keys should be present");
    assert!(
        leftover_env_keys
            .iter()
            .any(|value| value == "SESSION_SECRET")
    );
    assert!(
        leftover_env_keys
            .iter()
            .any(|value| value == "FABRO_DEV_TOKEN")
    );

    assert_eq!(
        std::fs::read_to_string(&config_path).unwrap(),
        "_version = 1\n[project]\nname = \"keep\"\n"
    );
    assert_eq!(
        std::fs::read_to_string(&vault_path).unwrap(),
        "{ not valid json"
    );

    let server_env = std::fs::read_to_string(storage.runtime_directory().env_path()).unwrap();
    assert!(server_env.contains("SESSION_SECRET="));
    assert!(server_env.contains("FABRO_DEV_TOKEN="));
    assert!(!callback_invoked.load(Ordering::Acquire));

    let session_response = app
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/install/session")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let session_body =
        response_json(session_response, StatusCode::OK, "GET /install/session").await;
    assert!(
        session_body["completed_steps"]
            .as_array()
            .unwrap()
            .iter()
            .any(|value| value == "github")
    );
}

#[tokio::test(flavor = "current_thread")]
async fn install_finish_failure_with_manual_credentials_does_not_leak_values() {
    let temp_dir = tempfile::tempdir().unwrap();
    let config_path = temp_dir.path().join("settings.toml");
    std::fs::write(&config_path, "_version = 1\n[project]\nname = \"keep\"\n").unwrap();

    let storage = Storage::new(temp_dir.path());
    let vault_path = storage.secrets_path();
    std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
    std::fs::write(&vault_path, "{ not valid json").unwrap();

    let app = build_install_router(InstallAppState::for_test_with_paths(
        "test-install-token",
        temp_dir.path(),
        &config_path,
    ));

    let access_key_id = "AKIA_FINISH_SHOULD_NOT_LEAK";
    let secret_access_key = "finish-secret-should-not-leak";

    put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
    put_install_object_store(
        &app,
        "test-install-token",
        &format!(
            r#"{{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"access_key","access_key_id":"{access_key_id}","secret_access_key":"{secret_access_key}"}}"#
        ),
    )
    .await;
    put_install_sandbox_docker(&app, "test-install-token").await;
    put_install_llm(&app, "test-install-token").await;
    put_install_github_token(&app, "test-install-token", "brynary").await;

    let lines = Arc::new(StdMutex::new(Vec::new()));
    let subscriber = Registry::default().with(CaptureLayer {
        lines: Arc::clone(&lines),
    });
    let _guard = tracing::subscriber::set_default(subscriber);

    let finish_response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/finish")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let finish_body = response_json(
        finish_response,
        StatusCode::INTERNAL_SERVER_ERROR,
        "POST /install/finish",
    )
    .await;

    let rendered = finish_body.to_string();
    assert!(!rendered.contains(access_key_id));
    assert!(!rendered.contains(secret_access_key));

    let captured = lines.lock().unwrap().join("\n");
    assert!(
        captured.contains("install persistence failed"),
        "expected finish failure logs to be captured, got: {captured}"
    );
    assert!(!captured.contains(access_key_id));
    assert!(!captured.contains(secret_access_key));
}

#[tokio::test]
async fn install_finish_failure_reports_only_env_keys_actually_removed() {
    let temp_dir = tempfile::tempdir().unwrap();
    let config_path = temp_dir.path().join("settings.toml");
    std::fs::write(&config_path, "_version = 1\n[project]\nname = \"keep\"\n").unwrap();

    let storage = Storage::new(temp_dir.path());
    let env_path = storage.runtime_directory().env_path();
    std::fs::create_dir_all(env_path.parent().unwrap()).unwrap();
    std::fs::write(
        &env_path,
        format!(
            "#{OBJECT_STORE_MANAGED_COMMENT}\nAWS_SECRET_ACCESS_KEY=managed-secret\nKEEP_ME=1\n"
        ),
    )
    .unwrap();

    let vault_path = storage.secrets_path();
    std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
    std::fs::write(&vault_path, "{ not valid json").unwrap();

    let app = build_install_router(InstallAppState::for_test_with_paths(
        "test-install-token",
        temp_dir.path(),
        &config_path,
    ));

    put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
    put_install_object_store(
        &app,
        "test-install-token",
        r#"{"provider":"s3","bucket":"fabro-data","region":"us-east-1","credential_mode":"runtime"}"#,
    )
    .await;
    put_install_sandbox_docker(&app, "test-install-token").await;
    put_install_llm(&app, "test-install-token").await;
    put_install_github_token(&app, "test-install-token", "brynary").await;

    let finish_response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/finish")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let finish_body = response_json(
        finish_response,
        StatusCode::INTERNAL_SERVER_ERROR,
        "POST /install/finish",
    )
    .await;

    assert_eq!(
        finish_body["removed_env_keys"]
            .as_array()
            .expect("removed_env_keys should be present"),
        &vec![serde_json::Value::String(
            "AWS_SECRET_ACCESS_KEY".to_string()
        )]
    );

    let server_env = std::fs::read_to_string(env_path).unwrap();
    assert!(!server_env.contains("AWS_SECRET_ACCESS_KEY=managed-secret"));
    assert!(server_env.contains("KEEP_ME=1"));
}

#[tokio::test]
async fn install_finish_failure_does_not_create_dev_token_files() {
    let temp_dir = tempfile::tempdir().unwrap();
    let home_root = tempfile::tempdir().unwrap();
    let home = Home::new(home_root.path().join(".fabro"));
    let config_path = temp_dir.path().join("settings.toml");
    std::fs::write(&config_path, "_version = 1\n[project]\nname = \"keep\"\n").unwrap();

    let storage = Storage::new(temp_dir.path());
    let vault_path = storage.secrets_path();
    std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
    std::fs::write(&vault_path, "{ not valid json").unwrap();

    let app = build_install_router(
        InstallAppState::for_test_with_paths("test-install-token", temp_dir.path(), &config_path)
            .with_home(home.clone()),
    );

    configure_token_install(&app, "test-install-token").await;

    let finish_response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/finish")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    response_status(
        finish_response,
        StatusCode::INTERNAL_SERVER_ERROR,
        "POST /install/finish",
    )
    .await;

    assert!(
        !home.root().join("dev-token").exists(),
        "home dev token file should not be created"
    );
    assert!(
        !storage.runtime_directory().dev_token_path().exists(),
        "storage dev token file should not be created when persistence fails"
    );

    let server_env = envfile::read_env_file(&storage.runtime_directory().env_path()).unwrap();
    assert!(
        server_env
            .get(fabro_static::EnvVars::FABRO_DEV_TOKEN)
            .is_some_and(|value| !value.is_empty())
    );
}

#[tokio::test]
async fn sandbox_docker_save_records_explicit_provider_in_session() {
    let app = build_install_router(InstallAppState::for_test("test-install-token"));

    let validation_response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/sandbox/test")
                .header("authorization", "Bearer test-install-token")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"provider":"docker"}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    let validation_body = response_json(
        validation_response,
        StatusCode::OK,
        "POST /install/sandbox/test",
    )
    .await;
    assert_eq!(validation_body["ok"], true);

    put_install_sandbox_docker(&app, "test-install-token").await;

    let session_response = app
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/install/session")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let session_body =
        response_json(session_response, StatusCode::OK, "GET /install/session").await;
    assert_eq!(session_body["sandbox"]["provider"], "docker");
    assert!(
        session_body["completed_steps"]
            .as_array()
            .unwrap()
            .iter()
            .any(|value| value == "sandbox")
    );
}

#[tokio::test]
async fn sandbox_daytona_without_api_key_is_rejected() {
    let app = build_install_router(InstallAppState::for_test("test-install-token"));

    let response = app
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri("/install/sandbox")
                .header("authorization", "Bearer test-install-token")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"provider":"daytona"}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    let body = response_json(
        response,
        StatusCode::UNPROCESSABLE_ENTITY,
        "PUT /install/sandbox without api_key",
    )
    .await;
    assert_eq!(
        body["errors"][0]["detail"],
        "api_key is required for daytona"
    );
}

#[tokio::test]
async fn sandbox_daytona_test_endpoint_without_api_key_is_rejected() {
    let app = build_install_router(InstallAppState::for_test("test-install-token"));

    let response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/sandbox/test")
                .header("authorization", "Bearer test-install-token")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"provider":"daytona"}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    let body = response_json(
        response,
        StatusCode::UNPROCESSABLE_ENTITY,
        "POST /install/sandbox/test without api_key",
    )
    .await;
    assert_eq!(
        body["errors"][0]["detail"],
        "api_key is required for daytona"
    );
}

#[tokio::test]
async fn sandbox_daytona_resave_without_api_key_preserves_saved_key() {
    let temp_dir = tempfile::tempdir().unwrap();
    let config_path = temp_dir.path().join("settings.toml");
    let app = build_install_router(InstallAppState::for_test_with_paths(
        "test-install-token",
        temp_dir.path(),
        &config_path,
    ));

    let api_key = "dtn_keep_me";
    put_install_sandbox(
        &app,
        "test-install-token",
        &format!(r#"{{"provider":"daytona","api_key":"{api_key}"}}"#),
    )
    .await;
    put_install_sandbox(&app, "test-install-token", r#"{"provider":"daytona"}"#).await;

    let session_response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/install/session")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let session_body =
        response_json(session_response, StatusCode::OK, "GET /install/session").await;
    assert_eq!(session_body["sandbox"]["provider"], "daytona");
    assert_eq!(session_body["sandbox"]["api_key_saved"], true);

    put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
    put_install_object_store_local(&app, "test-install-token").await;
    put_install_llm(&app, "test-install-token").await;
    put_install_github_token(&app, "test-install-token", "brynary").await;

    let finish_response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/finish")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    response_status(
        finish_response,
        StatusCode::ACCEPTED,
        "POST /install/finish",
    )
    .await;

    let vault = load_secret_snapshot(temp_dir.path()).await;
    assert_eq!(vault.get("DAYTONA_API_KEY"), Some(api_key));
}

#[tokio::test]
async fn sandbox_switching_from_daytona_to_docker_drops_saved_key() {
    let temp_dir = tempfile::tempdir().unwrap();
    let config_path = temp_dir.path().join("settings.toml");
    let app = build_install_router(InstallAppState::for_test_with_paths(
        "test-install-token",
        temp_dir.path(),
        &config_path,
    ));

    put_install_sandbox(
        &app,
        "test-install-token",
        r#"{"provider":"daytona","api_key":"dtn_will_be_dropped"}"#,
    )
    .await;
    put_install_sandbox_docker(&app, "test-install-token").await;

    put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
    put_install_object_store_local(&app, "test-install-token").await;
    put_install_llm(&app, "test-install-token").await;
    put_install_github_token(&app, "test-install-token", "brynary").await;

    let finish_response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/finish")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    response_status(
        finish_response,
        StatusCode::ACCEPTED,
        "POST /install/finish",
    )
    .await;

    let settings = std::fs::read_to_string(&config_path).unwrap();
    assert!(
        settings.contains("[run.environment]"),
        "settings.toml should select the default environment"
    );
    assert_no_legacy_environment_dir(&temp_dir);
    let default_environment = seeded_default_environment(&temp_dir).await;
    assert_eq!(default_environment.settings.provider.to_string(), "docker");
    let vault = load_secret_snapshot(temp_dir.path()).await;
    assert_eq!(vault.get("DAYTONA_API_KEY"), None);
}

#[tokio::test]
async fn sandbox_daytona_save_redacts_api_key_in_session() {
    let app = build_install_router(InstallAppState::for_test("test-install-token"));
    let api_key = "dtn_should_not_leak";

    put_install_sandbox(
        &app,
        "test-install-token",
        &format!(r#"{{"provider":"daytona","api_key":"{api_key}"}}"#),
    )
    .await;

    let session_response = app
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/install/session")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let session_body =
        response_json(session_response, StatusCode::OK, "GET /install/session").await;
    assert_eq!(session_body["sandbox"]["provider"], "daytona");
    assert_eq!(session_body["sandbox"]["api_key_saved"], true);
    let rendered = session_body.to_string();
    assert!(!rendered.contains(api_key));
}

#[tokio::test]
async fn install_finish_requires_sandbox_step() {
    let app = build_install_router(InstallAppState::for_test("test-install-token"));

    put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
    put_install_object_store_local(&app, "test-install-token").await;
    put_install_llm(&app, "test-install-token").await;
    put_install_github_token(&app, "test-install-token", "brynary").await;

    let finish_response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/finish")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let finish_body = response_json(
        finish_response,
        StatusCode::UNPROCESSABLE_ENTITY,
        "POST /install/finish",
    )
    .await;
    assert_eq!(
        finish_body["errors"][0]["detail"],
        "install step 'sandbox' is incomplete"
    );
}

#[tokio::test]
async fn daytona_install_finish_writes_settings_and_vault_secret() {
    let temp_dir = tempfile::tempdir().unwrap();
    let config_path = temp_dir.path().join("settings.toml");
    let app = build_install_router(InstallAppState::for_test_with_paths(
        "test-install-token",
        temp_dir.path(),
        &config_path,
    ));

    let api_key = "dtn_test_secret";
    put_install_server(&app, "test-install-token", "https://fabro.example.com").await;
    put_install_object_store_local(&app, "test-install-token").await;
    put_install_sandbox(
        &app,
        "test-install-token",
        &format!(r#"{{"provider":"daytona","api_key":"{api_key}"}}"#),
    )
    .await;
    put_install_llm(&app, "test-install-token").await;
    put_install_github_token(&app, "test-install-token", "brynary").await;

    let finish_response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/finish")
                .header("authorization", "Bearer test-install-token")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    response_status(
        finish_response,
        StatusCode::ACCEPTED,
        "POST /install/finish",
    )
    .await;

    let settings = std::fs::read_to_string(&config_path).unwrap();
    assert!(
        settings.contains("[run.environment]"),
        "settings.toml should contain [run.environment]"
    );
    assert!(
        !settings.contains("[environments"),
        "settings.toml should not contain environment catalog entries"
    );
    assert_sandbox_provider_policy(&settings, true, false, true);
    assert_no_legacy_environment_dir(&temp_dir);
    let default_environment = seeded_default_environment(&temp_dir).await;
    assert_eq!(default_environment.settings.provider.to_string(), "daytona");
    assert!(matches!(
        default_environment.settings.image.dockerfile.as_ref(),
        Some(fabro_types::settings::run::DockerfileSource::Inline(content))
            if content.contains("buildpack-deps:noble")
    ));

    let vault = load_secret_snapshot(temp_dir.path()).await;
    assert_eq!(vault.get("DAYTONA_API_KEY"), Some(api_key));
}

#[tokio::test]
async fn sandbox_daytona_test_endpoint_rejects_under_scoped_api_key() {
    let server = MockServer::start_async().await;
    let auth = mock_daytona_auth_probe(&server).await;
    let current_key = mock_daytona_current_key(&server, vec![
        "delete:snapshots",
        "delete:sandboxes",
        "delete:volumes",
    ])
    .await;
    let app = build_install_router(
        InstallAppState::for_test("test-install-token")
            .with_daytona_api_base_url(server.base_url()),
    );

    let response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/sandbox/test")
                .header("authorization", "Bearer test-install-token")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"provider":"daytona","api_key":"dtn_test"}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    let body = response_json(
        response,
        StatusCode::UNPROCESSABLE_ENTITY,
        "POST /install/sandbox/test under-scoped daytona",
    )
    .await;

    assert_eq!(
        body["errors"][0]["detail"],
        "API key 'delete-only' is missing required Daytona scopes: \
         write:snapshots, write:sandboxes. Regenerate the key with all \
         snapshot and sandbox scopes."
    );
    auth.assert_async().await;
    current_key.assert_async().await;
}

#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"))]
async fn sandbox_daytona_test_endpoint_validates_real_api_key() {
    let api_key = std::env::var(fabro_static::EnvVars::DAYTONA_API_KEY)
        .expect("DAYTONA_API_KEY must be set for live test");
    let app = build_install_router(InstallAppState::for_test("test-install-token"));

    let response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/install/sandbox/test")
                .header("authorization", "Bearer test-install-token")
                .header("content-type", "application/json")
                .body(Body::from(format!(
                    r#"{{"provider":"daytona","api_key":"{api_key}"}}"#
                )))
                .unwrap(),
        )
        .await
        .unwrap();
    let body = response_json(
        response,
        StatusCode::OK,
        "POST /install/sandbox/test (live daytona)",
    )
    .await;
    assert_eq!(body["ok"], true);
}
