//! Conformance tests: spec ↔ router ↔ Rust struct consistency.

use std::collections::BTreeSet;
use std::sync::Arc;

use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use fabro_api::jwt_auth::AuthMode;
use fabro_api::server::{build_router, create_app_state};
use fabro_api::server_config::*;
use fabro_workflows::cli::run_config::*;
use fabro_workflows::daytona_sandbox::*;
use fabro_workflows::handler::exit::ExitHandler;
use fabro_workflows::handler::start::StartHandler;
use fabro_workflows::handler::HandlerRegistry;
use fabro_workflows::hook::*;
use fabro_workflows::interviewer::Interviewer;
use tower::ServiceExt;

fn test_registry(_interviewer: Arc<dyn Interviewer>) -> HandlerRegistry {
    let mut registry = HandlerRegistry::new(Box::new(StartHandler));
    registry.register("start", Box::new(StartHandler));
    registry.register("exit", Box::new(ExitHandler));
    registry
}

async fn test_db() -> sqlx::SqlitePool {
    let pool = fabro_db::connect_memory().await.unwrap();
    fabro_db::initialize_db(&pool).await.unwrap();
    pool
}

fn load_spec() -> openapiv3::OpenAPI {
    let spec_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .unwrap()
        .parent()
        .unwrap()
        .parent()
        .unwrap()
        .join("docs/api-reference/fabro-api.yaml");
    let text = std::fs::read_to_string(&spec_path).expect("failed to read spec");
    serde_yaml::from_str(&text).expect("failed to parse spec")
}

fn resolve_path(path: &str) -> String {
    path.replace("{id}", "test-id")
        .replace("{qid}", "test-qid")
        .replace("{stageId}", "test-stage")
        .replace("{name}", "test-name")
        .replace("{slug}", "test-slug")
}

fn methods_for_path_item(item: &openapiv3::PathItem) -> Vec<Method> {
    let mut methods = Vec::new();
    if item.get.is_some() {
        methods.push(Method::GET);
    }
    if item.post.is_some() {
        methods.push(Method::POST);
    }
    if item.put.is_some() {
        methods.push(Method::PUT);
    }
    if item.delete.is_some() {
        methods.push(Method::DELETE);
    }
    if item.patch.is_some() {
        methods.push(Method::PATCH);
    }
    methods
}

#[tokio::test]
async fn all_spec_routes_are_routable() {
    let spec = load_spec();
    let state = create_app_state(test_db().await, test_registry);
    let app = build_router(state, AuthMode::Disabled);

    let mut checked = 0;
    for (path, item) in &spec.paths.paths {
        let path_item = match item {
            openapiv3::ReferenceOr::Item(item) => item,
            openapiv3::ReferenceOr::Reference { .. } => continue,
        };

        let uri = resolve_path(path);
        for method in methods_for_path_item(path_item) {
            let mut builder = Request::builder().method(&method).uri(&uri);

            let body = if method == Method::POST {
                builder = builder.header("content-type", "application/json");
                Body::from("{}")
            } else {
                Body::empty()
            };

            let req = builder.body(body).unwrap();
            let response = app.clone().oneshot(req).await.unwrap();

            assert_ne!(
                response.status(),
                StatusCode::METHOD_NOT_ALLOWED,
                "Route {method} {path} returned 405 — not registered in the router"
            );
            checked += 1;
        }
    }

    assert!(checked > 0, "No routes were checked — is the spec empty?");
}

// ── ServerConfig ↔ OpenAPI schema drift detection ──────────────────────

/// Load the spec as serde_json::Value for schema introspection.
fn load_spec_json() -> serde_json::Value {
    let spec_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .unwrap()
        .parent()
        .unwrap()
        .parent()
        .unwrap()
        .join("docs/api-reference/fabro-api.yaml");
    let text = std::fs::read_to_string(&spec_path).expect("read spec");
    serde_yaml::from_str(&text).expect("parse spec")
}

