///|
/// Compatibility impact assigned to one upload contract change.
pub(all) enum ContractChangeImpact {
  CompatibleChange
  ReviewChange
  BreakingChange
} derive(Debug, Eq)

///|
/// One deterministic difference between two upload contracts.
pub(all) struct ContractChange {
  impact : ContractChangeImpact
  code : String
  path : String
  message : String
} derive(Debug, Eq)

///|
/// Ordered compatibility report for an old and a new upload contract.
pub(all) struct ContractDiff {
  changes : Array[ContractChange]
} derive(Debug, Eq)

///|
/// Compare two endpoint contracts and classify compatibility changes.
pub fn compare_upload_contracts(
  previous : UploadContract,
  current : UploadContract,
) -> ContractDiff {
  let changes = Array::new()
  compare_parse_options(previous.parse_options, current.parse_options, changes)
  compare_risk_ceiling(previous.max_risk, current.max_risk, changes)
  compare_schema_flags(previous.schema, current.schema, changes)
  compare_field_rules(previous.schema, current.schema, changes)
  compare_file_rules(previous.schema, current.schema, changes)
  { changes, }
}

///|
pub fn ContractChangeImpact::label(self : ContractChangeImpact) -> String {
  match self {
    CompatibleChange => "compatible"
    ReviewChange => "review"
    BreakingChange => "breaking"
  }
}

///|
pub fn ContractChange::to_line(self : ContractChange) -> String {
  self.impact.label() + "|" + self.code + "|" + self.path + "|" + self.message
}

///|
pub fn ContractDiff::is_unchanged(self : ContractDiff) -> Bool {
  self.changes.length() == 0
}

///|
pub fn ContractDiff::has_breaking_changes(self : ContractDiff) -> Bool {
  self.count_impact(BreakingChange) > 0
}

///|
pub fn ContractDiff::breaking_count(self : ContractDiff) -> Int {
  self.count_impact(BreakingChange)
}

///|
pub fn ContractDiff::review_count(self : ContractDiff) -> Int {
  self.count_impact(ReviewChange)
}

///|
pub fn ContractDiff::compatible_count(self : ContractDiff) -> Int {
  self.count_impact(CompatibleChange)
}

///|
pub fn ContractDiff::highest_impact(
  self : ContractDiff,
) -> ContractChangeImpact {
  if self.has_breaking_changes() {
    BreakingChange
  } else if self.review_count() > 0 {
    ReviewChange
  } else {
    CompatibleChange
  }
}

///|
pub fn ContractDiff::summary(self : ContractDiff) -> String {
  "contract-diff: breaking=" +
  self.breaking_count().to_string() +
  ", review=" +
  self.review_count().to_string() +
  ", compatible=" +
  self.compatible_count().to_string()
}

///|
pub fn ContractDiff::to_lines(self : ContractDiff) -> Array[String] {
  let lines = [self.summary()]
  let mut i = 0
  while i < self.changes.length() {
    lines.push(self.changes[i].to_line())
    i = i + 1
  }
  lines
}

///|
/// Render a review-friendly Markdown compatibility report.
pub fn ContractDiff::to_markdown(self : ContractDiff) -> String {
  let lines = [
    "# Upload contract compatibility report",
    "",
    self.summary(),
    "",
    "| Impact | Code | Path | Change |",
    "| --- | --- | --- | --- |",
  ]
  if self.changes.length() == 0 {
    lines.push("| compatible | unchanged | contract | No contract changes |")
  } else {
    let mut i = 0
    while i < self.changes.length() {
      let change = self.changes[i]
      lines.push(
        "| " +
        change.impact.label() +
        " | `" +
        change.code +
        "` | `" +
        change.path +
        "` | " +
        change.message +
        " |",
      )
      i = i + 1
    }
  }
  lines.join("\n")
}

///|
fn ContractDiff::count_impact(
  self : ContractDiff,
  impact : ContractChangeImpact,
) -> Int {
  let mut count = 0
  let mut i = 0
  while i < self.changes.length() {
    if self.changes[i].impact == impact {
      count = count + 1
    }
    i = i + 1
  }
  count
}

///|
fn contract_change(
  impact : ContractChangeImpact,
  code : String,
  path : String,
  message : String,
) -> ContractChange {
  { impact, code, path, message }
}

