///|
pub(all) struct PathBudget {
  pattern : String
  max_files : Int64?
  max_bytes : Int64?
}

///|
/// Operational receiving policy, deliberately distinct from RFC validity.
pub(all) struct ReceivingPolicy {
  name : String
  payload_algorithms : Array[String]
  tag_algorithms : Array[String]
  forbid_legacy : Bool
  require_tag_protection : Bool
  require_portable : Bool
  allow_empty : Bool
  allow_fetch : Bool
  max_payload_files : Int64?
  max_payload_bytes : Int64?
  max_file_bytes : Int64?
  max_tag_bytes : Int64?
  max_path_depth : Int64?
  max_manifest_entries : Int64?
  path_budgets : Array[PathBudget]
}

///|
pub fn receiving_policy(name : String) -> ReceivingPolicy {
  {
    name,
    payload_algorithms: ["sha512"],
    tag_algorithms: [],
    forbid_legacy: true,
    require_tag_protection: true,
    require_portable: false,
    allow_empty: true,
    allow_fetch: true,
    max_payload_files: None,
    max_payload_bytes: None,
    max_file_bytes: None,
    max_tag_bytes: None,
    max_path_depth: None,
    max_manifest_entries: None,
    path_budgets: [],
  }
}

///|
fn policy_limit(
  fields : Map[String, Json],
  name : String,
) -> Int64? raise FormatError {
  match fields.get(name) {
    None | Some(Null) => None
    Some(value) => Some(document_count(value, name))
  }
}

///|
fn policy_switch(
  fields : Map[String, Json],
  name : String,
  fallback : Bool,
) -> Bool raise FormatError {
  match fields.get(name) {
    None => fallback
    Some(value) => document_bool(value, name)
  }
}

///|
fn check_receiving_policy(policy : ReceivingPolicy) -> Unit raise FormatError {
  guard policy.name.trim().to_owned() != "" else {
    raise Invalid("receiving policy name is empty")
  }
  for names in [policy.payload_algorithms, policy.tag_algorithms] {
    let seen : Map[String, Bool] = Map([])
    for name in names {
      let alg = algorithm(name)
      guard !seen.contains(name) else {
        raise Invalid("duplicate receiving algorithm: " + name)
      }
      guard !policy.forbid_legacy || (alg != Md5 && alg != Sha1) else {
        raise Invalid(
          "receiving policy both requires and forbids a legacy digest",
        )
      }
      seen[name] = true
    }
  }
  for
    value in [
      policy.max_payload_files,
      policy.max_payload_bytes,
      policy.max_file_bytes,
      policy.max_tag_bytes,
      policy.max_path_depth,
      policy.max_manifest_entries,
    ] {
    if value is Some(limit) && limit < 0L {
      raise Invalid("receiving limits must not be negative")
    }
  }
  let patterns : Map[String, Bool] = Map([])
  for budget in policy.path_budgets {
    guard budget.pattern != "" && !patterns.contains(budget.pattern) else {
      raise Invalid("empty or duplicate path budget pattern")
    }
    let sample = budget.pattern.replace_all(old="*", new="x")
    check_path(sample)
    guard budget.max_files is Some(_) || budget.max_bytes is Some(_) else {
      raise Invalid("path budget requires at least one limit")
    }
    for value in [budget.max_files, budget.max_bytes] {
      if value is Some(limit) && limit < 0L {
        raise Invalid("path budget limits must not be negative")
      }
    }
    patterns[budget.pattern] = true
  }
}

