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

use axum::body::Body;
use axum::http::{Method, Request, StatusCode, header};
use fabro_config::Storage;
use fabro_server::server::build_router;
use fabro_server::test_support::{
    TestAppStateBuilder, TestAutomationRunMaterializer, build_test_router, test_auth_mode,
};
use fabro_static::EnvVars;
use serde_json::{Value, json};
use sqlx::Row as _;
use tower::ServiceExt;

use crate::helpers::{
    MINIMAL_DOT, api, checked_response, minimal_manifest_json, response_json, response_status,
    run_json,
};

fn automation_body(id: &str, name: &str) -> Value {
    json!({
        "id": id,
        "name": name,
        "description": "Runs on a schedule.",
        "target": {
            "repository": "fabro-sh/fabro",
            "ref": "main",
            "workflow": "release"
        },
        "triggers": [
            {
                "type": "api",
                "id": "manual",
                "enabled": true
            },
            {
                "type": "schedule",
                "id": "nightly",
                "enabled": true,
                "expression": "0 3 * * *"
            }
        ]
    })
}

fn replacement_body(name: &str) -> Value {
    json!({
        "name": name,
        "description": null,
        "target": {
            "repository": "fabro-sh/fabro",
            "ref": "main",
            "workflow": "release"
        },
        "triggers": [
            {
                "type": "api",
                "id": "manual",
                "enabled": false
            }
        ]
    })
}

fn automation_app() -> (axum::Router, tempfile::TempDir, PathBuf) {
    let temp_dir = tempfile::tempdir().expect("automation test tempdir should be created");
    let active_config_path = temp_dir.path().join("settings.toml");
    let vault_path = temp_dir.path().join("secrets.json");
    let sqlite_path = Storage::new(temp_dir.path()).sqlite_path();
    let state = TestAppStateBuilder::new()
        .active_config_path(active_config_path)
        .vault_path(vault_path)
        .build();
    (build_test_router(state), temp_dir, sqlite_path)
}

fn automation_app_with_fake_materializer() -> (axum::Router, tempfile::TempDir, PathBuf) {
    let temp_dir = tempfile::tempdir().expect("automation test tempdir should be created");
    let active_config_path = temp_dir.path().join("settings.toml");
    let vault_path = temp_dir.path().join("secrets.json");
    let sqlite_path = Storage::new(temp_dir.path()).sqlite_path();
    let materialized_manifest: fabro_api::types::RunManifest =
        serde_json::from_value(minimal_manifest_json(MINIMAL_DOT))
            .expect("minimal run manifest fixture should deserialize");
    let submitted_manifest_bytes =
        serde_json::to_vec(&materialized_manifest).expect("minimal run manifest should serialize");
    let state = TestAppStateBuilder::new()
        .active_config_path(active_config_path)
        .vault_path(vault_path)
        .vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
        .automation_materializer(TestAutomationRunMaterializer::succeed(
            materialized_manifest,
            submitted_manifest_bytes,
        ))
        .build();
    (build_test_router(state), temp_dir, sqlite_path)
}

fn json_request(method: Method, path: &str, body: &Value) -> Request<Body> {
    Request::builder()
        .method(method)
        .uri(api(path))
        .header(header::CONTENT_TYPE, "application/json")
        .body(Body::from(
            serde_json::to_vec(&body).expect("automation fixture should serialize"),
        ))
        .expect("automation JSON request should build")
}

fn empty_request(method: Method, path: &str) -> Request<Body> {
    Request::builder()
        .method(method)
        .uri(api(path))
        .body(Body::empty())
        .expect("automation request should build")
}

fn request_with_if_match(
    method: Method,
    path: &str,
    revision: &str,
    body: Option<Value>,
) -> Request<Body> {
    let mut builder = Request::builder()
        .method(method)
        .uri(api(path))
        .header(header::IF_MATCH, revision);
    let body = match body {
        Some(value) => {
            builder = builder.header(header::CONTENT_TYPE, "application/json");
            Body::from(serde_json::to_vec(&value).expect("automation fixture should serialize"))
        }
        None => Body::empty(),
    };
    builder
        .body(body)
        .expect("automation If-Match request should build")
}

