///|
fn operator_name(operator : Operator) -> String {
  match operator {
    And => "and"
    Or => "or"
    Not => "not"
    Equal => "=="
    NotEqual => "!="
    GreaterThan => ">"
    GreaterOrEqual => ">="
    LessThan => "<"
    LessOrEqual => "<="
    In => "in"
    Contains => "contains"
    StartsWith => "starts_with"
    EndsWith => "ends_with"
  }
}

///|
fn valid_arity(operator : Operator, count : Int) -> Bool {
  match operator {
    And | Or => count >= 1
    Not => count == 1
    Equal
    | NotEqual
    | GreaterThan
    | GreaterOrEqual
    | LessThan
    | LessOrEqual
    | In
    | Contains
    | StartsWith
    | EndsWith => count == 2
  }
}

///|
fn arity_message(operator : Operator, count : Int) -> (String, String) {
  let name = operator_name(operator)
  match operator {
    And | Or =>
      (
        "operator \{name} expects at least 1 argument, received \{count}",
        "provide at least 1 argument",
      )
    _ =>
      (
        "operator \{name} expects \{if operator == Not { 1 } else { 2 }} arguments, received \{count}",
        "provide exactly \{if operator == Not { 1 } else { 2 }} arguments",
      )
  }
}

///|
fn diagnostic(
  code : String,
  rule_path : String,
  message : String,
  suggestion : String,
) -> Diagnostic {
  { code, severity: Error, rule_path, message, suggestion }
}

///|
fn add_literal_type_diagnostic(
  out : Array[Diagnostic],
  path : RulePath,
  expected : String,
) -> Unit {
  out.push(
    diagnostic(
      "E_LITERAL_TYPE",
      path.to_string(),
      "literal operand must be \{expected}",
      "replace the literal or use a variable with a \{expected} value",
    ),
  )
}

///|
fn check_literal_operand_types(
  operator : Operator,
  arguments : Array[Expr],
  operation_path : RulePath,
  out : Array[Diagnostic],
) -> Unit {
  match operator {
    GreaterThan | GreaterOrEqual | LessThan | LessOrEqual =>
      for index, argument in arguments {
        if argument is Literal(value) && !(value is Number(_)) {
          add_literal_type_diagnostic(
            out,
            operation_path.index(index),
            "a number",
          )
        }
      }
    Contains | StartsWith | EndsWith =>
      for index, argument in arguments {
        if argument is Literal(value) && !(value is String(_)) {
          add_literal_type_diagnostic(
            out,
            operation_path.index(index),
            "a string",
          )
        }
      }
    In =>
      if arguments.length() >= 2 {
        let candidate = arguments[1]
        if candidate is Literal(value) && !(value is Array(_)) {
          add_literal_type_diagnostic(out, operation_path.index(1), "an array")
        }
      }
    And | Or | Not =>
      for index, argument in arguments {
        if argument is Literal(value) && !(value is True) && !(value is False) {
          add_literal_type_diagnostic(
            out,
            operation_path.index(index),
            "a boolean",
          )
        }
      }
    Equal | NotEqual => ()
  }
}

///|
pub fn validate_budget(budget : Budget) -> Array[Diagnostic] {
  let maximum = Budget::default()
  let diagnostics : Array[Diagnostic] = []
  fn check_limit(
    diagnostics : Array[Diagnostic],
    field : String,
    supplied : Int,
    allowed : Int,
  ) -> Unit {
    if supplied > allowed {
      diagnostics.push(
        diagnostic(
          "E_BUDGET_LIMIT",
          "budget.\{field}",
          "budget \{field} cannot exceed the library ceiling of \{allowed}; received \{supplied}",
          "lower \{field} to \{allowed} or less",
        ),
      )
    }
  }

  check_limit(diagnostics, "max_depth", budget.max_depth, maximum.max_depth)
  check_limit(
    diagnostics,
    "max_rule_nodes",
    budget.max_rule_nodes,
    maximum.max_rule_nodes,
  )
  check_limit(diagnostics, "max_steps", budget.max_steps, maximum.max_steps)
  check_limit(
    diagnostics,
    "max_trace_nodes",
    budget.max_trace_nodes,
    maximum.max_trace_nodes,
  )
  check_limit(
    diagnostics,
    "max_preview_chars",
    budget.max_preview_chars,
    maximum.max_preview_chars,
  )
  diagnostics
}

///|
pub fn check_expr(expr : Expr, budget : Budget) -> Array[Diagnostic] {
  let diagnostics = validate_budget(budget)
  let count : Ref[Int] = Ref(0)
  check_node(
    expr,
    RulePath::root().field("condition"),
    1,
    count,
    budget,
    diagnostics,
  )
  diagnostics
}

///|
fn check_node(
  expr : Expr,
  path : RulePath,
  depth : Int,
  count : Ref[Int],
  budget : Budget,
  out : Array[Diagnostic],
) -> Unit {
  count.val += 1
  if depth > budget.max_depth {
    out.push(
      diagnostic(
        "E_MAX_DEPTH",
        path.to_string(),
        "rule depth \{depth} exceeds the configured maximum of \{budget.max_depth}",
        "reduce rule nesting or raise the caller limit within the library ceiling",
      ),
    )
  }
  if count.val > budget.max_rule_nodes {
    out.push(
      diagnostic(
        "E_MAX_RULE_NODES",
        path.to_string(),
        "rule node count exceeds the configured maximum of \{budget.max_rule_nodes}",
        "remove rule nodes or raise the caller limit within the library ceiling",
      ),
    )
  }
  match expr {
    Literal(_) => ()
    Variable(data_path) =>
      if parse_data_path(data_path) is Err(_) {
        out.push(
          diagnostic(
            "E_DATA_PATH",
            path.field("var").to_string(),
            "V1 data paths only support dot-separated object fields",
            "replace \{data_path} with an object-field path",
          ),
        )
      }
    Operation(operator, arguments) => {
      let operation_path = path.field(operator_name(operator))
      if !valid_arity(operator, arguments.length()) {
        let (message, suggestion) = arity_message(operator, arguments.length())
        out.push(
          diagnostic("E_ARITY", operation_path.to_string(), message, suggestion),
        )
      }
      check_literal_operand_types(operator, arguments, operation_path, out)
      for index, argument in arguments {
        check_node(
          argument,
          operation_path.index(index),
          depth + 1,
          count,
          budget,
          out,
        )
      }
    }
  }
}