///|
fn compare_parse_options(
  previous : ParseOptions,
  current : ParseOptions,
  changes : Array[ContractChange],
) -> Unit {
  compare_integer_ceiling(
    previous.max_parts,
    current.max_parts,
    "parse.max_parts",
    "max_parts",
    changes,
  )
  compare_integer_ceiling(
    previous.max_headers_per_part,
    current.max_headers_per_part,
    "parse.max_headers_per_part",
    "max_headers_per_part",
    changes,
  )
  compare_integer_ceiling(
    previous.max_body_length,
    current.max_body_length,
    "parse.max_body_length",
    "max_body_length",
    changes,
  )
}

///|
fn compare_integer_ceiling(
  previous : Int,
  current : Int,
  path : String,
  label : String,
  changes : Array[ContractChange],
) -> Unit {
  if previous == current {
    return
  }
  if current < previous {
    changes.push(
      contract_change(
        BreakingChange,
        "limit_tightened",
        path,
        label +
        " decreased from " +
        previous.to_string() +
        " to " +
        current.to_string(),
      ),
    )
  } else {
    changes.push(
      contract_change(
        CompatibleChange,
        "limit_relaxed",
        path,
        label +
        " increased from " +
        previous.to_string() +
        " to " +
        current.to_string(),
      ),
    )
  }
}

///|
fn compare_optional_ceiling(
  previous : Int,
  current : Int,
  path : String,
  label : String,
  changes : Array[ContractChange],
) -> Unit {
  if previous == current {
    return
  }
  let tightened = if previous < 0 {
    current >= 0
  } else if current < 0 {
    false
  } else {
    current < previous
  }
  let impact = if tightened { BreakingChange } else { CompatibleChange }
  let code = if tightened {
    "constraint_tightened"
  } else {
    "constraint_relaxed"
  }
  changes.push(
    contract_change(
      impact,
      code,
      path,
      label +
      " changed from " +
      limit_label(previous) +
      " to " +
      limit_label(current),
    ),
  )
}

///|
fn limit_label(value : Int) -> String {
  if value < 0 {
    "unlimited"
  } else {
    value.to_string()
  }
}

///|
fn compare_risk_ceiling(
  previous : UploadRisk,
  current : UploadRisk,
  changes : Array[ContractChange],
) -> Unit {
  if previous == current {
    return
  }
  if upload_risk_at_most(current, previous) {
    changes.push(
      contract_change(
        BreakingChange,
        "risk_ceiling_tightened",
        "risk.max",
        "accepted risk changed from " +
        previous.label() +
        " to " +
        current.label(),
      ),
    )
  } else {
    changes.push(
      contract_change(
        CompatibleChange,
        "risk_ceiling_relaxed",
        "risk.max",
        "accepted risk changed from " +
        previous.label() +
        " to " +
        current.label(),
      ),
    )
  }
}

///|
fn compare_schema_flags(
  previous : FormSchema,
  current : FormSchema,
  changes : Array[ContractChange],
) -> Unit {
  compare_unknown_flag(
    previous.allow_unknown_fields,
    current.allow_unknown_fields,
    "schema.unknown_fields",
    "field",
    changes,
  )
  compare_unknown_flag(
    previous.allow_unknown_files,
    current.allow_unknown_files,
    "schema.unknown_files",
    "file",
    changes,
  )
}

///|
fn compare_unknown_flag(
  previous : Bool,
  current : Bool,
  path : String,
  kind : String,
  changes : Array[ContractChange],
) -> Unit {
  if previous == current {
    return
  }
  if previous && !current {
    changes.push(
      contract_change(
        BreakingChange,
        "unknown_entries_rejected",
        path,
        "unknown " + kind + " names are now rejected",
      ),
    )
  } else {
    changes.push(
      contract_change(
        CompatibleChange,
        "unknown_entries_allowed",
        path,
        "unknown " + kind + " names are now allowed",
      ),
    )
  }
}