async fn create_automation(app: &axum::Router, id: &str, name: &str) -> Value {
    create_automation_with_body(app, &automation_body(id, name)).await
}

async fn create_automation_with_body(app: &axum::Router, body: &Value) -> Value {
    let response = app
        .clone()
        .oneshot(json_request(Method::POST, "/automations", body))
        .await
        .expect("create automation should respond");
    response_json(response, StatusCode::CREATED, "POST /api/v1/automations").await
}

async fn create_automation_run(
    app: &axum::Router,
    automation_id: &str,
    expected: StatusCode,
) -> Value {
    let response = app
        .clone()
        .oneshot(empty_request(
            Method::POST,
            &format!("/automations/{automation_id}/runs"),
        ))
        .await
        .expect("create automation run should respond");
    response_json(
        response,
        expected,
        format!("POST /api/v1/automations/{automation_id}/runs"),
    )
    .await
}

async fn list_automation_runs(app: &axum::Router, path: &str) -> Value {
    let response = app
        .clone()
        .oneshot(empty_request(Method::GET, path))
        .await
        .expect("list automation runs should respond");
    response_json(response, StatusCode::OK, format!("GET /api/v1{path}")).await
}

fn revision_from(body: &Value) -> &str {
    body["revision"]
        .as_str()
        .expect("automation response should include a revision")
}

fn assert_schedule_trigger(body: &Value, expression: &str, enabled: bool) {
    let trigger = body["triggers"]
        .as_array()
        .expect("automation response should include triggers")
        .iter()
        .find(|trigger| trigger["id"] == "nightly")
        .expect("automation response should include nightly schedule trigger");

    assert_eq!(
        trigger,
        &json!({
            "type": "schedule",
            "id": "nightly",
            "enabled": enabled,
            "expression": expression
        })
    );
}

async fn persisted_automation(sqlite_path: &Path, id: &str) -> Option<Value> {
    let database = fabro_db::Database::connect(sqlite_path)
        .await
        .expect("automation test database should open");
    let parent = sqlx::query("SELECT api_enabled FROM automations WHERE id = ?")
        .bind(id)
        .fetch_optional(database.pool())
        .await
        .expect("persisted automation should query")?;
    let triggers = sqlx::query(
        "SELECT id, enabled, expression FROM automation_triggers \
         WHERE automation_id = ? ORDER BY id",
    )
    .bind(id)
    .fetch_all(database.pool())
    .await
    .expect("persisted automation triggers should query")
    .into_iter()
    .map(|row| {
        json!({
            "id": row.get::<String, _>("id"),
            "enabled": row.get::<bool, _>("enabled"),
            "expression": row.get::<String, _>("expression")
        })
    })
    .collect::<Vec<_>>();
    Some(json!({
        "api_enabled": parent.get::<bool, _>("api_enabled"),
        "triggers": triggers
    }))
}

#[tokio::test]
async fn empty_automation_list_returns_total_zero() {
    let (app, _temp_dir, _automation_dir) = automation_app();

    let response = app
        .oneshot(empty_request(Method::GET, "/automations"))
        .await
        .expect("list automations should respond");
    let body = response_json(response, StatusCode::OK, "GET /api/v1/automations").await;

    assert_eq!(
        body,
        json!({
            "data": [],
            "meta": {
                "total": 0
            }
        })
    );
}

#[tokio::test]
async fn create_automation_persists_sql_aggregate() {
    let (app, _temp_dir, sqlite_path) = automation_app();

    let body = create_automation(&app, "nightly", "Nightly").await;

    assert_eq!(body["id"], "nightly");
    assert_eq!(body["name"], "Nightly");
    assert_eq!(
        persisted_automation(&sqlite_path, "nightly").await,
        Some(json!({
            "api_enabled": true,
            "triggers": [{
                "id": "nightly",
                "enabled": true,
                "expression": "0 3 * * *"
            }]
        }))
    );
}

