///|
/// A narrowly scoped, auditable suppression rule.
pub struct PolicySuppression {
  advisory_id : String
  package_name : String
  version : String
  reason : String
  expires_on : String
} derive(Debug, Eq, ToJson)

///|
/// CI policy for severity/scope gating and exact-version suppressions.
pub struct ScanPolicy {
  schema_version : String
  fail_on : String
  fail_on_scopes : Array[String]
  suppressions : Array[PolicySuppression]
} derive(Debug, Eq, ToJson)

///|
/// Policy outcome attached to a scan report.
pub struct PolicySummary {
  fail_on : String
  fail_on_scopes : Array[String]
  blocking_findings : Int32
  scope_excluded_findings : Int32
  suppressed_findings : Int32
  unmatched_suppressions : Array[PolicySuppression]
} derive(Debug, Eq, ToJson)

///|
pub struct PolicyEvaluation {
  report : ScanReport
  should_fail : Bool
} derive(Debug, Eq)

///|
pub(all) suberror PolicyValidationError {
  InvalidPolicyField(field~ : String, reason~ : String)
  ExpiredSuppression(
    index~ : Int32,
    advisory_id~ : String,
    expires_on~ : String
  )
} derive(Debug, Eq)

///|
/// Parse and validate a policy using a UTC date supplied by the caller.
/// Suppression expiries are inclusive: a rule remains active through expires_on.
pub fn parse_policy(text : String, today~ : String) -> ScanPolicy raise {
  let root = match @json.parse(text) {
    Object(fields) => fields
    _ =>
      raise PolicyValidationError::InvalidPolicyField(
        field="$",
        reason="must be a JSON object",
      )
  }
  let schema_version = policy_string_field(root, "schema_version")
  let fail_on = policy_string_field(root, "fail_on")
  let fail_on_scopes = match schema_version {
    "1" =>
      match root.get("fail_on_scopes") {
        Some(_) =>
          raise PolicyValidationError::InvalidPolicyField(
            field="fail_on_scopes",
            reason="is not supported by schema version 1",
          )
        None => all_policy_scopes()
      }
    "2" => parse_policy_scopes_field(root)
    _ =>
      raise PolicyValidationError::InvalidPolicyField(
        field="schema_version",
        reason="must be \"1\" or \"2\"",
      )
  }
  let suppressions : Array[PolicySuppression] = match root.get("suppressions") {
    None => []
    Some(Array(items)) => {
      let parsed : Array[PolicySuppression] = []
      for index, item in items {
        match item {
          Object(fields) =>
            parsed.push({
              advisory_id: policy_string_field(
                fields,
                "suppressions[\{index}].advisory_id",
                key="advisory_id",
              ),
              package_name: policy_string_field(
                fields,
                "suppressions[\{index}].package_name",
                key="package_name",
              ),
              version: policy_string_field(
                fields,
                "suppressions[\{index}].version",
                key="version",
              ),
              reason: policy_string_field(
                fields,
                "suppressions[\{index}].reason",
                key="reason",
              ),
              expires_on: policy_string_field(
                fields,
                "suppressions[\{index}].expires_on",
                key="expires_on",
              ),
            })
          _ =>
            raise PolicyValidationError::InvalidPolicyField(
              field="suppressions[\{index}]",
              reason="must be an object",
            )
        }
      }
      parsed
    }
    _ =>
      raise PolicyValidationError::InvalidPolicyField(
        field="suppressions",
        reason="must be an array",
      )
  }
  let policy = { schema_version, fail_on, fail_on_scopes, suppressions, }
  validate_policy(policy, today~)
  policy
}

