///|
pub suberror ProfileError {
  InvalidProfile(String)
}

///|
/// Validated, immutable rules. Construct only through parse_profile.
pub struct Profile {
  priv options : Options
  priv risk : RiskPolicy
  priv legacy : Bool
  priv ignored : Array[String]
  priv limits : Array[Int?]
}

///|
fn profile_keys(
  value : Json,
  allowed : Array[String],
  path : String,
) -> Unit raise ProfileDetailError {
  match value {
    Object(fields) =>
      for key, _ in fields {
        if !allowed.contains(key) {
          raise ProfileDetailError({
            code: "unknown-field",
            path: profile_pointer(path, key),
            message: "Unknown configuration field.",
          })
        }
      }
    _ =>
      raise ProfileDetailError({
        code: "object-type",
        path,
        message: "Expected a configuration object.",
      })
  }
}

///|
fn profile_section(value : Json, key : String) -> Json {
  if value is Object(fields) {
    fields.get(key).unwrap_or(Json::object(Map([])))
  } else {
    Json::null()
  }
}

///|
fn profile_bool(
  value : Json,
  key : String,
  path : String,
) -> Bool raise ProfileDetailError {
  if value is Object(fields) {
    match fields.get(key) {
      None => false
      Some(True) => true
      Some(False) => false
      _ =>
        raise ProfileDetailError({
          code: "boolean-type",
          path: profile_pointer(path, key),
          message: "Configuration flags must be booleans.",
        })
    }
  } else {
    raise ProfileDetailError({
      code: "object-type",
      path,
      message: "Expected a configuration object.",
    })
  }
}

///|
fn profile_limit(
  value : Json,
  key : String,
  path : String,
) -> Int? raise ProfileDetailError {
  if value is Object(fields) {
    match fields.get(key) {
      None => None
      Some(Number(n, repr~)) => {
        if repr is Some(raw) {
          if !raw.iter().all(c => c >= '0' && c <= '9') {
            raise ProfileDetailError({
              code: "number-notation",
              path: "",
              message: "Use unsigned decimal integer notation for limits and version.",
            })
          }
        }
        if n < 0 || n > 2147483647 || n != n.to_int().to_double() {
          raise ProfileDetailError({
            code: "limit-value",
            path: profile_pointer(path, key),
            message: "Limits must be integers from 0 to 2147483647.",
          })
        }
        Some(n.to_int())
      }
      _ =>
        raise ProfileDetailError({
          code: "limit-value",
          path: profile_pointer(path, key),
          message: "Limits must be integers from 0 to 2147483647.",
        })
    }
  } else {
    raise ProfileDetailError({
      code: "object-type",
      path,
      message: "Expected a limits object.",
    })
  }
}

///|
/// Run after the bounded standard parser. Decoding each key catches escaped duplicates.
fn profile_unique_keys(text : String) -> Unit raise ProfileDetailError {
  let objects : Array[Map[String, Bool]] = []
  let mut i = 0
  let mut depth = 0
  while i < text.length() {
    if text[i] == '{' || text[i] == '[' {
      depth += 1
      if depth > 16 {
        raise ProfileDetailError({
          code: "nesting",
          path: "",
          message: "Configuration nesting exceeds 16.",
        })
      }
      if text[i] == '{' {
        objects.push(Map([]))
      }
      i += 1
    } else if text[i] == '}' || text[i] == ']' {
      depth -= 1
      if text[i] == '}' {
        ignore(objects.pop())
      }
      i += 1
    } else if text[i] == '"' {
      let start = i
      i += 1
      while i < text.length() && text[i] != '"' {
        if text[i] == '\\' {
          i += 1
        }
        i += 1
      }
      if i >= text.length() {
        raise ProfileDetailError({
          code: "json",
          path: "",
          message: "Invalid configuration JSON.",
        })
      }
      i += 1
      let end = i
      while i < text.length() &&
            (
              text[i] == ' ' ||
              text[i] == '\t' ||
              text[i] == '\r' ||
              text[i] == '\n'
            ) {
        i += 1
      }
      if i < text.length() && text[i] == ':' && !objects.is_empty() {
        let key = @json.parse(text[start:end].to_owned()) catch {
          _ =>
            raise ProfileDetailError({
              code: "json",
              path: "",
              message: "Invalid configuration key.",
            })
        }
        if key is String(s) {
          let seen = objects[objects.length() - 1]
          if seen.contains(s) {
            raise ProfileDetailError({
              code: "duplicate-key",
              path: "",
              message: "Duplicate configuration key.",
            })
          }
          seen[s] = true
        }
      }
    } else if text[i] == '-' || (text[i] >= '0' && text[i] <= '9') {
      while i < text.length() &&
            text[i] != ',' &&
            text[i] != '}' &&
            text[i] != ']' &&
            text[i] != ' ' &&
            text[i] != '\t' &&
            text[i] != '\r' &&
            text[i] != '\n' {
        if text[i] < '0' || text[i] > '9' {
          raise ProfileDetailError({
            code: "number-notation",
            path: "",
            message: "Use unsigned decimal integer notation for limits and version.",
          })
        }
        i += 1
      }
    } else {
      i += 1
    }
  }
}