///|
fn compare_field_rules(
  previous : FormSchema,
  current : FormSchema,
  changes : Array[ContractChange],
) -> Unit {
  let mut i = 0
  while i < previous.field_rules.length() {
    let old_rule = previous.field_rules[i]
    match evolution_find_field_rule(current.field_rules, old_rule.name) {
      Some(new_rule) => compare_field_rule(old_rule, new_rule, changes)
      None => {
        let impact = if current.allow_unknown_fields {
          CompatibleChange
        } else {
          BreakingChange
        }
        changes.push(
          contract_change(
            impact,
            if impact == BreakingChange {
              "field_removed"
            } else {
              "field_rule_removed"
            },
            "schema.field." + old_rule.name,
            if impact == BreakingChange {
              "field is no longer accepted by the closed schema"
            } else {
              "field-specific restrictions were removed"
            },
          ),
        )
      }
    }
    i = i + 1
  }
  let mut j = 0
  while j < current.field_rules.length() {
    let rule = current.field_rules[j]
    if evolution_find_field_rule(previous.field_rules, rule.name) is None {
      let restrictive = rule.required ||
        (
          previous.allow_unknown_fields &&
          (rule.max_values >= 0 || rule.max_length >= 0 || !rule.allow_empty)
        )
      changes.push(
        contract_change(
          if restrictive {
            BreakingChange
          } else {
            CompatibleChange
          },
          if restrictive {
            "field_rule_added_restrictive"
          } else {
            "field_rule_added"
          },
          "schema.field." + rule.name,
          if rule.required {
            "new required field was added"
          } else if restrictive {
            "new field rule restricts a previously unknown field"
          } else {
            "new optional field is accepted"
          },
        ),
      )
    }
    j = j + 1
  }
}

///|
fn compare_field_rule(
  previous : FieldRule,
  current : FieldRule,
  changes : Array[ContractChange],
) -> Unit {
  let base = "schema.field." + previous.name
  compare_required(
    previous.required,
    current.required,
    base + ".required",
    "field",
    changes,
  )
  compare_optional_ceiling(
    previous.max_values,
    current.max_values,
    base + ".max_values",
    "max_values",
    changes,
  )
  compare_optional_ceiling(
    previous.max_length,
    current.max_length,
    base + ".max_length",
    "max_length",
    changes,
  )
  compare_allow_empty(
    previous.allow_empty,
    current.allow_empty,
    base + ".allow_empty",
    "field",
    changes,
  )
}

///|
fn compare_file_rules(
  previous : FormSchema,
  current : FormSchema,
  changes : Array[ContractChange],
) -> Unit {
  let mut i = 0
  while i < previous.file_rules.length() {
    let old_rule = previous.file_rules[i]
    match evolution_find_file_rule(current.file_rules, old_rule.name) {
      Some(new_rule) => compare_file_rule(old_rule, new_rule, changes)
      None => {
        let impact = if current.allow_unknown_files {
          CompatibleChange
        } else {
          BreakingChange
        }
        changes.push(
          contract_change(
            impact,
            if impact == BreakingChange {
              "file_removed"
            } else {
              "file_rule_removed"
            },
            "schema.file." + old_rule.name,
            if impact == BreakingChange {
              "file field is no longer accepted by the closed schema"
            } else {
              "file-specific restrictions were removed"
            },
          ),
        )
      }
    }
    i = i + 1
  }
  let mut j = 0
  while j < current.file_rules.length() {
    let rule = current.file_rules[j]
    if evolution_find_file_rule(previous.file_rules, rule.name) is None {
      let restrictive = rule.required ||
        (previous.allow_unknown_files && file_rule_has_restrictions(rule))
      changes.push(
        contract_change(
          if restrictive {
            BreakingChange
          } else {
            CompatibleChange
          },
          if restrictive {
            "file_rule_added_restrictive"
          } else {
            "file_rule_added"
          },
          "schema.file." + rule.name,
          if rule.required {
            "new required file field was added"
          } else if restrictive {
            "new file rule restricts a previously unknown file field"
          } else {
            "new optional file field is accepted"
          },
        ),
      )
    }
    j = j + 1
  }
}

///|
fn file_rule_has_restrictions(rule : FileRule) -> Bool {
  rule.max_files >= 0 ||
  rule.max_length >= 0 ||
  !rule.allow_empty ||
  rule.allowed_content_types.length() > 0 ||
  rule.allowed_extensions.length() > 0
}