///|
/// Validate a policy, including ISO date validity and expiration.
pub fn validate_policy(policy : ScanPolicy, today~ : String) -> Unit raise {
  if policy.schema_version != "1" && policy.schema_version != "2" {
    raise PolicyValidationError::InvalidPolicyField(
      field="schema_version",
      reason="must be \"1\" or \"2\"",
    )
  }
  if !is_policy_severity(policy.fail_on) {
    raise PolicyValidationError::InvalidPolicyField(
      field="fail_on",
      reason="must be one of critical, high, medium, low, info",
    )
  }
  let normalized_scopes = normalize_policy_scopes(policy.fail_on_scopes)
  if !string_arrays_equal(normalized_scopes, policy.fail_on_scopes) {
    raise PolicyValidationError::InvalidPolicyField(
      field="fail_on_scopes",
      reason="must be unique and use canonical scope order",
    )
  }
  if policy.schema_version == "1" &&
    !string_arrays_equal(policy.fail_on_scopes, all_policy_scopes()) {
    raise PolicyValidationError::InvalidPolicyField(
      field="fail_on_scopes",
      reason="schema version 1 always gates all scopes",
    )
  }
  if !is_iso_date(today) {
    raise PolicyValidationError::InvalidPolicyField(
      field="today",
      reason="must be a valid ISO date in YYYY-MM-DD form",
    )
  }
  for index, suppression in policy.suppressions {
    let prefix = "suppressions[\{index}]"
    if suppression.advisory_id.trim() == "" {
      raise PolicyValidationError::InvalidPolicyField(
        field="\{prefix}.advisory_id",
        reason="must not be empty",
      )
    }
    if suppression.package_name.trim() == "" {
      raise PolicyValidationError::InvalidPolicyField(
        field="\{prefix}.package_name",
        reason="must not be empty",
      )
    }
    if !is_stable_version(suppression.version) {
      raise PolicyValidationError::InvalidPolicyField(
        field="\{prefix}.version",
        reason="must be a stable three-part version",
      )
    }
    if suppression.reason.trim() == "" {
      raise PolicyValidationError::InvalidPolicyField(
        field="\{prefix}.reason",
        reason="must not be empty",
      )
    }
    if !is_iso_date(suppression.expires_on) {
      raise PolicyValidationError::InvalidPolicyField(
        field="\{prefix}.expires_on",
        reason="must be a valid ISO date in YYYY-MM-DD form",
      )
    }
    if lexicographic_compare(suppression.expires_on, today) < 0 {
      raise PolicyValidationError::ExpiredSuppression(
        index~,
        advisory_id=suppression.advisory_id,
        expires_on=suppression.expires_on,
      )
    }
    for previous_index in 0.. ScanPolicy {
  {
    schema_version: "2",
    fail_on,
    fail_on_scopes: all_policy_scopes(),
    suppressions: [],
  }
}

///|
fn parse_policy_scopes_field(fields : Map[String, Json]) -> Array[String] raise {
  match fields.get("fail_on_scopes") {
    Some(Array(items)) => {
      let scopes : Array[String] = []
      for index, item in items {
        match item {
          String(scope) => scopes.push(scope)
          _ =>
            raise PolicyValidationError::InvalidPolicyField(
              field="fail_on_scopes[\{index}]",
              reason="must be a string",
            )
        }
      }
      normalize_policy_scopes(scopes)
    }
    None =>
      raise PolicyValidationError::InvalidPolicyField(
        field="fail_on_scopes",
        reason="is required by schema version 2",
      )
    _ =>
      raise PolicyValidationError::InvalidPolicyField(
        field="fail_on_scopes",
        reason="must be an array of scope names",
      )
  }
}

///|
fn normalize_policy_scopes(scopes : Array[String]) -> Array[String] raise {
  let trimmed = scopes.map(fn(scope) { scope.trim().to_owned() })
  if trimmed.length() == 1 && trimmed[0] == "all" {
    return all_policy_scopes()
  }
  if trimmed.length() == 0 {
    raise PolicyValidationError::InvalidPolicyField(
      field="fail_on_scopes",
      reason="must contain at least one scope",
    )
  }
  let seen : Map[String, Bool] = Map([])
  for index, normalized in trimmed {
    if normalized == "" {
      raise PolicyValidationError::InvalidPolicyField(
        field="fail_on_scopes[\{index}]",
        reason="must not be empty",
      )
    }
    if normalized == "all" {
      raise PolicyValidationError::InvalidPolicyField(
        field="fail_on_scopes[\{index}]",
        reason="all must be the only value",
      )
    }
    if !is_policy_scope(normalized) {
      raise PolicyValidationError::InvalidPolicyField(
        field="fail_on_scopes[\{index}]",
        reason="must be runtime, test, wbtest, other, unclassified, or all",
      )
    }
    if seen.contains(normalized) {
      raise PolicyValidationError::InvalidPolicyField(
        field="fail_on_scopes[\{index}]",
        reason="duplicates an earlier scope",
      )
    }
    seen[normalized] = true
  }
  all_policy_scopes().filter(fn(scope) { seen.contains(scope) })
}

///|
fn all_policy_scopes() -> Array[String] {
  ["runtime", "test", "wbtest", "other", "unclassified"]
}

///|
fn is_policy_scope(scope : String) -> Bool {
  scope == "runtime" ||
  scope == "test" ||
  scope == "wbtest" ||
  scope == "other" ||
  scope == "unclassified"
}

///|
fn string_arrays_equal(left : Array[String], right : Array[String]) -> Bool {
  if left.length() != right.length() {
    return false
  }
  for index in 0.. PolicyEvaluation raise {
  evaluate_policy_with_scopes(report, policy, fail_on_override, None, today~)
}

///|
/// Apply severity, scope, baseline, and exact-match suppression rules while
/// retaining all findings in the report. A scope override replaces policy
/// scopes for this evaluation only.
pub fn evaluate_policy_with_scopes(
  report : ScanReport,
  policy : ScanPolicy,
  fail_on_override : String?,
  fail_on_scopes_override : Array[String]?,
  today~ : String,
) -> PolicyEvaluation raise {
  validate_policy(policy, today~)
  let fail_on = match fail_on_override {
    Some(value) if is_policy_severity(value) => value
    Some(_) =>
      raise PolicyValidationError::InvalidPolicyField(
        field="fail_on",
        reason="override must be one of critical, high, medium, low, info",
      )
    None => policy.fail_on
  }
  let fail_on_scopes = match fail_on_scopes_override {
    Some(scopes) => normalize_policy_scopes(scopes)
    None => policy.fail_on_scopes
  }
  let matched : Array[Bool] = policy.suppressions.map(fn(_) { false })
  let findings : Array[Finding] = []
  let mut suppressed_findings = 0
  let mut scope_excluded_findings = 0
  let mut blocking_findings = 0
  for finding in report.findings {
    let finding_scopes = finding.dependency.scopes.map(dependency_scope_name)
    let effective_finding_scopes = if finding_scopes.length() == 0 {
      ["unclassified"]
    } else {
      finding_scopes
    }
    let mut in_scope = false
    for scope in effective_finding_scopes {
      if fail_on_scopes.contains(scope) {
        in_scope = true
      }
    }
    if !in_scope {
      scope_excluded_findings += 1
    }
    let mut selected : PolicySuppression? = None
    for index, suppression in policy.suppressions {
      if selected is None && suppression_matches(suppression, finding) {
        selected = Some(suppression)
        matched[index] = true
      }
    }
    if selected is Some(_) {
      suppressed_findings += 1
    } else {
      let baseline_allows_gate = match report.baseline {
        Some(_) =>
          match finding.baseline_status {
            Some(New) | Some(Updated) => true
            _ => false
          }
        None => true
      }
      if in_scope &&
        baseline_allows_gate &&
        policy_severity_rank(finding.advisory.severity) >=
        policy_severity_rank(fail_on) {
        blocking_findings += 1
      }
    }
    findings.push({
      dependency: finding.dependency,
      advisory: finding.advisory,
      status: finding.status,
      suppression: selected,
      baseline_status: finding.baseline_status,
    })
  }
  let unmatched_suppressions : Array[PolicySuppression] = []
  for index, suppression in policy.suppressions {
    if !matched[index] {
      unmatched_suppressions.push(suppression)
    }
  }
  let policy_summary = {
    fail_on,
    fail_on_scopes,
    blocking_findings,
    scope_excluded_findings,
    suppressed_findings,
    unmatched_suppressions,
  }
  let evaluated_report = {
    schema_version: "6",
    dependencies: report.dependencies,
    findings,
    uncomparable: report.uncomparable,
    conflicts: report.conflicts,
    policy: Some(policy_summary),
    baseline: report.baseline,
  }
  { report: evaluated_report, should_fail: blocking_findings > 0, }
}

///|
fn policy_string_field(
  fields : Map[String, Json],
  field : String,
  key~ : String = "",
) -> String raise {
  let lookup = if key == "" { field } else { key }
  match fields.get(lookup) {
    Some(String(value)) => value
    None =>
      raise PolicyValidationError::InvalidPolicyField(
        field~,
        reason="is required",
      )
    _ =>
      raise PolicyValidationError::InvalidPolicyField(
        field~,
        reason="must be a string",
      )
  }
}

///|
fn is_policy_severity(value : String) -> Bool {
  match value {
    "critical" | "high" | "medium" | "low" | "info" => true
    _ => false
  }
}

///|
fn policy_severity_rank(value : String) -> Int32 {
  match value {
    "critical" => 5
    "high" => 4
    "medium" => 3
    "low" => 2
    _ => 1
  }
}

///|
fn same_suppression_key(
  left : PolicySuppression,
  right : PolicySuppression,
) -> Bool {
  left.advisory_id == right.advisory_id &&
  left.package_name == right.package_name &&
  left.version == right.version
}

///|
fn suppression_matches(
  suppression : PolicySuppression,
  finding : Finding,
) -> Bool {
  suppression.advisory_id == finding.advisory.id &&
  suppression.package_name == finding.dependency.name &&
  suppression.version == finding.dependency.version
}

///|
fn is_iso_date(value : String) -> Bool {
  if value.length() != 10 {
    return false
  }
  let chars = value.to_array()
  if chars[4] != '-' || chars[7] != '-' {
    return false
  }
  match
    (
      date_number(chars, 0, 4),
      date_number(chars, 5, 2),
      date_number(chars, 8, 2),
    ) {
    (Some(year), Some(month), Some(day)) => {
      if year == 0 || month < 1 || month > 12 {
        return false
      }
      let days_in_month = match month {
        2 if is_leap_year(year) => 29
        2 => 28
        4 | 6 | 9 | 11 => 30
        _ => 31
      }
      day >= 1 && day <= days_in_month
    }
    _ => false
  }
}

///|
fn date_number(chars : Array[Char], start : Int32, length : Int32) -> Int32? {
  let mut value = 0
  for index in start..<(start + length) {
    match digit_value(chars[index]) {
      Some(digit) => value = value * 10 + digit
      None => return None
    }
  }
  Some(value)
}

///|
fn is_leap_year(year : Int32) -> Bool {
  year % 400 == 0 || (year % 4 == 0 && year % 100 != 0)
}