///|
pub fn parse_receiving_policy(
  document : Json,
) -> ReceivingPolicy raise FormatError {
  let keys = [
    "format", "name", "payload_algorithms", "tag_algorithms", "forbid_legacy", "require_tag_protection",
    "require_portable", "allow_empty", "allow_fetch", "max_payload_files", "max_payload_bytes",
    "max_file_bytes", "max_tag_bytes", "max_path_depth", "max_manifest_entries",
    "path_budgets",
  ]
  let fields = document_object(
    document,
    keys,
    ["format", "name"],
    "receiving policy",
  )
  guard document_text(fields["format"], "policy format") ==
    "moonbagit-receiving-policy/1" else {
    raise Invalid("unsupported receiving policy schema")
  }
  let name = document_text(fields["name"], "policy name")
  let defaults = receiving_policy(name)
  let payload_algorithms = match fields.get("payload_algorithms") {
    Some(value) => document_strings(value, "payload algorithms")
    None => defaults.payload_algorithms
  }
  let tag_algorithms = match fields.get("tag_algorithms") {
    Some(value) => document_strings(value, "tag algorithms")
    None => defaults.tag_algorithms
  }
  let path_budgets : Array[PathBudget] = []
  if fields.get("path_budgets") is Some(value) {
    for item in document_array(value, "path budgets") {
      let budget = document_object(
        item,
        ["pattern", "max_files", "max_bytes"],
        ["pattern"],
        "path budget",
      )
      path_budgets.push({
        pattern: document_text(budget["pattern"], "path pattern"),
        max_files: policy_limit(budget, "max_files"),
        max_bytes: policy_limit(budget, "max_bytes"),
      })
    }
  }
  let policy : ReceivingPolicy = {
    name,
    payload_algorithms,
    tag_algorithms,
    forbid_legacy: policy_switch(
      fields,
      "forbid_legacy",
      defaults.forbid_legacy,
    ),
    require_tag_protection: policy_switch(
      fields,
      "require_tag_protection",
      defaults.require_tag_protection,
    ),
    require_portable: policy_switch(
      fields,
      "require_portable",
      defaults.require_portable,
    ),
    allow_empty: policy_switch(fields, "allow_empty", defaults.allow_empty),
    allow_fetch: policy_switch(fields, "allow_fetch", defaults.allow_fetch),
    max_payload_files: policy_limit(fields, "max_payload_files"),
    max_payload_bytes: policy_limit(fields, "max_payload_bytes"),
    max_file_bytes: policy_limit(fields, "max_file_bytes"),
    max_tag_bytes: policy_limit(fields, "max_tag_bytes"),
    max_path_depth: policy_limit(fields, "max_path_depth"),
    max_manifest_entries: policy_limit(fields, "max_manifest_entries"),
    path_budgets,
  }
  check_receiving_policy(policy)
  policy
}

///|
fn limit_document(value : Int64?) -> Json {
  match value {
    Some(count) => Json(count.to_string())
    None => Json::null()
  }
}

///|
pub fn receiving_policy_document(
  policy : ReceivingPolicy,
) -> Json raise FormatError {
  check_receiving_policy(policy)
  let budgets = policy.path_budgets.map(b => {
    (
      {
        "pattern": b.pattern,
        "max_files": limit_document(b.max_files),
        "max_bytes": limit_document(b.max_bytes),
      } : Json)
  })
  {
    "format": "moonbagit-receiving-policy/1",
    "name": policy.name,
    "payload_algorithms": Json(policy.payload_algorithms),
    "tag_algorithms": Json(policy.tag_algorithms),
    "forbid_legacy": policy.forbid_legacy,
    "require_tag_protection": policy.require_tag_protection,
    "require_portable": policy.require_portable,
    "allow_empty": policy.allow_empty,
    "allow_fetch": policy.allow_fetch,
    "max_payload_files": limit_document(policy.max_payload_files),
    "max_payload_bytes": limit_document(policy.max_payload_bytes),
    "max_file_bytes": limit_document(policy.max_file_bytes),
    "max_tag_bytes": limit_document(policy.max_tag_bytes),
    "max_path_depth": limit_document(policy.max_path_depth),
    "max_manifest_entries": limit_document(policy.max_manifest_entries),
    "path_budgets": Json(budgets),
  }
}

///|
pub(all) struct AcceptanceReport {
  accepted : Bool
  policy : String
  bag : Report
  diagnostics : Array[Diagnostic]
} derive(ToJson)

///|
pub extend AcceptanceReport with ToJson::{to_json}

///|
fn enforce_limit(
  diagnostics : Array[Diagnostic],
  actual : Int64,
  maximum : Int64?,
  code : String,
  path : String,
) -> Unit {
  if maximum is Some(limit) && actual > limit {
    issue(
      diagnostics,
      code,
      path,
      "observed " +
      actual.to_string() +
      "; receiving limit " +
      limit.to_string(),
    )
  }
}

