///|
pub fn ModelRule::new(
  code~ : String,
  description~ : String,
  severity? : DiagnosticSeverity = Warning,
) -> ModelRule {
  {
    code,
    description,
    severity,
    entity_type: None,
    minimum_arguments: None,
    required_attributes: [],
    required_properties: [],
    minimum_value: None,
    maximum_value: None,
  }
}

///|
pub fn ModelRule::for_entity(
  self : ModelRule,
  entity_type : String,
) -> ModelRule {
  { ..self, entity_type: Some(entity_type) }
}

///|
pub fn ModelRule::with_minimum_arguments(
  self : ModelRule,
  count : Int,
) -> ModelRule {
  { ..self, minimum_arguments: Some(if count < 0 { 0 } else { count }) }
}

///|
pub fn ModelRule::requiring_attribute(
  self : ModelRule,
  index : Int,
) -> ModelRule {
  let attributes = self.required_attributes.copy()
  if index >= 0 && !attributes.contains(index) {
    attributes.push(index)
  }
  { ..self, required_attributes: attributes }
}

///|
pub fn ModelRule::requiring_property(
  self : ModelRule,
  name : String,
) -> ModelRule {
  let properties = self.required_properties.copy()
  if !properties.contains(name) {
    properties.push(name)
  }
  { ..self, required_properties: properties }
}

///|
pub fn ModelRule::with_value_range(
  self : ModelRule,
  minimum : Double?,
  maximum : Double?,
) -> ModelRule {
  { ..self, minimum_value: minimum, maximum_value: maximum }
}

///|
pub fn ModelRule::is_error(self : ModelRule) -> Bool {
  self.severity == Error
}

///|
pub fn ModelRule::headline(self : ModelRule) -> String {
  self.code + ": " + self.description
}

///|
fn rule_entity_matches(rule : ModelRule, entity : Entity) -> Bool {
  match rule.entity_type {
    Some(name) => entity.name.to_upper() == name.to_upper()
    None => true
  }
}

///|
fn rule_entity_product(products : Array[Product], id : Int) -> Product? {
  for product in products {
    if product.id == id {
      return Some(product)
    }
  }
  None
}

///|
fn rule_issue(
  rule : ModelRule,
  entity : Entity?,
  message : String,
  property_name : String?,
) -> RuleViolation {
  {
    rule_code: rule.code,
    severity: rule.severity,
    entity_id: entity.map(item => item.id),
    entity_type: entity.map(item => item.name),
    message,
    property_name,
  }
}

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

///|
fn rule_numeric_values(entity : Entity) -> Array[Double] {
  let values : Array[Double] = []
  fn visit(value : Value, result : Array[Double]) -> Unit {
    match value {
      Integer(number) => result.push(number.to_double())
      Real(number) => result.push(number)
      Typed(name~, value~) => {
        ignore(name)
        visit(value, result)
      }
      List(items) =>
        for item in items {
          visit(item, result)
        }
      _ => ()
    }
  }
  for value in entity.arguments {
    visit(value, values)
  }
  values
}

///|
fn rule_value_in_range(rule : ModelRule, entity : Entity) -> Bool {
  let numbers = rule_numeric_values(entity)
  for number in numbers {
    match (rule.minimum_value, rule.maximum_value) {
      (Some(minimum), Some(maximum)) if number < minimum || number > maximum =>
        return false
      (Some(minimum), None) if number < minimum => return false
      (None, Some(maximum)) if number > maximum => return false
      _ => ()
    }
  }
  true
}

///|
fn rule_entity_violations(
  index : ModelIndex,
  products : Array[Product],
  rule : ModelRule,
  entity : Entity,
) -> Array[RuleViolation] raise ParseError {
  if !rule_entity_matches(rule, entity) {
    return []
  }
  let result : Array[RuleViolation] = []
  match rule.minimum_arguments {
    Some(count) if entity.arguments.length() < count =>
      result.push(
        rule_issue(
          rule,
          Some(entity),
          "expected at least " + count.to_string() + " arguments",
          None,
        ),
      )
    _ => ()
  }
  for argument in rule.required_attributes {
    if !rule_arguments_have_value(entity, argument) {
      result.push(
        rule_issue(
          rule,
          Some(entity),
          "required argument " + argument.to_string() + " is missing",
          None,
        ),
      )
    }
  }
  if !rule_value_in_range(rule, entity) {
    result.push(
      rule_issue(
        rule,
        Some(entity),
        "numeric argument is outside the allowed range",
        None,
      ),
    )
  }
  if rule.required_properties.length() > 0 {
    match rule_entity_product(products, entity.id) {
      Some(product) => {
        let properties = property_sets_for_product(index, product)
        for name in rule.required_properties {
          if property_named(properties, name) is None {
            result.push(
              rule_issue(
                rule,
                Some(entity),
                "required property is missing",
                Some(name),
              ),
            )
          }
        }
      }
      None => ()
    }
  }
  result
}