#[tokio::test]
async fn automation_persists_across_app_rebuild() {
    let temp_dir = tempfile::tempdir().expect("automation test tempdir should be created");
    let active_config_path = temp_dir.path().join("settings.toml");
    let vault_path = temp_dir.path().join("secrets.json");
    let state = TestAppStateBuilder::new()
        .active_config_path(active_config_path.clone())
        .vault_path(vault_path.clone())
        .build();
    let app = build_test_router(state);
    let created = create_automation(&app, "nightly", "Nightly").await;
    drop(app);

    let state = TestAppStateBuilder::new()
        .active_config_path(active_config_path)
        .vault_path(vault_path)
        .build();
    let response = build_test_router(state)
        .oneshot(empty_request(Method::GET, "/automations/nightly"))
        .await
        .expect("get persisted automation should respond");
    let retrieved = response_json(
        response,
        StatusCode::OK,
        "GET /api/v1/automations/nightly after app rebuild",
    )
    .await;

    assert_eq!(retrieved, created);
}

#[tokio::test]
async fn schedule_trigger_round_trips_through_create_list_get_and_sql() {
    let (app, _temp_dir, sqlite_path) = automation_app();

    let created = create_automation(&app, "nightly", "Nightly").await;
    assert_schedule_trigger(&created, "0 3 * * *", true);

    let response = app
        .clone()
        .oneshot(empty_request(Method::GET, "/automations/nightly"))
        .await
        .expect("get automation should respond");
    let retrieved =
        response_json(response, StatusCode::OK, "GET /api/v1/automations/nightly").await;
    assert_schedule_trigger(&retrieved, "0 3 * * *", true);

    let response = app
        .oneshot(empty_request(Method::GET, "/automations"))
        .await
        .expect("list automations should respond");
    let list = response_json(response, StatusCode::OK, "GET /api/v1/automations").await;
    assert_schedule_trigger(&list["data"][0], "0 3 * * *", true);

    assert_eq!(
        persisted_automation(&sqlite_path, "nightly")
            .await
            .expect("automation should be persisted")["triggers"][0],
        json!({
            "id": "nightly",
            "enabled": true,
            "expression": "0 3 * * *"
        })
    );
}

#[tokio::test]
async fn invalid_stored_automation_returns_internal_server_error() {
    let (app, _temp_dir, sqlite_path) = automation_app();
    create_automation(&app, "nightly", "Nightly").await;
    let database = fabro_db::Database::connect(sqlite_path)
        .await
        .expect("automation test database should open");
    sqlx::query(
        "UPDATE automation_triggers SET expression = 'not cron' WHERE automation_id = 'nightly'",
    )
    .execute(database.pool())
    .await
    .expect("stored schedule should be corrupted for the test");

    let response = app
        .oneshot(empty_request(Method::GET, "/automations/nightly"))
        .await
        .expect("get invalid stored automation should respond");

    response_status(
        response,
        StatusCode::INTERNAL_SERVER_ERROR,
        "GET /api/v1/automations/nightly with invalid stored schedule",
    )
    .await;
}

#[tokio::test]
async fn list_automations_returns_items_sorted_by_id() {
    let (app, _temp_dir, _automation_dir) = automation_app();
    create_automation(&app, "zulu", "Zulu").await;
    create_automation(&app, "alpha", "Alpha").await;

    let response = app
        .oneshot(empty_request(Method::GET, "/automations"))
        .await
        .expect("list automations should respond");
    let body = response_json(response, StatusCode::OK, "GET /api/v1/automations").await;

    assert_eq!(body["meta"]["total"], 2);
    assert_eq!(body["data"][0]["id"], "alpha");
    assert_eq!(body["data"][1]["id"], "zulu");
}

#[tokio::test]
async fn duplicate_automation_create_returns_conflict() {
    let (app, _temp_dir, _automation_dir) = automation_app();
    create_automation(&app, "nightly", "Nightly").await;

    let response = app
        .oneshot(json_request(
            Method::POST,
            "/automations",
            &automation_body("nightly", "Duplicate"),
        ))
        .await
        .expect("duplicate create should respond");

    response_status(
        response,
        StatusCode::CONFLICT,
        "POST /api/v1/automations duplicate",
    )
    .await;
}

