///|
fn make_schema_rule(
  entity_name : String,
  minimum_arguments : Int,
  maximum_arguments : Int?,
  required_reference_arguments : Array[Int],
  family : String,
  description : String,
) -> SchemaRule {
  {
    entity_name: entity_name.to_upper(),
    minimum_arguments,
    maximum_arguments,
    required_reference_arguments,
    family,
    description,
  }
}

///|
fn common_schema_rules() -> Array[SchemaRule] {
  [
    make_schema_rule(
      "IFCPROJECT",
      9,
      Some(9),
      [1, 7, 8],
      "spatial",
      "project root",
    ),
    make_schema_rule(
      "IFCSITE",
      9,
      Some(9),
      [1, 5, 6, 9],
      "spatial",
      "site root",
    ),
    make_schema_rule(
      "IFCBUILDING",
      9,
      Some(9),
      [1, 5, 6, 9],
      "spatial",
      "building root",
    ),
    make_schema_rule(
      "IFCBUILDINGSTOREY",
      10,
      Some(10),
      [1, 5],
      "spatial",
      "storey",
    ),
    make_schema_rule("IFCSPACE", 10, Some(10), [1, 5, 6], "spatial", "space"),
    make_schema_rule(
      "IFCWALL",
      8,
      Some(8),
      [1, 5, 6],
      "product",
      "wall product",
    ),
    make_schema_rule(
      "IFCWALLSTANDARDCASE",
      8,
      Some(8),
      [1, 5, 6],
      "product",
      "wall product",
    ),
    make_schema_rule(
      "IFCWALLELEMENTEDCASE",
      8,
      Some(8),
      [1, 5, 6],
      "product",
      "wall product",
    ),
    make_schema_rule(
      "IFCDOOR",
      9,
      Some(9),
      [1, 5, 6],
      "product",
      "door product",
    ),
    make_schema_rule(
      "IFCWINDOW",
      9,
      Some(9),
      [1, 5, 6],
      "product",
      "window product",
    ),
    make_schema_rule(
      "IFCBEAM",
      8,
      Some(8),
      [1, 5, 6],
      "product",
      "beam product",
    ),
    make_schema_rule(
      "IFCCOLUMN",
      8,
      Some(8),
      [1, 5, 6],
      "product",
      "column product",
    ),
    make_schema_rule(
      "IFCSLAB",
      8,
      Some(8),
      [1, 5, 6],
      "product",
      "slab product",
    ),
    make_schema_rule(
      "IFCROOF",
      8,
      Some(8),
      [1, 5, 6],
      "product",
      "roof product",
    ),
    make_schema_rule(
      "IFCOWNERHISTORY",
      8,
      Some(8),
      [],
      "metadata",
      "owner history",
    ),
    make_schema_rule(
      "IFCLOCALPLACEMENT",
      2,
      Some(2),
      [1],
      "placement",
      "relative placement",
    ),
    make_schema_rule(
      "IFCAXIS2PLACEMENT3D",
      3,
      Some(3),
      [0],
      "placement",
      "3D placement",
    ),
    make_schema_rule(
      "IFCAXIS2PLACEMENT2D",
      2,
      Some(2),
      [0],
      "placement",
      "2D placement",
    ),
    make_schema_rule(
      "IFCCARTESIANPOINT",
      1,
      Some(1),
      [],
      "geometry",
      "point coordinates",
    ),
    make_schema_rule(
      "IFCDIRECTION",
      1,
      Some(1),
      [],
      "geometry",
      "direction ratios",
    ),
    make_schema_rule(
      "IFCEXTRUDEDAREASOLID",
      4,
      Some(4),
      [0, 1, 2],
      "geometry",
      "extruded solid",
    ),
    make_schema_rule(
      "IFCRECTANGLEPROFILEDEF",
      5,
      Some(5),
      [],
      "geometry",
      "rectangle profile",
    ),
    make_schema_rule(
      "IFCCIRCLEPROFILEDEF",
      4,
      Some(4),
      [],
      "geometry",
      "circle profile",
    ),
    make_schema_rule(
      "IFCPRODUCTDEFINITIONSHAPE",
      3,
      Some(3),
      [2],
      "geometry",
      "product shape",
    ),
    make_schema_rule(
      "IFCSHAPEREPRESENTATION",
      4,
      Some(4),
      [0, 3],
      "geometry",
      "shape representation",
    ),
    make_schema_rule(
      "IFCGEOMETRICREPRESENTATIONCONTEXT",
      6,
      None,
      [4],
      "geometry",
      "representation context",
    ),
    make_schema_rule(
      "IFCUNITASSIGNMENT",
      1,
      Some(1),
      [0],
      "units",
      "unit assignment",
    ),
    make_schema_rule("IFCSIUNIT", 4, Some(4), [], "units", "SI unit"),
    make_schema_rule(
      "IFCPROPERTYSET",
      5,
      Some(5),
      [1, 4],
      "property",
      "property set",
    ),
    make_schema_rule(
      "IFCPROPERTYSINGLEVALUE",
      4,
      Some(4),
      [],
      "property",
      "single property",
    ),
    make_schema_rule(
      "IFCRELCONTAINEDINSPATIALSTRUCTURE",
      6,
      Some(6),
      [1, 4, 5],
      "relationship",
      "spatial containment",
    ),
    make_schema_rule(
      "IFCRELAGGREGATES",
      6,
      Some(6),
      [1, 4, 5],
      "relationship",
      "spatial aggregation",
    ),
    make_schema_rule(
      "IFCRELDEFINESBYPROPERTIES",
      6,
      Some(6),
      [1, 4, 5],
      "relationship",
      "property association",
    ),
    make_schema_rule(
      "IFCRELASSOCIATESMATERIAL",
      6,
      Some(6),
      [1, 4, 5],
      "relationship",
      "material association",
    ),
    make_schema_rule(
      "IFCFACETEDBREP",
      1,
      Some(1),
      [0],
      "topology",
      "faceted boundary representation",
    ),
    make_schema_rule(
      "IFCCLOSEDSHELL",
      1,
      Some(1),
      [0],
      "topology",
      "closed shell",
    ),
    make_schema_rule("IFCFACE", 1, Some(1), [0], "topology", "face"),
    make_schema_rule(
      "IFCFACEOUTERBOUND",
      2,
      Some(2),
      [0, 1],
      "topology",
      "outer face boundary",
    ),
    make_schema_rule("IFCPOLYLOOP", 1, Some(1), [0], "topology", "poly loop"),
  ]
}

