//! OpenAPI 3.0 spec generated from the manifest.
//!
//! The same `Vec<EntityDef>` that drives the router drives the spec, so the
//! documentation always matches the live endpoints and each field's policy:
//! readable fields appear in the response schema, updatable fields in the
//! PATCH body, and the auto-injected `guid` is the (uuid) path identifier.

use roundabout_orm_schema::{EntityDef, FieldDef};
use serde_json::{json, Map, Value};

/// Build the complete OpenAPI document.
pub fn build_spec(manifest: &[EntityDef]) -> Value {
    let mut paths = Map::new();
    let mut schemas = Map::new();

    for ent in manifest {
        schemas.insert(ent.name.clone(), read_schema(ent));
        schemas.insert(format!("{}Update", ent.name), update_schema(ent));
        add_paths(&mut paths, ent);
    }

    let ws_tables: Vec<&str> = manifest
        .iter()
        .filter(|e| e.has_ws())
        .map(|e| e.table.as_str())
        .collect();
    let mut description =
        "Auto-generated by roundabout_orm. Entities are addressed by their `guid` (UUID); \
         writes are staged and applied asynchronously (HTTP 202)."
            .to_string();
    if !ws_tables.is_empty() {
        description.push_str(&format!(
            " Live WebSocket alerts: GET /ws/{{table}} for [{}].",
            ws_tables.join(", ")
        ));
    }

    json!({
        "openapi": "3.0.3",
        "info": {
            "title": "roundabout_orm API",
            "version": env!("CARGO_PKG_VERSION"),
            "description": description,
        },
        "paths": Value::Object(paths),
        "components": { "schemas": Value::Object(schemas) },
    })
}

/// Response schema: every readable field (includes `guid`).
fn read_schema(ent: &EntityDef) -> Value {
    let mut props = Map::new();
    for f in ent.readable() {
        props.insert(f.name.clone(), oas_type(f));
    }
    json!({ "type": "object", "properties": Value::Object(props) })
}

/// PATCH body schema: only updatable fields (never `guid`).
fn update_schema(ent: &EntityDef) -> Value {
    let mut props = Map::new();
    for f in ent.fields.iter().filter(|f| f.update) {
        props.insert(f.name.clone(), oas_type(f));
    }
    json!({ "type": "object", "properties": Value::Object(props) })
}

fn add_paths(paths: &mut Map<String, Value>, ent: &EntityDef) {
    let table = &ent.table;
    let name = &ent.name;
    let tag = table.clone();

    paths.insert(
        format!("/{table}"),
        json!({
            "get": {
                "tags": [tag],
                "summary": format!("List {table}"),
                "responses": { "200": {
                    "description": "Array of records",
                    "content": { "application/json": { "schema": {
                        "type": "array",
                        "items": { "$ref": format!("#/components/schemas/{name}") }
                    }}}
                }}
            }
        }),
    );

    let guid_param = json!({
        "name": "guid",
        "in": "path",
        "required": true,
        "schema": { "type": "string", "format": "uuid" }
    });

    paths.insert(
        format!("/{table}/{{guid}}"),
        json!({
            "get": {
                "tags": [tag],
                "summary": format!("Fetch one {name} by guid"),
                "parameters": [guid_param],
                "responses": {
                    "200": { "description": "The record", "content": { "application/json": {
                        "schema": { "$ref": format!("#/components/schemas/{name}") }
                    }}},
                    "404": { "description": "Not found" }
                }
            },
            "patch": {
                "tags": [tag],
                "summary": format!("Stage an update to a {name}"),
                "parameters": [guid_param],
                "requestBody": { "required": true, "content": { "application/json": {
                    "schema": { "$ref": format!("#/components/schemas/{name}Update") }
                }}},
                "responses": {
                    "202": { "description": "Update staged (applied asynchronously)" },
                    "400": { "description": "Unknown or non-updatable field" }
                }
            }
        }),
    );
}

/// Map a field's Rust type to an OpenAPI schema object.
fn oas_type(field: &FieldDef) -> Value {
    if field.sql_type == "UUID" {
        return json!({ "type": "string", "format": "uuid" });
    }
    match field.rust_type.as_str() {
        "i16" | "i32" | "u32" => json!({ "type": "integer", "format": "int32" }),
        "i64" | "u64" | "usize" | "isize" => json!({ "type": "integer", "format": "int64" }),
        "f32" => json!({ "type": "number", "format": "float" }),
        "f64" => json!({ "type": "number", "format": "double" }),
        "bool" => json!({ "type": "boolean" }),
        _ => json!({ "type": "string" }),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn f(name: &str, sql: &str, rust: &str, read: bool, update: bool, pk: bool) -> FieldDef {
        FieldDef {
            name: name.into(),
            sql_type: sql.into(),
            rust_type: rust.into(),
            read,
            update,
            ws: false,
            primary_key: pk,
            default_expr: None,
        }
    }

    fn manifest() -> Vec<EntityDef> {
        vec![EntityDef {
            name: "Product".into(),
            table: "products".into(),
            fields: vec![
                f("guid", "UUID", "String", true, false, true),
                f("name", "TEXT", "String", true, true, false),
                f("price", "DOUBLE PRECISION", "f64", true, true, false),
            ],
            relations: vec![],
        }]
    }

    #[test]
    fn spec_documents_paths_and_guid_schema() {
        let spec = build_spec(&manifest());

        // Both collection paths exist.
        assert!(spec["paths"]["/products"]["get"].is_object());
        assert!(spec["paths"]["/products/{guid}"]["get"].is_object());
        assert!(spec["paths"]["/products/{guid}"]["patch"].is_object());

        // Read schema includes guid as a uuid string.
        let read_props = &spec["components"]["schemas"]["Product"]["properties"];
        assert_eq!(read_props["guid"]["format"], "uuid");
        assert_eq!(read_props["price"]["type"], "number");

        // Update schema omits guid (not updatable) but includes name.
        let upd_props = &spec["components"]["schemas"]["ProductUpdate"]["properties"];
        assert!(upd_props["guid"].is_null());
        assert!(upd_props["name"].is_object());
    }
}