/// Follow a `$ref` pointer, or return the value unchanged.
fn resolve_ref<'a>(
    value: &'a serde_json::Value,
    root: &'a serde_json::Value,
) -> &'a serde_json::Value {
    match value.get("$ref").and_then(|v| v.as_str()) {
        Some(ref_str) => {
            let mut cur = root;
            for seg in ref_str.trim_start_matches("#/").split('/') {
                cur = &cur[seg];
            }
            cur
        }
        None => value,
    }
}

/// Collect property names from an OpenAPI schema object.
fn spec_keys(schema: &serde_json::Value) -> BTreeSet<String> {
    schema
        .get("properties")
        .and_then(|p| p.as_object())
        .map(|m| m.keys().cloned().collect())
        .unwrap_or_default()
}

/// Recursively compare serialized JSON keys against OpenAPI schema properties.
fn compare_schema(
    path: &str,
    json: &serde_json::Value,
    schema: &serde_json::Value,
    root: &serde_json::Value,
    errors: &mut Vec<String>,
) {
    let obj = match json.as_object() {
        Some(o) => o,
        None => return,
    };

    // Skip pure-map schemas (additionalProperties without properties).
    if schema.get("additionalProperties").is_some() && schema.get("properties").is_none() {
        return;
    }

    let json_keys: BTreeSet<String> = obj.keys().cloned().collect();
    let schema_keys = spec_keys(schema);

    for key in json_keys.difference(&schema_keys) {
        errors.push(format!(
            "{path}.{key}: in Rust but missing from OpenAPI spec"
        ));
    }
    for key in schema_keys.difference(&json_keys) {
        errors.push(format!(
            "{path}.{key}: in OpenAPI spec but missing from Rust"
        ));
    }

    let properties = match schema.get("properties").and_then(|p| p.as_object()) {
        Some(p) => p,
        None => return,
    };

    for key in json_keys.intersection(&schema_keys) {
        let json_val = &obj[key];
        let prop_schema = resolve_ref(&properties[key], root);

        // Skip maps and union types.
        if prop_schema.get("additionalProperties").is_some() || prop_schema.get("oneOf").is_some() {
            continue;
        }

        match json_val {
            serde_json::Value::Object(_) => {
                compare_schema(
                    &format!("{path}.{key}"),
                    json_val,
                    prop_schema,
                    root,
                    errors,
                );
            }
            serde_json::Value::Array(arr) => {
                // Union keys across all array elements.
                let union: BTreeSet<String> = arr
                    .iter()
                    .filter_map(|e| e.as_object())
                    .flat_map(|o| o.keys().cloned())
                    .collect();
                if union.is_empty() {
                    continue;
                }
                let items = match prop_schema.get("items") {
                    Some(i) => resolve_ref(i, root),
                    None => continue,
                };
                let synthetic = serde_json::Value::Object(
                    union
                        .into_iter()
                        .map(|k| (k, serde_json::Value::Null))
                        .collect(),
                );
                compare_schema(&format!("{path}.{key}[]"), &synthetic, items, root, errors);
            }
            _ => {}
        }
    }
}