///|
fn rules_for_version(version : SchemaVersion) -> Array[SchemaRule] {
  let rules = common_schema_rules()
  match version {
    IFC2X3 => rules
    IFC4 => rules
    IFC4X3 => rules
    UnknownSchema(_) => rules
  }
}

///|
pub fn schema_profile(version : SchemaVersion) -> SchemaProfile {
  let name = match version {
    IFC2X3 => "IFC 2x3 coordination profile"
    IFC4 => "IFC 4 coordination profile"
    IFC4X3 => "IFC 4.3 coordination profile"
    UnknownSchema(value) => "Unknown schema profile: " + value
  }
  let rules = rules_for_version(version)
  { name, version, known_entity_count: rules.length(), rules }
}

///|
pub fn default_schema_profile() -> SchemaProfile {
  schema_profile(IFC4)
}

///|
fn schema_rule_for(
  profile : SchemaProfile,
  entity_name : String,
) -> SchemaRule? {
  let wanted = entity_name.to_upper()
  for rule in profile.rules {
    if rule.entity_name == wanted {
      return Some(rule)
    }
  }
  None
}

///|
pub fn SchemaProfile::rule(
  self : SchemaProfile,
  entity_name : String,
) -> SchemaRule? {
  schema_rule_for(self, entity_name)
}

///|
fn schema_issue(
  code : String,
  entity : Entity,
  message : String,
  severity : DiagnosticSeverity,
) -> SchemaIssue {
  { code, entity_id: entity.id, entity_name: entity.name, message, severity }
}

///|
fn argument_is_reference(entity : Entity, index : Int) -> Bool {
  match entity.arguments.get(index) {
    Some(Reference(_)) => true
    _ => false
  }
}

///|
fn check_schema_entity(
  profile : SchemaProfile,
  index : ModelIndex,
  entity : Entity,
  issues : Array[SchemaIssue],
) -> Unit {
  match schema_rule_for(profile, entity.name) {
    None =>
      issues.push(
        schema_issue(
          "SCHEMA001",
          entity,
          "entity is not covered by the selected semantic profile; generic parsing is retained",
          Note,
        ),
      )
    Some(rule) => {
      let actual = entity.arguments.length()
      if actual < rule.minimum_arguments {
        issues.push(
          schema_issue(
            "SCHEMA002",
            entity,
            "expected at least " +
            rule.minimum_arguments.to_string() +
            " argument(s), found " +
            actual.to_string(),
            Error,
          ),
        )
      }
      match rule.maximum_arguments {
        Some(maximum) if actual > maximum =>
          issues.push(
            schema_issue(
              "SCHEMA003",
              entity,
              "expected at most " +
              maximum.to_string() +
              " argument(s), found " +
              actual.to_string(),
              Error,
            ),
          )
        _ => ()
      }
      for required in rule.required_reference_arguments {
        match entity.arguments.get(required) {
          Some(value) => {
            let targets = references(value)
            if targets.length() == 0 {
              issues.push(
                schema_issue(
                  "SCHEMA004",
                  entity,
                  "required reference argument " +
                  required.to_string() +
                  " is unset",
                  Error,
                ),
              )
            }
            for target in targets {
              if !index.has(target) {
                issues.push(
                  schema_issue(
                    "SCHEMA005",
                    entity,
                    "reference argument " +
                    required.to_string() +
                    " points to missing entity #" +
                    target.to_string(),
                    Error,
                  ),
                )
              }
            }
          }
          None =>
            issues.push(
              schema_issue(
                "SCHEMA006",
                entity,
                "required reference argument " +
                required.to_string() +
                " is missing",
                Error,
              ),
            )
        }
      }
      if rule.family == "relationship" && !argument_is_reference(entity, 1) {
        issues.push(
          schema_issue(
            "SCHEMA007",
            entity,
            "OwnerHistory relationship slot is not a reference",
            Warning,
          ),
        )
      }
    }
  }
}

