///|
/// Semantic diagnostics for an already parsed OpenRPC document.
///
/// Parsing answers whether a document has the expected JSON shape. Linting is
/// a separate, non-mutating pass for rules that need the complete declaration
/// context, such as positional optionality and recursive schema boundaries.
pub fn Document::lint(self : Document) -> Array[Diagnostic] {
  let diagnostics : Array[Diagnostic] = []
  for index, server in self.servers {
    lint_server(server, "$.servers[" + index.to_string() + "]", diagnostics)
  }
  for method_index, method_value in self.methods {
    let method_path = "$.methods[" + method_index.to_string() + "]"
    lint_method(method_value, method_path, diagnostics)
  }
  for name, schema in self.components.schemas {
    lint_schema(schema, "$.components.schemas." + name, diagnostics)
  }
  for reference_diagnostic in self.lint_references() {
    diagnostics.push(reference_diagnostic)
  }
  diagnostics
}

///|
/// Check server URL templates and declared default values without performing I/O.
fn lint_server(
  server : Server,
  path : String,
  diagnostics : Array[Diagnostic],
) -> Unit {
  for name, variable in server.variables {
    if variable.enum_values.length() > 0 {
      let mut found_default = false
      for allowed in variable.enum_values {
        if allowed == variable.default_value {
          found_default = true
          break
        }
      }
      if !found_default {
        diagnostics.push(
          diagnostic(
            path + ".variables." + name + ".default",
            InvalidServerVariableValue,
            "server variable default must be one of its enum values",
          ),
        )
      }
    }
  }
  match server.render_url(Map([])) {
    Ok(_) => ()
    Err(server_diagnostics) =>
      for server_diagnostic in server_diagnostics {
        diagnostics.push({
          path: server_diagnostic.path,
          code: server_diagnostic.code,
          message: server_diagnostic.message,
        })
      }
  }
}

///|
/// Lint method-level names, parameter ordering, and child schemas.
fn lint_method(
  method_value : Method,
  method_path : String,
  diagnostics : Array[Diagnostic],
) -> Unit {
  if method_value.name.length() == 0 {
    diagnostics.push(
      diagnostic(
        method_path + ".name",
        EmptyMethodName,
        "method name must not be empty",
      ),
    )
  }
  for server_index, server in method_value.servers {
    lint_server(
      server,
      method_path + ".servers[" + server_index.to_string() + "]",
      diagnostics,
    )
  }
  let mut optional_seen = false
  for index, parameter in method_value.params {
    let parameter_path = method_path + ".params[" + index.to_string() + "]"
    if parameter.name.length() == 0 {
      diagnostics.push(
        diagnostic(
          parameter_path + ".name",
          EmptyParameterName,
          "parameter name must not be empty",
        ),
      )
    }
    for previous_index in 0.. ()
    Some(result) => {
      let result_path = method_path + ".result"
      if result.name.length() == 0 {
        diagnostics.push(
          diagnostic(
            result_path + ".name",
            EmptyResultName,
            "result name must not be empty",
          ),
        )
      }
      lint_schema(result.schema, result_path + ".schema", diagnostics)
    }
  }
}

///|
/// Recursively lint a supported schema value and its structural boundaries.
fn lint_schema(
  schema : Json,
  path : String,
  diagnostics : Array[Diagnostic],
) -> Unit {
  match schema {
    True | False => ()
    Object(schema_object) =>
      lint_schema_object(schema_object, path, diagnostics)
    _ =>
      diagnostics.push(
        diagnostic(
          path,
          InvalidSchemaBoundary,
          "schema must be a boolean or object",
        ),
      )
  }
}

///|
/// Lint object-form schema keywords and recurse into child schemas.
fn lint_schema_object(
  schema : Map[String, Json],
  path : String,
  diagnostics : Array[Diagnostic],
) -> Unit {
  lint_type_keyword(schema, path, diagnostics)
  lint_enum_keyword(schema, path, diagnostics)
  lint_boolean_keyword(schema, "uniqueItems", path, diagnostics)
  lint_integer_bound_pair(schema, "minLength", "maxLength", path, diagnostics)
  lint_integer_bound_pair(schema, "minItems", "maxItems", path, diagnostics)
  lint_integer_bound_pair(
    schema, "minProperties", "maxProperties", path, diagnostics,
  )
  lint_number_bound_pair(schema, "minimum", "maximum", path, diagnostics)
  lint_number_bound_pair(
    schema, "exclusiveMinimum", "exclusiveMaximum", path, diagnostics,
  )
  lint_required_keyword(schema, path, diagnostics)
  lint_properties_keyword(schema, path, diagnostics)
  lint_additional_properties(schema, path, diagnostics)
  lint_items_keyword(schema, path, diagnostics)
  lint_composition_keyword(schema, "allOf", path, diagnostics)
  lint_composition_keyword(schema, "anyOf", path, diagnostics)
  lint_composition_keyword(schema, "oneOf", path, diagnostics)
  match schema.get("not") {
    None => ()
    Some(child_schema) => lint_schema(child_schema, path + ".not", diagnostics)
  }
}

