///|
/// The wire comparison names are stable tokens, independent of display strings.
fn codec_comparison_to_json(comparison : Comparison) -> Json {
  Json::string(
    match comparison {
      Equal => "eq"
      NotEqual => "ne"
      Less => "lt"
      LessEqual => "le"
      Greater => "gt"
      GreaterEqual => "ge"
    },
  )
}

///|
fn codec_comparison_from_json(
  value : Json,
  path : String,
) -> Comparison raise CepError {
  match codec_string(value, path) {
    "eq" => Equal
    "ne" => NotEqual
    "lt" => Less
    "le" => LessEqual
    "gt" => Greater
    "ge" => GreaterEqual
    _ => raise Invalid(path~, reason="expected eq, ne, lt, le, gt or ge")
  }
}

///|
fn codec_text_property(
  object : Map[String, Json],
  key : String,
  path : String,
) -> String raise CepError {
  codec_string(codec_required(object, key, path), "\{path}.\{key}")
}

///|
fn codec_array(value : Json, path : String) -> Array[Json] raise CepError {
  match value {
    Array(items) => items
    _ => raise Invalid(path~, reason="expected array")
  }
}

///|
/// Small schema counters are JSON integers, never rounded floating-point values.
fn codec_int32(value : Json, path : String) -> Int32 raise CepError {
  match value {
    Number(number, ..) =>
      if finite(number) && number >= 0.0 && number <= 2147483647.0 {
        let integer = number.to_int()
        if integer.to_double() != number {
          raise Invalid(path~, reason="expected nonnegative Int32 integer")
        }
        integer
      } else {
        raise Invalid(path~, reason="expected nonnegative Int32 integer")
      }
    _ => raise Invalid(path~, reason="expected JSON integer")
  }
}

///|
fn codec_bounded_format(value : Json) -> String raise CepError {
  let text = value.stringify()
  if text.length() > 1048576 {
    raise Resource(resource="JSON UTF-16 code units", limit=1048576)
  }
  text
}

///|
/// This internal traversal is called only after structural validation.
fn codec_condition_to_json(condition : Condition) -> Json {
  match condition {
    Always => { "type": "always" }
    Kind(kind) => { "type": "kind", "value": Json::string(kind) }
    Has(field) => { "type": "has", "field": Json::string(field) }
    Compare(field, comparison, value) =>
      {
        "type": "compare",
        "field": Json::string(field),
        "op": codec_comparison_to_json(comparison),
        "value": codec_value_to_json(value),
      }
    FieldCompare(field, comparison, other_field) =>
      {
        "type": "field_compare",
        "field": Json::string(field),
        "op": codec_comparison_to_json(comparison),
        "other_field": Json::string(other_field),
      }
    CaptureCompare(field, comparison, capture, captured_field) =>
      {
        "type": "capture_compare",
        "field": Json::string(field),
        "op": codec_comparison_to_json(comparison),
        "capture": Json::string(capture),
        "captured_field": Json::string(captured_field),
      }
    CaptureCount(capture, comparison, count) =>
      {
        "type": "capture_count",
        "capture": Json::string(capture),
        "op": codec_comparison_to_json(comparison),
        "count": Json::number(count.to_double()),
      }
    TextPrefix(field, text) =>
      {
        "type": "text_prefix",
        "field": Json::string(field),
        "text": Json::string(text),
      }
    TextContains(field, text) =>
      {
        "type": "text_contains",
        "field": Json::string(field),
        "text": Json::string(text),
      }
    All(children) =>
      {
        "type": "all",
        "conditions": Json::array(children.map(codec_condition_to_json)),
      }
    Any(children) =>
      {
        "type": "any",
        "conditions": Json::array(children.map(codec_condition_to_json)),
      }
    Not(child) => { "type": "not", "condition": codec_condition_to_json(child) }
  }
}

///|
pub fn condition_to_json(condition : Condition) -> Json raise CepError {
  validate_condition_structure(condition)
  codec_condition_to_json(condition)
}

///|
priv struct CodecConditionBudget {
  mut nodes : Int32
}