///|
fn rule_duplicate_ids(model : Model, rule : ModelRule) -> Array[RuleViolation] {
  let seen : Map[Int, Bool] = Map([])
  let result : Array[RuleViolation] = []
  for entity in model.entities {
    if seen.contains(entity.id) {
      result.push(
        rule_issue(rule, Some(entity), "entity id occurs more than once", None),
      )
    }
    seen[entity.id] = true
  }
  result
}

///|
fn rule_orphan_references(
  model : Model,
  rule : ModelRule,
) -> Array[RuleViolation] {
  let index = model.index()
  let result : Array[RuleViolation] = []
  for entity in model.entities {
    for target in entity_references(entity) {
      if !index.has(target) {
        result.push(
          rule_issue(
            rule,
            Some(entity),
            "reference target #" + target.to_string() + " is absent",
            None,
          ),
        )
      }
    }
  }
  result
}

///|
fn rule_product_geometry(
  model : Model,
  rule : ModelRule,
) -> Array[RuleViolation] {
  let index = model.index()
  let result : Array[RuleViolation] = []
  for product in model.products() {
    if product.representation is Some(_) {
      let geometries = geometries_for_product(index, product)
      if geometries.length() == 0 {
        result.push(
          rule_issue(
            rule,
            index.entity(product.id),
            "representation has no decodable geometry",
            None,
          ),
        )
      }
    }
  }
  result
}

///|
fn rule_product_names(model : Model, rule : ModelRule) -> Array[RuleViolation] {
  let result : Array[RuleViolation] = []
  for product in model.products() {
    if product.name.unwrap_or("").trim().length() == 0 {
      result.push({
        rule_code: rule.code,
        severity: rule.severity,
        entity_id: Some(product.id),
        entity_type: Some(product.ifc_type),
        message: "product has no display name",
        property_name: None,
      })
    }
  }
  result
}

///|
fn rule_global_violations(
  model : Model,
  rule : ModelRule,
) -> Array[RuleViolation] {
  match rule.code {
    "MODEL_UNIQUE_ID" => rule_duplicate_ids(model, rule)
    "MODEL_REFERENCE_TARGET" => rule_orphan_references(model, rule)
    "MODEL_GEOMETRY_DECODE" => rule_product_geometry(model, rule)
    "PRODUCT_DISPLAY_NAME" => rule_product_names(model, rule)
    _ => []
  }
}

///|
fn rule_count_severity(violations : Array[RuleViolation]) -> (Int, Int) {
  violations.fold(init=(0, 0), (result, violation) => {
    let (errors, warnings) = result
    match violation.severity {
      Error => (errors + 1, warnings)
      Warning => (errors, warnings + 1)
      Note => (errors, warnings)
    }
  })
}

///|
/// Apply custom rules plus model-wide integrity rules without changing the model.
pub fn evaluate_rules(
  model : Model,
  rules : Array[ModelRule],
) -> RuleReport raise ParseError {
  let index = model.index()
  let products = model.products()
  let violations : Array[RuleViolation] = []
  for rule in rules {
    if rule.code == "MODEL_UNIQUE_ID" ||
      rule.code == "MODEL_REFERENCE_TARGET" ||
      rule.code == "MODEL_GEOMETRY_DECODE" ||
      rule.code == "PRODUCT_DISPLAY_NAME" {
      for violation in rule_global_violations(model, rule) {
        violations.push(violation)
      }
    } else {
      for entity in model.entities {
        for violation in rule_entity_violations(index, products, rule, entity) {
          violations.push(violation)
        }
      }
    }
  }
  let (error_count, warning_count) = rule_count_severity(violations)
  {
    rules,
    violations,
    error_count,
    warning_count,
    checked_entities: model.entities.length(),
    checked_products: products.length(),
  }
}

///|
pub fn standard_model_rules() -> Array[ModelRule] {
  [
    ModelRule::new(
      code="MODEL_UNIQUE_ID",
      description="entity ids are unique",
      severity=Error,
    ),
    ModelRule::new(
      code="MODEL_REFERENCE_TARGET",
      description="all references resolve to an entity",
      severity=Error,
    ),
    ModelRule::new(
      code="MODEL_GEOMETRY_DECODE",
      description="declared product representations have decodable geometry",
      severity=Warning,
    ),
  ]
}

///|
pub fn delivery_model_rules() -> Array[ModelRule] {
  standard_model_rules() +
  [
    ModelRule::new(
      code="PRODUCT_DISPLAY_NAME",
      description="products have a display name for coordination views",
      severity=Warning,
    ),
  ]
}

///|
pub fn rule_report(
  model : Model,
  rules : Array[ModelRule],
) -> RuleReport raise ParseError {
  evaluate_rules(model, rules)
}