///|
fn count_schema_severity(issues : Array[SchemaIssue]) -> (Int, Int) {
  let mut errors = 0
  let mut warnings = 0
  for issue in issues {
    match issue.severity {
      Error => errors += 1
      Warning => warnings += 1
      Note => ()
    }
  }
  (errors, warnings)
}

///|
pub fn validate_with_schema(
  model : Model,
  profile : SchemaProfile,
) -> SchemaReport {
  let index = model.index()
  let issues : Array[SchemaIssue] = []
  for entity in model.entities {
    check_schema_entity(profile, index, entity, issues)
  }
  let (error_count, warning_count) = count_schema_severity(issues)
  {
    profile,
    checked_entities: model.entities.length(),
    issues,
    error_count,
    warning_count,
  }
}

///|
pub fn validate_schema(model : Model) -> SchemaReport {
  validate_with_schema(model, schema_profile(schema_version(model)))
}

///|
pub fn schema_issues_by_code(
  report : SchemaReport,
  code : String,
) -> Array[SchemaIssue] {
  report.issues.filter(issue => issue.code == code)
}

///|
pub fn SchemaReport::is_valid(self : SchemaReport) -> Bool {
  self.error_count == 0
}

///|
pub fn SchemaReport::unknown_entity_count(self : SchemaReport) -> Int {
  schema_issues_by_code(self, "SCHEMA001").length()
}

///|
fn schema_severity_text(severity : DiagnosticSeverity) -> String {
  match severity {
    Error => "error"
    Warning => "warning"
    Note => "note"
  }
}

///|
pub fn SchemaReport::format(self : SchemaReport) -> String {
  let header = self.profile.name +
    ": checked=" +
    self.checked_entities.to_string() +
    ", errors=" +
    self.error_count.to_string() +
    ", warnings=" +
    self.warning_count.to_string()
  if self.issues.length() == 0 {
    return header
  }
  header +
  "\n" +
  self.issues
  .map(issue => {
    schema_severity_text(issue.severity) +
    "[" +
    issue.code +
    "] #" +
    issue.entity_id.to_string() +
    " " +
    issue.entity_name +
    ": " +
    issue.message
  })
  .join("\n")
}

///|
pub fn SchemaReport::headline(self : SchemaReport) -> String {
  if self.is_valid() {
    self.profile.name +
    " valid with " +
    self.unknown_entity_count().to_string() +
    " extension note(s)"
  } else {
    self.profile.name +
    " invalid: " +
    self.error_count.to_string() +
    " error(s)"
  }
}

///|
pub fn schema_profile_summary(profile : SchemaProfile) -> String {
  profile.name +
  ": " +
  profile.known_entity_count.to_string() +
  " entity rules (" +
  profile.rules.map(rule => rule.entity_name).join(", ") +
  ")"
}

///|
pub fn schema_family_histogram(profile : SchemaProfile) -> Map[String, Int] {
  let result : Map[String, Int] = Map([])
  for rule in profile.rules {
    result.update_or_default(rule.family, 1, count => count + 1)
  }
  result
}

///|
pub fn schema_required_reference_count(profile : SchemaProfile) -> Int {
  profile.rules.fold(init=0, (count, rule) => {
    count + rule.required_reference_arguments.length()
  })
}

///|
pub fn schema_rule_names(profile : SchemaProfile) -> Array[String] {
  profile.rules.map(rule => rule.entity_name)
}

///|
pub fn schema_entity_is_known(
  profile : SchemaProfile,
  entity_name : String,
) -> Bool {
  schema_rule_for(profile, entity_name) is Some(_)
}

///|
pub fn schema_validate_entity(
  profile : SchemaProfile,
  model : Model,
  entity_id : Int,
) -> Array[SchemaIssue] {
  let index = model.index()
  match index.entity(entity_id) {
    Some(entity) => {
      let issues : Array[SchemaIssue] = []
      check_schema_entity(profile, index, entity, issues)
      issues
    }
    None => []
  }
}

///|
pub fn schema_compare_profiles(
  left : SchemaProfile,
  right : SchemaProfile,
) -> Array[String] {
  let result : Array[String] = []
  for rule in left.rules {
    if !schema_entity_is_known(right, rule.entity_name) {
      result.push("removed: " + rule.entity_name)
    }
  }
  for rule in right.rules {
    if !schema_entity_is_known(left, rule.entity_name) {
      result.push("added: " + rule.entity_name)
    }
  }
  result
}