///|
fn compare_file_rule(
  previous : FileRule,
  current : FileRule,
  changes : Array[ContractChange],
) -> Unit {
  let base = "schema.file." + previous.name
  compare_required(
    previous.required,
    current.required,
    base + ".required",
    "file",
    changes,
  )
  compare_optional_ceiling(
    previous.max_files,
    current.max_files,
    base + ".max_files",
    "max_files",
    changes,
  )
  compare_optional_ceiling(
    previous.max_length,
    current.max_length,
    base + ".max_length",
    "max_length",
    changes,
  )
  compare_allow_empty(
    previous.allow_empty,
    current.allow_empty,
    base + ".allow_empty",
    "file",
    changes,
  )
  compare_allow_list(
    previous.allowed_content_types,
    current.allowed_content_types,
    base + ".content_types",
    "content_type",
    changes,
  )
  compare_allow_list(
    previous.allowed_extensions,
    current.allowed_extensions,
    base + ".extensions",
    "extension",
    changes,
  )
}

///|
fn compare_required(
  previous : Bool,
  current : Bool,
  path : String,
  kind : String,
  changes : Array[ContractChange],
) -> Unit {
  if previous == current {
    return
  }
  if !previous && current {
    changes.push(
      contract_change(
        BreakingChange,
        "required_added",
        path,
        kind + " is now required",
      ),
    )
  } else {
    changes.push(
      contract_change(
        CompatibleChange,
        "required_removed",
        path,
        kind + " is now optional",
      ),
    )
  }
}

///|
fn compare_allow_empty(
  previous : Bool,
  current : Bool,
  path : String,
  kind : String,
  changes : Array[ContractChange],
) -> Unit {
  if previous == current {
    return
  }
  if previous && !current {
    changes.push(
      contract_change(
        BreakingChange,
        "empty_rejected",
        path,
        "empty " + kind + " values are now rejected",
      ),
    )
  } else {
    changes.push(
      contract_change(
        CompatibleChange,
        "empty_allowed",
        path,
        "empty " + kind + " values are now allowed",
      ),
    )
  }
}

///|
fn compare_allow_list(
  previous : Array[String],
  current : Array[String],
  path : String,
  item_name : String,
  changes : Array[ContractChange],
) -> Unit {
  if previous.length() == 0 && current.length() == 0 {
    return
  }
  if previous.length() == 0 {
    changes.push(
      contract_change(
        BreakingChange,
        "allowlist_introduced",
        path,
        item_name + " allowlist now restricts previously accepted values",
      ),
    )
    return
  }
  if current.length() == 0 {
    changes.push(
      contract_change(
        CompatibleChange,
        "allowlist_removed",
        path,
        item_name + " allowlist was removed",
      ),
    )
    return
  }
  let mut i = 0
  while i < previous.length() {
    if !evolution_contains_ignore_case(current, previous[i]) {
      changes.push(
        contract_change(
          BreakingChange,
          "allowlist_value_removed",
          path,
          item_name + " is no longer accepted: " + previous[i],
        ),
      )
    }
    i = i + 1
  }
  let mut j = 0
  while j < current.length() {
    if !evolution_contains_ignore_case(previous, current[j]) {
      changes.push(
        contract_change(
          CompatibleChange,
          "allowlist_value_added",
          path,
          item_name + " is now accepted: " + current[j],
        ),
      )
    }
    j = j + 1
  }
}

///|
fn evolution_find_field_rule(
  rules : Array[FieldRule],
  name : String,
) -> FieldRule? {
  let mut i = 0
  while i < rules.length() {
    if rules[i].name == name {
      return Some(rules[i])
    }
    i = i + 1
  }
  None
}

///|
fn evolution_find_file_rule(
  rules : Array[FileRule],
  name : String,
) -> FileRule? {
  let mut i = 0
  while i < rules.length() {
    if rules[i].name == name {
      return Some(rules[i])
    }
    i = i + 1
  }
  None
}

///|
fn evolution_contains_ignore_case(
  values : Array[String],
  target : String,
) -> Bool {
  let mut i = 0
  while i < values.length() {
    if values[i].compare_ignore_ascii_case(target) == 0 {
      return true
    }
    i = i + 1
  }
  false
}