///|
pub fn delivery_rule_report(model : Model) -> RuleReport raise ParseError {
  evaluate_rules(model, delivery_model_rules())
}

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

///|
pub fn RuleReport::violation_count(self : RuleReport) -> Int {
  self.violations.length()
}

///|
pub fn RuleReport::errors(self : RuleReport) -> Array[RuleViolation] {
  self.violations.filter(violation => violation.severity == Error)
}

///|
pub fn RuleReport::warnings(self : RuleReport) -> Array[RuleViolation] {
  self.violations.filter(violation => violation.severity == Warning)
}

///|
pub fn RuleReport::for_code(
  self : RuleReport,
  code : String,
) -> Array[RuleViolation] {
  self.violations.filter(violation => violation.rule_code == code)
}

///|
pub fn RuleReport::for_entity(
  self : RuleReport,
  id : Int,
) -> Array[RuleViolation] {
  self.violations.filter(violation => violation.entity_id == Some(id))
}

///|
pub fn RuleReport::codes(self : RuleReport) -> Array[String] {
  let result : Array[String] = []
  for violation in self.violations {
    if !result.contains(violation.rule_code) {
      result.push(violation.rule_code)
    }
  }
  result
}

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

///|
pub fn RuleViolation::headline(self : RuleViolation) -> String {
  let entity = self.entity_id.map(id => " #" + id.to_string()).unwrap_or("")
  self.rule_code +
  entity +
  " [" +
  rule_severity_text(self.severity) +
  "]: " +
  self.message
}

///|
pub fn RuleReport::headline(self : RuleReport) -> String {
  "valid=" +
  self.is_valid().to_string() +
  " errors=" +
  self.error_count.to_string() +
  " warnings=" +
  self.warning_count.to_string() +
  " checked_entities=" +
  self.checked_entities.to_string()
}

///|
pub fn RuleReport::format(self : RuleReport) -> String {
  let builder = StringBuilder()
  builder <+ "\{self.headline()}\n"
  for violation in self.violations {
    builder <+ "- \{violation.headline()}\n"
  }
  builder.to_string()
}

///|
pub fn RuleReport::to_json(self : RuleReport) -> String {
  let values = self.violations.map(violation => {
    "{\"code\":\"" +
    violation.rule_code +
    "\",\"severity\":\"" +
    rule_severity_text(violation.severity) +
    "\",\"entity_id\":" +
    violation.entity_id.map(id => id.to_string()).unwrap_or("null") +
    ",\"message\":\"" +
    violation.message +
    "\"}"
  })
  "{\"valid\":" +
  self.is_valid().to_string() +
  ",\"error_count\":" +
  self.error_count.to_string() +
  ",\"warning_count\":" +
  self.warning_count.to_string() +
  ",\"checked_entities\":" +
  self.checked_entities.to_string() +
  ",\"violations\":[" +
  values.join(",") +
  "]}"
}

///|
pub fn RuleReport::to_markdown(self : RuleReport) -> String {
  let builder = StringBuilder()
  builder <+ "# Model rule report\n\n"
  builder <+ "- Valid: \{self.is_valid()}\n"
  builder <+ "- Errors: \{self.error_count}\n"
  builder <+ "- Warnings: \{self.warning_count}\n"
  builder <+ "- Checked entities: \{self.checked_entities}\n\n"
  if self.violations.length() == 0 {
    builder <+ "No violations.\n"
  } else {
    builder <+ "| Rule | Entity | Severity | Message |\n"
    builder <+ "| --- | ---: | --- | --- |\n"
    for violation in self.violations {
      let entity_text = violation.entity_id
        .map(id => id.to_string())
        .unwrap_or("")
      builder <+
        "| \{violation.rule_code} | \{entity_text} | \{rule_severity_text(violation.severity)} | \{violation.message} |\n"
    }
  }
  builder.to_string()
}

///|
pub fn validate_rule(
  model : Model,
  rule : ModelRule,
) -> RuleReport raise ParseError {
  evaluate_rules(model, [rule])
}

///|
pub fn RuleReport::merge(self : RuleReport, other : RuleReport) -> RuleReport {
  let violations = self.violations + other.violations
  {
    rules: self.rules + other.rules,
    violations,
    error_count: self.error_count + other.error_count,
    warning_count: self.warning_count + other.warning_count,
    checked_entities: if self.checked_entities > other.checked_entities {
      self.checked_entities
    } else {
      other.checked_entities
    },
    checked_products: if self.checked_products > other.checked_products {
      self.checked_products
    } else {
      other.checked_products
    },
  }
}

///|
pub fn rule_violation_codes(report : RuleReport) -> Map[String, Int] {
  report.violations.fold(init=Map([]), (result, violation) => {
    result.update_or_default(violation.rule_code, 1, count => count + 1)
    result
  })
}

///|
pub fn rule_names(rules : Array[ModelRule]) -> Array[String] {
  rules.map(rule => rule.code)
}