///|
/// Policy failure does not rewrite the RFC-validity result to 'invalid'.
pub fn evaluate_receiving_policy(
  view : BagView,
  policy : ReceivingPolicy,
) -> AcceptanceReport raise FormatError {
  check_receiving_policy(policy)
  let audit = audit_bag(view)
  let diagnostics : Array[Diagnostic] = []
  enforce_limit(
    diagnostics,
    audit.report.payload_files.to_int64(),
    policy.max_payload_files,
    "policy_payload_files",
    "data",
  )
  enforce_limit(
    diagnostics,
    audit.report.payload_bytes,
    policy.max_payload_bytes,
    "policy_payload_bytes",
    "data",
  )
  if !policy.allow_empty && audit.report.payload_files == 0 {
    issue(
      diagnostics, "policy_empty", "data", "receiving policy requires payload files",
    )
  }
  if !policy.allow_fetch && view.files.any(f => f.path == "fetch.txt") {
    issue(
      diagnostics, "policy_fetch", "fetch.txt", "receiving policy forbids fetch.txt",
    )
  }
  let mut tag_bytes = 0L
  for file in view.files {
    if file.path.has_prefix("data/") {
      enforce_limit(
        diagnostics,
        file.size,
        policy.max_file_bytes,
        "policy_file_bytes",
        file.path,
      )
    } else if file.size >= 0L {
      tag_bytes = checked_add(tag_bytes, file.size, "receiving tag bytes") catch {
        Invalid(message) => {
          issue(diagnostics, "policy_tag_bytes_overflow", file.path, message)
          tag_bytes
        }
      }
    }
    let depth = file.path.split("/").count().to_int64()
    enforce_limit(
      diagnostics,
      depth,
      policy.max_path_depth,
      "policy_path_depth",
      file.path,
    )
  }
  enforce_limit(
    diagnostics,
    tag_bytes,
    policy.max_tag_bytes,
    "policy_tag_bytes",
    "",
  )
  for file in audit.files {
    let required = if file.payload {
      policy.payload_algorithms
    } else {
      policy.tag_algorithms
    }
    if !file.payload && manifest_name(file.path, true) {
      continue
    }
    for name in required {
      if !file.checksums.any(c => c.algorithm == name && c.status == "verified") {
        issue(
          diagnostics,
          "policy_required_digest",
          file.path,
          "verified " + name + " manifest coverage is required",
        )
      }
    }
    if policy.require_tag_protection &&
      !file.payload &&
      !file.integrity_verified {
      issue(
        diagnostics,
        "policy_tag_protection",
        file.path,
        "receiving policy requires verified tag-manifest coverage",
      )
    }
  }
  for file in view.files {
    if manifest_name(file.path, false) || manifest_name(file.path, true) {
      if view.texts.get(file.path) is Some(text) {
        let parsed = parse_manifest(file.path, text) catch {
          Invalid(_) => continue
        }
        enforce_limit(
          diagnostics,
          parsed.entries.length().to_int64(),
          policy.max_manifest_entries,
          "policy_manifest_entries",
          file.path,
        )
        if policy.forbid_legacy &&
          (parsed.algorithm == Md5 || parsed.algorithm == Sha1) {
          issue(
            diagnostics,
            "policy_legacy",
            file.path,
            "legacy digest manifests are not accepted by this receiving policy",
          )
        }
      }
    }
  }
  for budget in policy.path_budgets {
    let mut files = 0L
    let mut bytes = 0L
    for file in view.files {
      if profile_path_matches(budget.pattern, file.path) {
        files = files + 1L
        if file.size >= 0L {
          bytes = checked_add(bytes, file.size, "path budget bytes") catch {
            Invalid(message) => {
              issue(
                diagnostics,
                "policy_path_bytes_overflow",
                budget.pattern,
                message,
              )
              bytes
            }
          }
        }
      }
    }
    enforce_limit(
      diagnostics,
      files,
      budget.max_files,
      "policy_path_files",
      budget.pattern,
    )
    enforce_limit(
      diagnostics,
      bytes,
      budget.max_bytes,
      "policy_path_bytes",
      budget.pattern,
    )
  }
  if policy.require_portable {
    for item in lint_portability(view) {
      issue(
        diagnostics,
        "policy_portability",
        item.path,
        item.code + ": " + item.message,
      )
    }
  }
  {
    accepted: audit.report.valid && !diagnostics.any(d => d.severity == Error),
    policy: policy.name,
    bag: audit.report,
    diagnostics,
  }
}