///|
/// Check a boolean-only schema keyword without imposing a value policy.
fn lint_boolean_keyword(
  schema : Map[String, Json],
  keyword : String,
  path : String,
  diagnostics : Array[Diagnostic],
) -> Unit {
  match schema.get(keyword) {
    None | Some(True) | Some(False) => ()
    Some(_) =>
      diagnostics.push(
        diagnostic(
          path + "." + keyword,
          InvalidSchemaBoundary,
          keyword + " must be a boolean",
        ),
      )
  }
}

///|
/// Check primitive type declarations without evaluating a value.
fn lint_type_keyword(
  schema : Map[String, Json],
  path : String,
  diagnostics : Array[Diagnostic],
) -> Unit {
  match schema.get("type") {
    None => ()
    Some(String(type_name)) =>
      if !known_schema_type(type_name) {
        diagnostics.push(
          diagnostic(
            path + ".type",
            InvalidSchemaBoundary,
            "schema type is not supported",
          ),
        )
      }
    Some(Array(type_values)) => {
      if type_values.length() == 0 {
        diagnostics.push(
          diagnostic(
            path + ".type",
            InvalidSchemaBoundary,
            "schema type array cannot be empty",
          ),
        )
      }
      for index, type_value in type_values {
        match type_value {
          String(type_name) =>
            if !known_schema_type(type_name) {
              diagnostics.push(
                diagnostic(
                  path + ".type[" + index.to_string() + "]",
                  InvalidSchemaBoundary,
                  "schema type is not supported",
                ),
              )
            }
          _ =>
            diagnostics.push(
              diagnostic(
                path + ".type[" + index.to_string() + "]",
                InvalidSchemaBoundary,
                "schema type array must contain strings",
              ),
            )
        }
      }
    }
    Some(_) =>
      diagnostics.push(
        diagnostic(
          path + ".type",
          InvalidSchemaBoundary,
          "schema type must be a string or array",
        ),
      )
  }
}

///|
/// Check that enum is a non-empty array and contains JSON values.
fn lint_enum_keyword(
  schema : Map[String, Json],
  path : String,
  diagnostics : Array[Diagnostic],
) -> Unit {
  match schema.get("enum") {
    None => ()
    Some(Array(values)) =>
      if values.length() == 0 {
        diagnostics.push(
          diagnostic(
            path + ".enum",
            InvalidSchemaBoundary,
            "enum must contain at least one value",
          ),
        )
      }
    Some(_) =>
      diagnostics.push(
        diagnostic(
          path + ".enum",
          InvalidSchemaBoundary,
          "enum must be an array",
        ),
      )
  }
}

///|
/// Validate integer bounds and report contradictions at the lower bound.
fn lint_integer_bound_pair(
  schema : Map[String, Json],
  minimum_keyword : String,
  maximum_keyword : String,
  path : String,
  diagnostics : Array[Diagnostic],
) -> Unit {
  let minimum = lint_nonnegative_integer(
    schema, minimum_keyword, path, diagnostics,
  )
  let maximum = lint_nonnegative_integer(
    schema, maximum_keyword, path, diagnostics,
  )
  match (minimum, maximum) {
    (Some(minimum_value), Some(maximum_value)) if minimum_value > maximum_value =>
      diagnostics.push(
        diagnostic(
          path + "." + minimum_keyword,
          InvalidSchemaBoundary,
          minimum_keyword + " must not exceed " + maximum_keyword,
        ),
      )
    _ => ()
  }
}

///|
/// Validate numeric bounds and report contradictions at the lower bound.
fn lint_number_bound_pair(
  schema : Map[String, Json],
  minimum_keyword : String,
  maximum_keyword : String,
  path : String,
  diagnostics : Array[Diagnostic],
) -> Unit {
  let minimum = lint_number(schema, minimum_keyword, path, diagnostics)
  let maximum = lint_number(schema, maximum_keyword, path, diagnostics)
  match (minimum, maximum) {
    (Some(minimum_value), Some(maximum_value)) if minimum_value > maximum_value =>
      diagnostics.push(
        diagnostic(
          path + "." + minimum_keyword,
          InvalidSchemaBoundary,
          minimum_keyword + " must not exceed " + maximum_keyword,
        ),
      )
    _ => ()
  }
}