/// Build a ServerConfig with every Option set to Some so all keys appear
/// in the serialized JSON.
fn fully_populated_server_config() -> ServerConfig {
    ServerConfig {
        data_dir: Some("/data".into()),
        max_concurrent_runs: Some(10),
        web: WebConfig {
            url: "https://example.com".into(),
            auth: AuthConfig {
                provider: AuthProvider::Github,
                allowed_usernames: vec!["user".into()],
            },
        },
        api: ApiConfig {
            base_url: "https://api.example.com".into(),
            authentication_strategies: vec![ApiAuthStrategy::Jwt],
            tls: Some(TlsConfig {
                cert: "c".into(),
                key: "k".into(),
                ca: "ca".into(),
            }),
        },
        git: GitConfig {
            provider: GitProvider::Github,
            app_id: Some("123".into()),
            client_id: Some("456".into()),
            slug: Some("fabro".into()),
            author: GitAuthorConfig {
                name: Some("bot".into()),
                email: Some("bot@x".into()),
            },
            webhooks: Some(WebhookConfig {
                strategy: WebhookStrategy::TailscaleFunnel,
            }),
        },
        features: Features {
            session_sandboxes: true,
            retros: false,
        },
        log: LogConfig {
            level: Some("debug".into()),
        },
        run_defaults: RunDefaults {
            work_dir: Some("/work".into()),
            llm: Some(LlmConfig {
                model: Some("m".into()),
                provider: Some("p".into()),
                fallbacks: Some(Default::default()),
            }),
            setup: Some(SetupConfig {
                commands: vec!["echo hi".into()],
                timeout_ms: Some(5000),
            }),
            sandbox: Some(SandboxConfig {
                provider: Some("daytona".into()),
                preserve: Some(true),
                devcontainer: None,
                local: None,
                daytona: Some(DaytonaConfig {
                    auto_stop_interval: Some(60),
                    labels: Some(Default::default()),
                    snapshot: Some(DaytonaSnapshotConfig {
                        name: "snap".into(),
                        cpu: Some(2),
                        memory: Some(4),
                        disk: Some(10),
                        dockerfile: Some(DockerfileSource::Inline("FROM x".into())),
                    }),
                    network: Some(DaytonaNetwork::Block),
                }),
                exe: Some(fabro_exe::ExeConfig { image: None }),
                ssh: None,
                env: Some(Default::default()),
            }),
            vars: Some(Default::default()),
            checkpoint: CheckpointConfig {
                exclude_globs: vec![],
            },
            pull_request: Some(PullRequestConfig {
                enabled: true,
                draft: false,
                auto_merge: false,
                merge_strategy: fabro_workflows::cli::run_config::MergeStrategy::Squash,
            }),
            assets: Some(AssetsConfig {
                include: vec!["test-results/**".into()],
            }),
            // One hook per HookType variant so the key union covers all fields.
            hooks: vec![
                HookDefinition {
                    name: Some("cmd".into()),
                    event: HookEvent::RunStart,
                    command: Some("echo".into()),
                    hook_type: None,
                    matcher: Some("*".into()),
                    blocking: Some(true),
                    timeout_ms: Some(5000),
                    sandbox: Some(true),
                },
                HookDefinition {
                    name: Some("http".into()),
                    event: HookEvent::RunStart,
                    command: None,
                    hook_type: Some(HookType::Http {
                        url: "http://x".into(),
                        headers: Some(Default::default()),
                        allowed_env_vars: vec!["X".into()],
                        tls: TlsMode::Verify,
                    }),
                    matcher: None,
                    blocking: None,
                    timeout_ms: None,
                    sandbox: None,
                },
                HookDefinition {
                    name: Some("prompt".into()),
                    event: HookEvent::RunStart,
                    command: None,
                    hook_type: Some(HookType::Prompt {
                        prompt: "hi".into(),
                        model: Some("m".into()),
                    }),
                    matcher: None,
                    blocking: None,
                    timeout_ms: None,
                    sandbox: None,
                },
                HookDefinition {
                    name: Some("agent".into()),
                    event: HookEvent::RunStart,
                    command: None,
                    hook_type: Some(HookType::Agent {
                        prompt: "hi".into(),
                        model: Some("m".into()),
                        max_tool_rounds: Some(5),
                    }),
                    matcher: None,
                    blocking: None,
                    timeout_ms: None,
                    sandbox: None,
                },
            ],
            mcp_servers: Default::default(),
            github: None,
        },
    }
}

#[test]
fn server_config_keys_match_openapi_spec() {
    let config = fully_populated_server_config();
    let json = serde_json::to_value(&config).expect("serialize ServerConfig");
    let spec = load_spec_json();
    let schema = &spec["components"]["schemas"]["ServerConfiguration"];

    let mut errors = Vec::new();
    compare_schema("ServerConfiguration", &json, schema, &spec, &mut errors);

    if !errors.is_empty() {
        panic!(
            "ServerConfig ↔ OpenAPI schema drift:\n  {}",
            errors.join("\n  ")
        );
    }
}