///|
fn parse_profile_detail(data : Bytes) -> Profile raise ProfileDetailError {
  if data.length() > 65536 {
    raise ProfileDetailError({
      code: "size",
      path: "",
      message: "Configuration exceeds 64 KiB.",
    })
  }
  let text = @utf8.decode(data) catch {
    _ =>
      raise ProfileDetailError({
        code: "encoding",
        path: "",
        message: "Configuration must be UTF-8.",
      })
  }
  profile_unique_keys(text)
  let raw = @json.parse(text) catch {
    _ =>
      raise ProfileDetailError({
        code: "json",
        path: "",
        message: "Invalid configuration JSON.",
      })
  }
  profile_keys(raw, ["profile_version", "common", "review", "compare"], "")
  if profile_limit(raw, "profile_version", "") != Some(1) {
    raise ProfileDetailError({
      code: "version",
      path: "/profile_version",
      message: "Expected profile_version 1.",
    })
  }
  let common = profile_section(raw, "common")
  let review = profile_section(raw, "review")
  let compare = profile_section(raw, "compare")
  let limits = profile_section(review, "limits")
  profile_keys(common, ["allow_missing_version", "legacy_dn_spaces"], "/common")
  profile_keys(
    review,
    ["deny_delete", "deny_clear", "deny_rename", "limits"],
    "/review",
  )
  profile_keys(compare, ["ignored_attributes"], "/compare")
  profile_keys(
    limits,
    ["max_change_records", "max_delete_records", "max_clear_operations"],
    "/review/limits",
  )
  let names : Map[String, Bool] = Map([])
  if compare is Object(fields) {
    match fields.get("ignored_attributes") {
      None => ()
      Some(Array(attrs)) => {
        if attrs.length() > 64 {
          raise ProfileDetailError({
            code: "ignored-count",
            path: "/compare/ignored_attributes",
            message: "At most 64 ignored attributes.",
          })
        }
        for index, attr in attrs {
          if attr is String(s) {
            if s.length() > 256 || !valid_attribute(s) || ascii_lower(s) == "dn" {
              raise ProfileDetailError({
                code: "ignored-attribute",
                path: "/compare/ignored_attributes/" + index.to_string(),
                message: "Invalid ignored attribute description; DN cannot be excluded.",
              })
            }
            names[snapshot_attribute_key(s)] = true
          } else {
            raise ProfileDetailError({
              code: "ignored-type",
              path: "/compare/ignored_attributes/" + index.to_string(),
              message: "Ignored attributes must be strings.",
            })
          }
        }
      }
      _ =>
        raise ProfileDetailError({
          code: "ignored-array",
          path: "/compare/ignored_attributes",
          message: "Ignored attributes must be an array.",
        })
    }
  }
  let ignored = names.keys().to_array()
  ignored.sort()
  {
    options: {
      allow_missing_version: profile_bool(
        common, "allow_missing_version", "/common",
      ),
      deny_delete: profile_bool(review, "deny_delete", "/review"),
    },
    risk: {
      deny_clear: profile_bool(review, "deny_clear", "/review"),
      deny_rename: profile_bool(review, "deny_rename", "/review"),
    },
    legacy: profile_bool(common, "legacy_dn_spaces", "/common"),
    ignored,
    limits: [
      profile_limit(limits, "max_change_records", "/review/limits"),
      profile_limit(limits, "max_delete_records", "/review/limits"),
      profile_limit(limits, "max_clear_operations", "/review/limits"),
    ],
  }
}

///|
pub(all) struct ProfileIssue {
  code : String
  path : String
  message : String
} derive(Eq, Debug, ToJson)

///|
pub(all) enum ProfileValidation {
  Valid(Profile)
  Invalid(ProfileIssue)
}

///|
priv suberror ProfileDetailError {
  ProfileDetailError(ProfileIssue)
}

///|
fn profile_pointer(parent : String, key : String) -> String {
  parent + "/" + key.replace(old="~", new="~0").replace(old="/", new="~1")
}

///|
pub fn validate_profile(data : Bytes) -> ProfileValidation {
  Valid(parse_profile_detail(data)) catch {
    ProfileDetailError(issue) => Invalid(issue)
  }
}

///|
pub fn parse_profile(data : Bytes) -> Profile raise ProfileError {
  match validate_profile(data) {
    Valid(profile) => profile
    Invalid(issue) => raise InvalidProfile(issue.message)
  }
}