#[tokio::test]
async fn get_automation_returns_current_etag() {
    let (app, _temp_dir, _automation_dir) = automation_app();
    let created = create_automation(&app, "nightly", "Nightly").await;
    let revision = revision_from(&created);

    let response = app
        .oneshot(empty_request(Method::GET, "/automations/nightly"))
        .await
        .expect("get automation should respond");
    let response =
        checked_response(response, StatusCode::OK, "GET /api/v1/automations/nightly").await;

    assert_eq!(
        response
            .headers()
            .get(header::ETAG)
            .expect("GET automation should include ETag"),
        &format!("\"{revision}\"")
    );
    let body = crate::helpers::body_json(response.into_body()).await;
    assert_eq!(body["revision"], revision);
}

#[tokio::test]
async fn replace_automation_accepts_unquoted_if_match_and_returns_new_etag() {
    let (app, _temp_dir, _automation_dir) = automation_app();
    let created = create_automation(&app, "nightly", "Nightly").await;
    let revision = revision_from(&created);

    let response = app
        .oneshot(request_with_if_match(
            Method::PUT,
            "/automations/nightly",
            revision,
            Some(replacement_body("Updated")),
        ))
        .await
        .expect("replace automation should respond");
    let response =
        checked_response(response, StatusCode::OK, "PUT /api/v1/automations/nightly").await;
    let etag = response
        .headers()
        .get(header::ETAG)
        .expect("PUT automation should include ETag")
        .to_str()
        .expect("ETag should be ASCII")
        .to_string();
    let body = crate::helpers::body_json(response.into_body()).await;

    assert_eq!(body["name"], "Updated");
    assert_ne!(body["revision"], revision);
    assert_eq!(etag, format!("\"{}\"", revision_from(&body)));
}

#[tokio::test]
async fn replace_automation_round_trips_schedule_trigger() {
    let (app, _temp_dir, sqlite_path) = automation_app();
    let created = create_automation(&app, "nightly", "Nightly").await;
    let revision = revision_from(&created);
    let mut replacement = replacement_body("Rescheduled");
    replacement["triggers"] = json!([
        {
            "type": "api",
            "id": "manual",
            "enabled": true
        },
        {
            "type": "schedule",
            "id": "nightly",
            "enabled": false,
            "expression": "30 4 * * *"
        }
    ]);

    let response = app
        .oneshot(request_with_if_match(
            Method::PUT,
            "/automations/nightly",
            revision,
            Some(replacement),
        ))
        .await
        .expect("replace automation should respond");
    let body = response_json(response, StatusCode::OK, "PUT /api/v1/automations/nightly").await;

    assert_schedule_trigger(&body, "30 4 * * *", false);
    assert_eq!(
        persisted_automation(&sqlite_path, "nightly")
            .await
            .expect("automation should be persisted")["triggers"][0],
        json!({
            "id": "nightly",
            "enabled": false,
            "expression": "30 4 * * *"
        })
    );
}

#[tokio::test]
async fn stale_automation_replace_returns_conflict() {
    let (app, _temp_dir, _automation_dir) = automation_app();
    let created = create_automation(&app, "nightly", "Nightly").await;
    let stale_revision = revision_from(&created).to_string();

    let replaced = app
        .clone()
        .oneshot(request_with_if_match(
            Method::PUT,
            "/automations/nightly",
            &stale_revision,
            Some(replacement_body("Updated")),
        ))
        .await
        .expect("first replace should respond");
    response_status(
        replaced,
        StatusCode::OK,
        "PUT /api/v1/automations/nightly first replace",
    )
    .await;

    let response = app
        .oneshot(request_with_if_match(
            Method::PUT,
            "/automations/nightly",
            &stale_revision,
            Some(replacement_body("Stale")),
        ))
        .await
        .expect("stale replace should respond");

    response_status(
        response,
        StatusCode::CONFLICT,
        "PUT /api/v1/automations/nightly stale",
    )
    .await;
}

#[tokio::test]
async fn replace_and_delete_automation_require_if_match() {
    let (app, _temp_dir, _automation_dir) = automation_app();
    create_automation(&app, "nightly", "Nightly").await;

    let replace_response = app
        .clone()
        .oneshot(json_request(
            Method::PUT,
            "/automations/nightly",
            &replacement_body("Updated"),
        ))
        .await
        .expect("replace without If-Match should respond");
    response_status(
        replace_response,
        StatusCode::PRECONDITION_REQUIRED,
        "PUT /api/v1/automations/nightly without If-Match",
    )
    .await;

    let delete_response = app
        .oneshot(empty_request(Method::DELETE, "/automations/nightly"))
        .await
        .expect("delete without If-Match should respond");
    response_status(
        delete_response,
        StatusCode::PRECONDITION_REQUIRED,
        "DELETE /api/v1/automations/nightly without If-Match",
    )
    .await;
}