///|
/// Read a non-negative integer keyword while emitting malformed-value errors.
fn lint_nonnegative_integer(
  schema : Map[String, Json],
  keyword : String,
  path : String,
  diagnostics : Array[Diagnostic],
) -> Int? {
  match schema.get(keyword) {
    None => None
    Some(Number(value, ..)) => {
      let integer = value.to_int()
      if value != integer.to_double() || integer < 0 {
        diagnostics.push(
          diagnostic(
            path + "." + keyword,
            InvalidSchemaBoundary,
            keyword + " must be a non-negative integer",
          ),
        )
        None
      } else {
        Some(integer)
      }
    }
    Some(_) => {
      diagnostics.push(
        diagnostic(
          path + "." + keyword,
          InvalidSchemaBoundary,
          keyword + " must be a non-negative integer",
        ),
      )
      None
    }
  }
}

///|
/// Read a numeric keyword while emitting malformed-value errors.
fn lint_number(
  schema : Map[String, Json],
  keyword : String,
  path : String,
  diagnostics : Array[Diagnostic],
) -> Double? {
  match schema.get(keyword) {
    None => None
    Some(Number(value, ..)) => Some(value)
    Some(_) => {
      diagnostics.push(
        diagnostic(
          path + "." + keyword,
          InvalidSchemaBoundary,
          keyword + " must be a number",
        ),
      )
      None
    }
  }
}

///|
/// Validate required names and duplicate required entries.
fn lint_required_keyword(
  schema : Map[String, Json],
  path : String,
  diagnostics : Array[Diagnostic],
) -> Unit {
  match schema.get("required") {
    None => ()
    Some(Array(names)) =>
      for index, value in names {
        match value {
          String(name) =>
            for previous_index in 0..
                  diagnostics.push(
                    diagnostic(
                      path + ".required[" + index.to_string() + "]",
                      InvalidSchemaBoundary,
                      "required property names must be unique",
                    ),
                  )
                _ => ()
              }
            }
          _ =>
            diagnostics.push(
              diagnostic(
                path + ".required[" + index.to_string() + "]",
                InvalidSchemaBoundary,
                "required must contain strings",
              ),
            )
        }
      }
    Some(_) =>
      diagnostics.push(
        diagnostic(
          path + ".required",
          InvalidSchemaBoundary,
          "required must be an array",
        ),
      )
  }
}

///|
/// Recurse into an object-form properties map.
fn lint_properties_keyword(
  schema : Map[String, Json],
  path : String,
  diagnostics : Array[Diagnostic],
) -> Unit {
  match schema.get("properties") {
    None => ()
    Some(Object(properties)) =>
      for name, property_schema in properties {
        lint_schema(property_schema, path + ".properties." + name, diagnostics)
      }
    Some(_) =>
      diagnostics.push(
        diagnostic(
          path + ".properties",
          InvalidSchemaBoundary,
          "properties must be an object",
        ),
      )
  }
}

///|
/// Check additionalProperties shape; object values are recursively linted.
fn lint_additional_properties(
  schema : Map[String, Json],
  path : String,
  diagnostics : Array[Diagnostic],
) -> Unit {
  match schema.get("additionalProperties") {
    None | Some(True) | Some(False) => ()
    Some(Object(additional_schema)) =>
      lint_schema(
        Json::object(additional_schema),
        path + ".additionalProperties",
        diagnostics,
      )
    Some(_) =>
      diagnostics.push(
        diagnostic(
          path + ".additionalProperties",
          InvalidSchemaBoundary,
          "additionalProperties must be a boolean or object",
        ),
      )
  }
}

///|
/// Check and recurse into a single item schema; tuple arrays are explicit.
fn lint_items_keyword(
  schema : Map[String, Json],
  path : String,
  diagnostics : Array[Diagnostic],
) -> Unit {
  match schema.get("items") {
    None => ()
    Some(Array(_)) =>
      diagnostics.push(
        diagnostic(
          path + ".items",
          InvalidSchemaBoundary,
          "tuple-style items arrays are not supported",
        ),
      )
    Some(item_schema) => lint_schema(item_schema, path + ".items", diagnostics)
  }
}

///|
/// Ensure composition keywords have children and recurse into each branch.
fn lint_composition_keyword(
  schema : Map[String, Json],
  keyword : String,
  path : String,
  diagnostics : Array[Diagnostic],
) -> Unit {
  match schema.get(keyword) {
    None => ()
    Some(Array(schemas)) => {
      if schemas.length() == 0 {
        diagnostics.push(
          diagnostic(
            path + "." + keyword,
            InvalidSchemaBoundary,
            keyword + " must contain at least one schema",
          ),
        )
      }
      for index, child_schema in schemas {
        lint_schema(
          child_schema,
          path + "." + keyword + "[" + index.to_string() + "]",
          diagnostics,
        )
      }
    }
    Some(_) =>
      diagnostics.push(
        diagnostic(
          path + "." + keyword,
          InvalidSchemaBoundary,
          keyword + " must be an array",
        ),
      )
  }
}