///|
struct Rule {
  id : String
  priority : Double
  condition : Expression
  output : Value
  computed : Expression?
}

///|
fn load_rules(source : Value) -> Array[Rule] raise {
  let ids : Map[String, Bool] = {}
  let rules : Array[Rule] = []
  for item in arr(source) {
    let id = str(get(item, "id"))
    if ids.contains(id) {
      raise InputError("duplicate rule ID: " + id)
    }
    ids[id] = true
    rules.push({
      id,
      priority: optional_num(get(item, "priority"), 0.0),
      condition: compile(str(get(item, "when"))),
      output: get(item, "output"),
      computed: if get(item, "compute") == Null {
        None
      } else {
        Some(compile(str(get(item, "compute"))))
      },
    })
  }
  rules.sort_by(fn(a, b) {
    let cmp = b.priority.compare(a.priority)
    if cmp == 0 {
      a.id.compare(b.id)
    } else {
      cmp
    }
  })
  rules
}

///|
fn decide(
  rules : Array[Rule],
  facts : Value,
  mode : String,
  fallback : Value,
  budget : Int,
) -> Value raise {
  ignore(obj(facts))
  let matched : Array[Value] = []
  let evaluated : Array[Value] = []
  let state : Evaluation = { fuel: budget, trace: [], }
  for rule in rules {
    let start = state.trace.length()
    let hits = boolean(evaluate(rule.condition, facts, state))
    let trace : Array[Value] = []
    for i = start; i < state.trace.length(); i = i + 1 {
      trace.push(state.trace[i])
    }
    evaluated.push(
      record([
        ("id", String(rule.id)),
        ("matched", Bool(hits)),
        ("trace", Array(trace)),
      ]),
    )
    if hits {
      let output = match rule.computed {
        Some(expr) => evaluate(expr, facts, state)
        None => rule.output
      }
      matched.push(
        record([
          ("id", String(rule.id)),
          ("priority", Number(rule.priority)),
          ("value", output),
        ]),
      )
    }
  }
  let conflict = mode == "unique" && matched.length() > 1
  let decision = if matched.is_empty() {
    fallback
  } else if conflict {
    Null
  } else if mode == "all" {
    Array(matched.map(fn(item) { get(item, "value") }))
  } else {
    get(matched[0], "value")
  }
  record([
    ("decision", decision),
    ("conflict", Bool(conflict)),
    ("matched", Array(matched)),
    ("evaluated", Array(evaluated)),
    ("steps", Number((budget - state.fuel).to_double())),
  ])
}

///|
pub fn run(request : Value) -> Value raise {
  let operation = optional_str(get(request, "operation"), "decide")
  if operation == "evaluate" {
    return record([
      (
        "value",
        evaluate_expression(
          str(get(request, "expression")),
          get(request, "facts"),
        ),
      ),
    ])
  }
  if operation == "diff" {
    let old_rules : Map[String, Value] = {}
    let new_rules : Map[String, Value] = {}
    for r in arr(get(request, "old")) {
      old_rules[str(get(r, "id"))] = r
    }
    for r in arr(get(request, "new")) {
      new_rules[str(get(r, "id"))] = r
    }
    let added : Array[String] = []
    let removed : Array[String] = []
    let changed : Array[String] = []
    for id, r in new_rules {
      match old_rules.get(id) {
        None => added.push(id)
        Some(old) => if old != r { changed.push(id) }
      }
    }
    for id, _ in old_rules {
      if !new_rules.contains(id) {
        removed.push(id)
      }
    }
    added.sort()
    removed.sort()
    changed.sort()
    return record([
      ("added", strings(added)),
      ("removed", strings(removed)),
      ("changed", strings(changed)),
    ])
  }
  if operation != "decide" {
    raise InputError("unknown operation")
  }
  let rules = load_rules(get(request, "rules"))
  let mode = optional_str(get(request, "mode"), "first")
  if !["first", "all", "unique"].contains(mode) {
    raise InputError("unknown decision mode")
  }
  let budget = optional_num(get(request, "budget"), 10000.0).to_int()
  if budget <= 0 || budget > 1000000 {
    raise InputError("budget out of range")
  }
  let results : Array[Value] = []
  let facts = match get(request, "facts") {
    Array(items) => items
    other => [other]
  }
  for i = 0; i < facts.length(); i = i + 1 {
    results.push(
      record([
        ("index", Number(i.to_double())),
        (
          "result",
          decide(rules, facts[i], mode, get(request, "default"), budget),
        ),
      ]),
    ) catch {
      error =>
        results.push(
          record([
            ("index", Number(i.to_double())),
            ("error", String(error.to_string())),
          ]),
        )
    }
  }
  record([
    ("results", Array(results)),
    ("rule_count", Number(rules.length().to_double())),
  ])
}