#[tokio::test]
async fn delete_automation_removes_sql_aggregate() {
    let (app, _temp_dir, sqlite_path) = automation_app();
    let created = create_automation(&app, "nightly", "Nightly").await;
    let revision = revision_from(&created);

    let response = app
        .clone()
        .oneshot(request_with_if_match(
            Method::DELETE,
            "/automations/nightly",
            &format!("\"{revision}\""),
            None,
        ))
        .await
        .expect("delete automation should respond");
    response_status(
        response,
        StatusCode::NO_CONTENT,
        "DELETE /api/v1/automations/nightly",
    )
    .await;

    assert!(
        persisted_automation(&sqlite_path, "nightly")
            .await
            .is_none()
    );
    let response = app
        .oneshot(empty_request(Method::GET, "/automations/nightly"))
        .await
        .expect("get deleted automation should respond");
    response_status(
        response,
        StatusCode::NOT_FOUND,
        "GET /api/v1/automations/nightly after delete",
    )
    .await;
}

#[tokio::test]
async fn unknown_trigger_type_is_unprocessable() {
    let (app, _temp_dir, _automation_dir) = automation_app();
    let mut body = automation_body("nightly", "Nightly");
    body["triggers"][1] = json!({
        "type": "event",
        "id": "event_trigger",
        "enabled": true
    });

    let response = app
        .oneshot(json_request(Method::POST, "/automations", &body))
        .await
        .expect("unknown trigger type create should respond");

    response_status(
        response,
        StatusCode::UNPROCESSABLE_ENTITY,
        "POST /api/v1/automations unknown trigger type",
    )
    .await;
}

#[tokio::test]
async fn invalid_trigger_ids_are_unprocessable() {
    let (app, _temp_dir, _automation_dir) = automation_app();
    let mut body = automation_body("nightly", "Nightly");
    body["triggers"][0]["id"] = json!("Bad!");

    let response = app
        .oneshot(json_request(Method::POST, "/automations", &body))
        .await
        .expect("invalid trigger id create should respond");

    response_status(
        response,
        StatusCode::UNPROCESSABLE_ENTITY,
        "POST /api/v1/automations invalid trigger id",
    )
    .await;
}

#[tokio::test]
async fn empty_automation_name_is_unprocessable() {
    let (app, _temp_dir, _automation_dir) = automation_app();
    let mut body = automation_body("nightly", "Nightly");
    body["name"] = json!(" ");

    let response = app
        .oneshot(json_request(Method::POST, "/automations", &body))
        .await
        .expect("empty automation name create should respond");

    response_status(
        response,
        StatusCode::UNPROCESSABLE_ENTITY,
        "POST /api/v1/automations empty name",
    )
    .await;
}

#[tokio::test]
async fn duplicate_trigger_ids_are_unprocessable() {
    let (app, _temp_dir, _automation_dir) = automation_app();
    let mut body = automation_body("nightly", "Nightly");
    body["triggers"][1]["id"] = json!("manual");

    let response = app
        .oneshot(json_request(Method::POST, "/automations", &body))
        .await
        .expect("duplicate trigger create should respond");

    response_status(
        response,
        StatusCode::UNPROCESSABLE_ENTITY,
        "POST /api/v1/automations duplicate trigger ids",
    )
    .await;
}

#[tokio::test]
async fn second_api_trigger_is_unprocessable() {
    let (app, _temp_dir, _automation_dir) = automation_app();
    let mut body = automation_body("nightly", "Nightly");
    body["triggers"][1] = json!({
        "type": "api",
        "id": "manual2",
        "enabled": true
    });

    let response = app
        .oneshot(json_request(Method::POST, "/automations", &body))
        .await
        .expect("second API trigger create should respond");

    response_status(
        response,
        StatusCode::UNPROCESSABLE_ENTITY,
        "POST /api/v1/automations second API trigger",
    )
    .await;
}

