//! OpenAPI 3.x extractor (also handles Swagger 2.x for paths).
//!
//! Reads YAML or JSON. Emits:
//! - One `Route` Symbol per `paths.<path>.<method>` (with `operationId`,
//!   summary, and tags in `doc`).
//! - One `Class` Symbol per `components.schemas.<Name>` (or
//!   `definitions.<Name>` in Swagger 2). Required + optional properties
//!   become `Constant`-kind Symbols with `Defines` edges.
//! - `Implements` edges for `allOf` parent schemas.

mod components;
mod paths;

use std::path::Path;

use ckg_core::Result;
use serde_yml::Value;

use crate::ParseOutput;

/// Hard cap on `openapi.yaml` / `openapi.json` source size. Real-world
/// specs are megabytes; gigabyte-scale inputs are pathological or
/// adversarial. `serde_yml::from_str` allocates the whole document
/// graph before validation — without this cap a 4 GB crafted spec
/// OOMs the indexer (parallel to `MAX_SCIP_BYTES`).
pub const MAX_OPENAPI_BYTES: usize = 64 * 1024 * 1024; // 64 MiB

/// Hard cap on `$ref` resolution depth. Prevents stack overflow on
/// recursively-defined schemas (`A: { $ref: B }`, `B: { $ref: A }`).
pub const MAX_REF_DEPTH: usize = 16;

pub fn extract(repo_id: &str, path: &Path, source: &str) -> Result<ParseOutput> {
    if source.len() > MAX_OPENAPI_BYTES {
        return Err(ckg_core::Error::Parse(format!(
            "openapi: source too large ({} bytes > {} cap)",
            source.len(),
            MAX_OPENAPI_BYTES
        )));
    }
    let mut out = ParseOutput::default();
    let relpath = crate::util::path_to_relpath(path)?;
    let doc: Value = serde_yml::from_str(source)
        .map_err(|e| ckg_core::Error::wrap("openapi", e))?;

    paths::extract_paths(&doc, repo_id, &relpath, &mut out);
    components::extract_schemas(&doc, repo_id, &relpath, &mut out);
    Ok(out)
}

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

    #[test]
    fn extracts_paths_and_schemas() {
        let src = r#"
openapi: 3.0.0
info:
  title: Demo
paths:
  /users:
    get:
      operationId: listUsers
      summary: List users
      tags: [users]
    post:
      operationId: createUser
  /users/{id}:
    get:
      operationId: getUser
components:
  schemas:
    User:
      description: A user
      required: [id, name]
      properties:
        id:
          type: integer
        name:
          type: string
        team:
          $ref: '#/components/schemas/Team'
    AdminUser:
      allOf:
        - $ref: '#/components/schemas/User'
      properties:
        role:
          type: string
"#;
        let out = extract("repo", Path::new("openapi.yaml"), src).unwrap();
        let names: Vec<&str> = out.symbols.iter().map(|s| s.name.as_str()).collect();
        // Routes
        for r in ["GET /users", "POST /users", "GET /users/{id}"] {
            assert!(names.contains(&r), "route {r} missing: {names:?}");
        }
        // Schemas
        for s in ["User", "AdminUser", "id", "name", "team", "role"] {
            assert!(names.contains(&s), "schema {s} missing");
        }
        // allOf → Implements
        assert!(
            out.edges
                .iter()
                .any(|e| matches!(e.kind, EdgeKind::Implements) && e.dst == "User")
        );
        // operationId / summary / tags propagated
        let list = out.symbols.iter().find(|s| s.name == "GET /users").unwrap();
        assert!(list.doc.contains("listUsers"));
        assert!(list.doc.contains("List users"));
        assert!(list.doc.contains("tags=[users]"));
    }

    #[test]
    fn parses_json_too() {
        let src = r#"{"openapi":"3.0.0","paths":{"/health":{"get":{"operationId":"health"}}}}"#;
        let out = extract("repo", Path::new("openapi.json"), src).unwrap();
        assert!(out.symbols.iter().any(|s| s.name == "GET /health"));
    }

    /// Item #9: nested object schemas walked recursively. `User` has an
    /// `address` property of `type: object`; `address.city` must emit
    /// as a Defines descendant.
    #[test]
    fn extracts_nested_object_schemas() {
        let src = r#"
openapi: 3.0.0
components:
  schemas:
    User:
      type: object
      properties:
        name:
          type: string
        address:
          type: object
          required: [city]
          properties:
            city:
              type: string
            zip:
              type: string
"#;
        let out = extract("repo", Path::new("api.yaml"), src).unwrap();
        let qnames: Vec<&str> = out.symbols.iter().map(|s| s.qname.as_str()).collect();
        assert!(qnames.contains(&"User.address"));
        assert!(qnames.contains(&"User.address.city"));
        assert!(qnames.contains(&"User.address.zip"));
        // city is `required` — doc should say so.
        let city = out
            .symbols
            .iter()
            .find(|s| s.qname == "User.address.city")
            .unwrap();
        assert!(city.doc.contains("required"), "city.doc={:?}", city.doc);
    }

    /// Item #10: path-level + operation-level `parameters` extracted
    /// into the route's doc.
    #[test]
    fn extracts_path_parameters() {
        let src = r#"
openapi: 3.0.0
paths:
  /users/{id}:
    parameters:
      - name: id
        in: path
        required: true
    get:
      parameters:
        - name: include
          in: query
"#;
        let out = extract("repo", Path::new("api.yaml"), src).unwrap();
        let route = out
            .symbols
            .iter()
            .find(|s| s.name == "GET /users/{id}")
            .unwrap();
        assert!(
            route.doc.contains("id:path:required"),
            "expected `id:path:required` in doc; got {:?}",
            route.doc
        );
        assert!(
            route.doc.contains("include:query"),
            "expected `include:query` in doc; got {:?}",
            route.doc
        );
    }

    /// Item #12: `MAX_OPENAPI_BYTES` cap rejects oversize specs.
    #[test]
    fn rejects_oversize_spec() {
        let huge = "openapi: 3.0.0\npaths:\n".to_string()
            + &"  /x:\n    get:\n      summary: x\n".repeat(MAX_OPENAPI_BYTES);
        // Above the cap — should error.
        assert!(huge.len() > MAX_OPENAPI_BYTES);
        let res = extract("repo", Path::new("huge.yaml"), &huge);
        match res {
            Err(ckg_core::Error::Parse(msg)) => {
                assert!(msg.contains("too large"), "msg={msg}");
            }
            other => panic!("expected too-large parse error; got {other:?}"),
        }
    }

    /// Item #25: route ids must include relpath — the same `GET /x` in
    /// two different OpenAPI files MUST get distinct ids.
    #[test]
    fn route_ids_are_relpath_qualified() {
        let src = r#"openapi: 3.0.0
paths:
  /x:
    get:
      operationId: x
"#;
        let a = extract("repo", Path::new("a.yaml"), src).unwrap();
        let b = extract("repo", Path::new("b.yaml"), src).unwrap();
        let ra = a.symbols.iter().find(|s| s.name == "GET /x").unwrap();
        let rb = b.symbols.iter().find(|s| s.name == "GET /x").unwrap();
        assert_ne!(
            ra.id, rb.id,
            "route ids must differ across files; got both = {}",
            ra.id
        );
    }
}