///|
/// Depth and node budgets are checked while reading, before allocating an AST.
/// This also terminates when a caller passes a cyclic mutable Json value.
fn codec_read_condition(
  value : Json,
  path : String,
  depth : Int32,
  budget : CodecConditionBudget,
) -> Condition raise CepError {
  if depth > 32 {
    raise Resource(resource="condition depth", limit=32)
  }
  budget.nodes += 1
  if budget.nodes > 512 {
    raise Resource(resource="condition nodes", limit=512)
  }
  let object = codec_object(value, path)
  let tag = codec_text_property(object, "type", path)
  match tag {
    "always" => {
      codec_keys(object, ["type"], path)
      Always
    }
    "kind" => {
      codec_keys(object, ["type", "value"], path)
      let kind = codec_text_property(object, "value", path)
      require_label(kind, "\{path}.value")
      Kind(kind)
    }
    "has" => {
      codec_keys(object, ["type", "field"], path)
      let field = codec_text_property(object, "field", path)
      require_label(field, "\{path}.field")
      Has(field)
    }
    "compare" => {
      codec_keys(object, ["type", "field", "op", "value"], path)
      let field = codec_text_property(object, "field", path)
      require_label(field, "\{path}.field")
      let comparison = codec_comparison_from_json(
        codec_required(object, "op", path),
        "\{path}.op",
      )
      let scalar = codec_value_from_json(
        codec_required(object, "value", path),
        "\{path}.value",
      )
      Compare(field, comparison, scalar)
    }
    "field_compare" => {
      codec_keys(object, ["type", "field", "op", "other_field"], path)
      let field = codec_text_property(object, "field", path)
      let other_field = codec_text_property(object, "other_field", path)
      require_label(field, "\{path}.field")
      require_label(other_field, "\{path}.other_field")
      let comparison = codec_comparison_from_json(
        codec_required(object, "op", path),
        "\{path}.op",
      )
      FieldCompare(field, comparison, other_field)
    }
    "capture_compare" => {
      codec_keys(
        object,
        ["type", "field", "op", "capture", "captured_field"],
        path,
      )
      let field = codec_text_property(object, "field", path)
      let capture = codec_text_property(object, "capture", path)
      let captured_field = codec_text_property(object, "captured_field", path)
      require_label(field, "\{path}.field")
      require_label(capture, "\{path}.capture")
      require_label(captured_field, "\{path}.captured_field")
      let comparison = codec_comparison_from_json(
        codec_required(object, "op", path),
        "\{path}.op",
      )
      CaptureCompare(field, comparison, capture, captured_field)
    }
    "capture_count" => {
      codec_keys(object, ["type", "capture", "op", "count"], path)
      let capture = codec_text_property(object, "capture", path)
      require_label(capture, "\{path}.capture")
      let comparison = codec_comparison_from_json(
        codec_required(object, "op", path),
        "\{path}.op",
      )
      let count = codec_int32(
        codec_required(object, "count", path),
        "\{path}.count",
      )
      CaptureCount(capture, comparison, count)
    }
    "text_prefix" | "text_contains" => {
      codec_keys(object, ["type", "field", "text"], path)
      let field = codec_text_property(object, "field", path)
      let text = codec_text_property(object, "text", path)
      require_label(field, "\{path}.field")
      if text.length() > 4096 {
        raise Invalid(
          path="\{path}.text",
          reason="text exceeds 4096 UTF-16 code units",
        )
      }
      if tag == "text_prefix" {
        TextPrefix(field, text)
      } else {
        TextContains(field, text)
      }
    }
    "all" | "any" => {
      codec_keys(object, ["type", "conditions"], path)
      let items = codec_array(
        codec_required(object, "conditions", path),
        "\{path}.conditions",
      )
      if items.length() > 512 {
        raise Resource(resource="condition nodes", limit=512)
      }
      let children : Array[Condition] = []
      for index, child in items {
        children.push(
          codec_read_condition(
            child,
            "\{path}.conditions[\{index}]",
            depth + 1,
            budget,
          ),
        )
      }
      if tag == "all" {
        All(children)
      } else {
        Any(children)
      }
    }
    "not" => {
      codec_keys(object, ["type", "condition"], path)
      Not(
        codec_read_condition(
          codec_required(object, "condition", path),
          "\{path}.condition",
          depth + 1,
          budget,
        ),
      )
    }
    _ => raise Invalid(path="\{path}.type", reason="unknown condition type")
  }
}

///|
fn codec_condition_from_json(
  value : Json,
  path : String,
) -> Condition raise CepError {
  let condition = codec_read_condition(value, path, 1, { nodes: 0, })
  validate_condition_structure(condition)
  condition
}

///|
pub fn condition_from_json(value : Json) -> Condition raise CepError {
  codec_condition_from_json(value, "condition")
}

///|
/// Wire nesting allows object/array wrappers around the 32-level AST.
pub fn parse_condition(text : String) -> Condition raise CepError {
  condition_from_json(codec_parse(text, max_depth=128))
}

///|
pub fn format_condition(condition : Condition) -> String raise CepError {
  codec_bounded_format(condition_to_json(condition))
}