#[tokio::test]
async fn invalid_schedule_expression_is_unprocessable() {
    let (app, _temp_dir, _automation_dir) = automation_app();
    let mut body = automation_body("nightly", "Nightly");
    body["triggers"][1]["expression"] = json!("60 3 * * *");

    let response = app
        .oneshot(json_request(Method::POST, "/automations", &body))
        .await
        .expect("invalid schedule create should respond");

    response_status(
        response,
        StatusCode::UNPROCESSABLE_ENTITY,
        "POST /api/v1/automations invalid schedule",
    )
    .await;
}

#[tokio::test]
async fn automation_store_malformed_persisted_toml_fails_startup() {
    let temp_dir = tempfile::tempdir().expect("automation test tempdir should be created");
    let automation_dir = temp_dir.path().join("automations");
    tokio::fs::create_dir_all(&automation_dir)
        .await
        .expect("automation dir should be created");
    tokio::fs::write(automation_dir.join("broken.toml"), "not valid toml =")
        .await
        .expect("broken automation fixture should be written");

    let result = TestAppStateBuilder::new()
        .active_config_path(temp_dir.path().join("settings.toml"))
        .try_build();

    assert!(result.is_err());
}

#[tokio::test]
async fn legacy_automation_is_imported_and_directory_is_backed_up() {
    let temp_dir = tempfile::tempdir().expect("automation test tempdir should be created");
    let automation_dir = temp_dir.path().join("automations");
    tokio::fs::create_dir_all(&automation_dir)
        .await
        .expect("automation dir should be created");
    tokio::fs::write(
        automation_dir.join("nightly.toml"),
        r#"name = "Legacy nightly"

[target]
repository = "fabro-sh/fabro"
ref = "main"
workflow = "release"

[[triggers]]
type = "api"
id = "manual"
enabled = true
"#,
    )
    .await
    .expect("legacy automation fixture should be written");
    let state = TestAppStateBuilder::new()
        .active_config_path(temp_dir.path().join("settings.toml"))
        .vault_path(temp_dir.path().join("secrets.json"))
        .build();

    let response = build_test_router(state)
        .oneshot(empty_request(Method::GET, "/automations/nightly"))
        .await
        .expect("get imported automation should respond");
    let body = response_json(
        response,
        StatusCode::OK,
        "GET /api/v1/automations/nightly after legacy import",
    )
    .await;

    assert_eq!(body["name"], "Legacy nightly");
    assert!(!automation_dir.exists());
    let mut entries = tokio::fs::read_dir(temp_dir.path())
        .await
        .expect("storage directory should be readable");
    let mut backup_exists = false;
    while let Some(entry) = entries
        .next_entry()
        .await
        .expect("storage directory entry should be readable")
    {
        if entry
            .file_name()
            .to_string_lossy()
            .starts_with("automations.imported-")
        {
            backup_exists = true;
            break;
        }
    }
    assert!(backup_exists);
}

#[tokio::test]
async fn automations_routes_require_authenticated_user() {
    let temp_dir = tempfile::tempdir().expect("automation test tempdir should be created");
    let state = TestAppStateBuilder::new()
        .active_config_path(temp_dir.path().join("settings.toml"))
        .build();
    let app = build_router(state, test_auth_mode());

    let response = app
        .oneshot(empty_request(Method::GET, "/automations"))
        .await
        .expect("unauthenticated automation list should respond");

    response_status(
        response,
        StatusCode::UNAUTHORIZED,
        "GET /api/v1/automations without auth",
    )
    .await;
}

#[tokio::test]
async fn missing_automation_run_endpoint_returns_not_found() {
    let (app, _temp_dir, _automation_dir) = automation_app_with_fake_materializer();

    create_automation_run(&app, "missing", StatusCode::NOT_FOUND).await;
}

#[tokio::test]
async fn disabled_api_trigger_run_endpoint_returns_conflict_code() {
    let (app, _temp_dir, _automation_dir) = automation_app_with_fake_materializer();
    let mut body = automation_body("nightly", "Nightly");
    body["triggers"][0]["enabled"] = json!(false);
    create_automation_with_body(&app, &body).await;

    let error = create_automation_run(&app, "nightly", StatusCode::CONFLICT).await;

    assert_eq!(
        error["errors"][0]["code"],
        "automation_api_trigger_disabled"
    );
}