///|
pub fn Profile::to_json(self : Profile) -> Json {
  let limits : Map[String, Json] = Map([])
  for
    i, key in [
      "max_change_records", "max_delete_records", "max_clear_operations",
    ] {
    if self.limits[i] is Some(n) {
      limits[key] = n.to_json()
    }
  }
  {
    "profile_version": 1,
    "common": {
      "allow_missing_version": self.options.allow_missing_version.to_json(),
      "legacy_dn_spaces": self.legacy.to_json(),
    },
    "review": {
      "deny_delete": self.options.deny_delete.to_json(),
      "deny_clear": self.risk.deny_clear.to_json(),
      "deny_rename": self.risk.deny_rename.to_json(),
      "limits": Json::object(limits),
    },
    "compare": { "ignored_attributes": self.ignored.to_json() },
  }
}

///|
/// Canonical UTF-8 input for the platform's effective-configuration SHA-256.
pub fn Profile::canonical(self : Profile) -> String {
  self.to_json().stringify()
}

///|
/// Only rules applied by this mode participate in its effective fingerprint.
pub fn Profile::effective_json(
  self : Profile,
  section : String,
) -> Json raise ProfileError {
  if section != "review" && section != "compare" {
    raise InvalidProfile("Expected review or compare configuration section.")
  }
  let config = self.to_json()
  let fields : Map[String, Json] = Map([])
  fields["profile_version"] = (1).to_json()
  fields["common"] = snapshot_field(config, "common")
  fields[section] = snapshot_field(config, section)
  Json::object(fields)
}

///|
fn profile_evaluate(report : Report, profile : Profile) -> Json {
  let counts = [0, 0, 0]
  let first : Array[Span?] = [None, None, None]
  fn count(i : Int, span : Span) -> Unit {
    counts[i] += 1
    if profile.limits[i] is Some(limit) {
      if counts[i] > limit && first[i] == None {
        first[i] = Some(span)
      }
    }
  }
  for r in report.document.records {
    match r.body {
      Entry(_) => ()
      _ => count(0, r.span)
    }
    match r.body {
      Delete => count(1, r.span)
      Modify(mods) =>
        for m in mods {
          if (m.operation == "delete" || m.operation == "replace") &&
            m.values.is_empty() {
            count(2, m.span)
          }
        }
      _ => ()
    }
  }
  let complete = report.exit_code() != 2
  let applicable = report.document.mode != "content"
  let metrics : Array[Json] = []
  for
    i, key in [
      "max_change_records", "max_delete_records", "max_clear_operations",
    ] {
    let code = [
        "change-record-limit", "delete-record-limit", "clear-operation-limit",
      ][i]
    metrics.push({
      "rule": key.to_json(),
      "code": code.to_json(),
      "actual": counts[i].to_json(),
      "limit": profile.limits[i].to_json(),
      "first_exceeded_span": first[i].to_json(),
    })
    if first[i] is Some(span) {
      diagnose(
        report.diagnostics,
        code,
        "policy",
        span,
        "Observed " +
        counts[i].to_string() +
        " operations; configured maximum " +
        profile.limits[i].unwrap().to_string() +
        ". Counts cover all parsed records per file.",
      )
    }
  }
  if !applicable && profile.limits.any(n => n != None) {
    diagnose(
      report.diagnostics,
      "profile-limits-not-applicable",
      "error",
      single_line(1),
      "Change limits require a change plan, not a content export.",
    )
  }
  {
    "scope": "per_file",
    "applicable": applicable.to_json(),
    "counts_complete": (complete && applicable).to_json(),
    "counts_are_lower_bounds": (!complete).to_json(),
    "metrics": metrics.to_json(),
  }
}

///|
pub fn check_with_profile(data : Bytes, profile : Profile) -> Report {
  let r = check(
    data,
    options=profile.options,
    risk_policy=profile.risk,
    legacy_dn_spaces=profile.legacy,
  )
  ignore(profile_evaluate(r, profile))
  r
}

///|
fn profile_metadata(
  profile : Profile,
  section : String,
  source_sha : String,
  effective_sha : String,
) -> Json raise QueryError {
  for hash in [source_sha, effective_sha] {
    if hash != "" &&
      (
        hash.length() != 64 ||
        !hash.iter().all(c => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))
      ) {
      raise InvalidQuery("Invalid configuration SHA-256.")
    }
  }
  let config = profile.effective_json(section) catch {
    _ => raise InvalidQuery("Invalid configuration section.")
  }
  {
    "applied_section": section.to_json(),
    "source_sha256": source_sha.to_json(),
    "effective_sha256": effective_sha.to_json(),
    "effective": config,
  }
}