#[tokio::test]
async fn missing_api_trigger_run_endpoint_returns_conflict_code() {
    let (app, _temp_dir, _automation_dir) = automation_app_with_fake_materializer();
    let mut body = automation_body("nightly", "Nightly");
    body["triggers"] = json!([
        {
            "type": "schedule",
            "id": "nightly",
            "enabled": true,
            "expression": "0 3 * * *"
        }
    ]);
    create_automation_with_body(&app, &body).await;

    let error = create_automation_run(&app, "nightly", StatusCode::CONFLICT).await;

    assert_eq!(
        error["errors"][0]["code"],
        "automation_api_trigger_disabled"
    );
}

#[tokio::test]
async fn successful_api_triggered_automation_run_persists_automation_metadata() {
    let (app, _temp_dir, _automation_dir) = automation_app_with_fake_materializer();
    create_automation(&app, "nightly", "Nightly").await;

    let created = create_automation_run(&app, "nightly", StatusCode::CREATED).await;

    assert_eq!(created["automation"]["id"], "nightly");
    assert_eq!(created["automation"]["name"], "Nightly");
    assert_eq!(created["automation"]["trigger_id"], "manual");

    let run_id = created["id"]
        .as_str()
        .expect("created automation run should include id");
    let retrieved = run_json(&app, run_id).await;
    assert_eq!(retrieved["id"], run_id);
    assert_eq!(retrieved["automation"], created["automation"]);
}

#[tokio::test]
async fn automation_run_listing_includes_only_runs_for_that_automation() {
    let (app, _temp_dir, _automation_dir) = automation_app_with_fake_materializer();
    create_automation(&app, "nightly", "Nightly").await;
    create_automation(&app, "weekly", "Weekly").await;
    let nightly = create_automation_run(&app, "nightly", StatusCode::CREATED).await;
    let weekly = create_automation_run(&app, "weekly", StatusCode::CREATED).await;

    let body = list_automation_runs(&app, "/automations/nightly/runs").await;

    assert_eq!(body["meta"]["total"], 1);
    assert_eq!(body["meta"]["has_more"], false);
    assert_eq!(
        body["data"].as_array().expect("data should be array").len(),
        1
    );
    assert_eq!(body["data"][0]["id"], nightly["id"]);
    assert_ne!(body["data"][0]["id"], weekly["id"]);
    assert_eq!(body["data"][0]["automation"]["id"], "nightly");
}

#[tokio::test]
async fn missing_automation_run_listing_returns_not_found() {
    let (app, _temp_dir, _automation_dir) = automation_app_with_fake_materializer();

    let response = app
        .oneshot(empty_request(Method::GET, "/automations/missing/runs"))
        .await
        .expect("missing automation run listing should respond");

    response_status(
        response,
        StatusCode::NOT_FOUND,
        "GET /api/v1/automations/missing/runs",
    )
    .await;
}

#[tokio::test]
async fn automation_run_listing_is_newest_first_and_paginates() {
    let (app, _temp_dir, _automation_dir) = automation_app_with_fake_materializer();
    create_automation(&app, "nightly", "Nightly").await;
    let oldest = create_automation_run(&app, "nightly", StatusCode::CREATED).await;
    let middle = create_automation_run(&app, "nightly", StatusCode::CREATED).await;
    let newest = create_automation_run(&app, "nightly", StatusCode::CREATED).await;

    let first_page = list_automation_runs(&app, "/automations/nightly/runs?page[limit]=2").await;
    assert_eq!(first_page["meta"]["total"], 3);
    assert_eq!(first_page["meta"]["has_more"], true);
    assert_eq!(first_page["data"][0]["id"], newest["id"]);
    assert_eq!(first_page["data"][1]["id"], middle["id"]);

    let second_page = list_automation_runs(
        &app,
        "/automations/nightly/runs?page[limit]=2&page[offset]=2",
    )
    .await;
    assert_eq!(second_page["meta"]["total"], 3);
    assert_eq!(second_page["meta"]["has_more"], false);
    assert_eq!(second_page["data"][0]["id"], oldest["id"]);
}
