///|
/// Catalog-level checks for retention rules and capsule inventories.
///
/// This module audits application data-lifecycle metadata only. It does not
/// inspect repositories, package registries, CI systems, or open-source
/// provenance.
pub(all) enum CatalogFindingKind {
  DuplicateRuleId
  DuplicateCapsuleId
  InvalidRetentionWindow
  MissingAllowedRegion
  MissingRuleOwner
  RuleWithoutCapsule
  CapsuleWithoutRule
  SensitiveExternalSharing
  SensitivePlainCapsule
  PublicSharedRestrictedCapsule
} derive(Eq, Debug)

///|
pub(all) struct CatalogFinding {
  kind : CatalogFindingKind
  severity : RetentionSeverity
  subject : String
  message : String
  remedy : String
} derive(Eq, Debug)

///|
pub(all) struct CatalogReport {
  rules : Array[RetentionRule]
  capsules : Array[DataCapsule]
  findings : Array[CatalogFinding]
  covered_capsules : Int
  uncovered_capsules : Int
} derive(Eq, Debug)

///|
pub fn CatalogFindingKind::label(self : CatalogFindingKind) -> String {
  match self {
    DuplicateRuleId => "duplicate-rule-id"
    DuplicateCapsuleId => "duplicate-capsule-id"
    InvalidRetentionWindow => "invalid-retention-window"
    MissingAllowedRegion => "missing-allowed-region"
    MissingRuleOwner => "missing-rule-owner"
    RuleWithoutCapsule => "rule-without-capsule"
    CapsuleWithoutRule => "capsule-without-rule"
    SensitiveExternalSharing => "sensitive-external-sharing"
    SensitivePlainCapsule => "sensitive-plain-capsule"
    PublicSharedRestrictedCapsule => "public-shared-restricted-capsule"
  }
}

///|
pub fn analyze_catalog(
  rules : Array[RetentionRule],
  capsules : Array[DataCapsule],
) -> CatalogReport {
  let findings : Array[CatalogFinding] = []
  audit_rule_ids(rules, findings)
  audit_capsule_ids(capsules, findings)
  audit_rules(rules, capsules, findings)
  audit_capsules(rules, capsules, findings)
  let uncovered_capsules = capsules.count_if(capsule => {
    !catalog_has_rule(capsule, rules)
  })
  {
    rules,
    capsules,
    findings: sort_catalog_findings(findings),
    covered_capsules: capsules.length() - uncovered_capsules,
    uncovered_capsules,
  }
}

///|
pub fn sample_catalog_report() -> CatalogReport {
  analyze_catalog(sample_rules(), sample_capsules())
}

///|
pub fn CatalogReport::is_ready(self : CatalogReport) -> Bool {
  !self.findings.any(finding => {
    finding.severity == RetentionCritical || finding.severity == RetentionHigh
  })
}

///|
pub fn CatalogReport::coverage_percent(self : CatalogReport) -> Int {
  if self.capsules.is_empty() {
    100
  } else {
    self.covered_capsules * 100 / self.capsules.length()
  }
}

///|
pub fn CatalogReport::count_by_severity(
  self : CatalogReport,
  severity : RetentionSeverity,
) -> Int {
  self.findings.count_if(finding => finding.severity == severity)
}

///|
pub fn CatalogReport::count_by_kind(
  self : CatalogReport,
  kind : CatalogFindingKind,
) -> Int {
  self.findings.count_if(finding => finding.kind == kind)
}

///|
pub fn CatalogReport::findings_for_subject(
  self : CatalogReport,
  subject : StringView,
) -> Array[CatalogFinding] {
  let expected = subject.trim().to_owned()
  self.findings.filter(finding => finding.subject == expected)
}

///|
pub fn CatalogReport::to_markdown(self : CatalogReport) -> String {
  let lines : Array[String] = []
  lines.push("# CapsuleTrace Catalog Report")
  lines.push("")
  lines.push("- Rules: " + self.rules.length().to_string())
  lines.push("- Capsules: " + self.capsules.length().to_string())
  lines.push("- Covered capsules: " + self.covered_capsules.to_string())
  lines.push("- Coverage: " + self.coverage_percent().to_string() + "%")
  lines.push("- Ready: " + bool_label(self.is_ready()))
  lines.push("")
  lines.push("| Severity | Rule | Subject | Message |")
  lines.push("| --- | --- | --- | --- |")
  if self.findings.is_empty() {
    lines.push("| info | clean | all | no catalog findings |")
  } else {
    for finding in self.findings {
      lines.push(
        "| " +
        finding.severity.label() +
        " | " +
        finding.kind.label() +
        " | " +
        markdown_cell(finding.subject) +
        " | " +
        markdown_cell(finding.message) +
        " |",
      )
    }
  }
  lines.join("\n")
}

///|
pub fn CatalogReport::to_json_string(self : CatalogReport) -> String {
  Json::object(
    Map([
      ("ready", Json::boolean(self.is_ready())),
      ("coverage_percent", json_int(self.coverage_percent())),
      ("rules", json_int(self.rules.length())),
      ("capsules", json_int(self.capsules.length())),
      ("covered_capsules", json_int(self.covered_capsules)),
      ("uncovered_capsules", json_int(self.uncovered_capsules)),
      ("findings", catalog_findings_to_json(self.findings)),
    ]),
  ).stringify(indent=2)
}

///|
fn audit_rule_ids(
  rules : Array[RetentionRule],
  findings : Array[CatalogFinding],
) -> Unit {
  let seen : Array[String] = []
  for rule in rules {
    if seen.contains(rule.id) {
      findings.push({
        kind: DuplicateRuleId,
        severity: RetentionHigh,
        subject: rule.id,
        message: "retention rule id appears more than once",
        remedy: "keep rule ids unique so matched decisions are deterministic",
      })
    } else {
      seen.push(rule.id)
    }
  }
}

///|
fn audit_capsule_ids(
  capsules : Array[DataCapsule],
  findings : Array[CatalogFinding],
) -> Unit {
  let seen : Array[String] = []
  for capsule in capsules {
    if seen.contains(capsule.id) {
      findings.push({
        kind: DuplicateCapsuleId,
        severity: RetentionHigh,
        subject: capsule.id,
        message: "data capsule id appears more than once",
        remedy: "keep capsule ids unique before building a deletion queue",
      })
    } else {
      seen.push(capsule.id)
    }
  }
}

///|
fn audit_rules(
  rules : Array[RetentionRule],
  capsules : Array[DataCapsule],
  findings : Array[CatalogFinding],
) -> Unit {
  for rule in rules {
    if rule.max_keep_days <= 0 {
      findings.push({
        kind: InvalidRetentionWindow,
        severity: RetentionHigh,
        subject: rule.id,
        message: "max retention days must be positive",
        remedy: "set a positive max_keep_days value",
      })
    }
    if rule.anonymize_after_days <= 0 {
      findings.push({
        kind: InvalidRetentionWindow,
        severity: RetentionMedium,
        subject: rule.id,
        message: "anonymization window should be positive",
        remedy: "set anonymize_after_days to the first day identifiers should be reduced",
      })
    }
    if rule.anonymize_after_days > rule.max_keep_days {
      findings.push({
        kind: InvalidRetentionWindow,
        severity: RetentionHigh,
        subject: rule.id,
        message: "anonymization window is later than deletion deadline",
        remedy: "make anonymization happen before or on the retention deadline",
      })
    }
    if rule.allowed_regions.is_empty() {
      findings.push({
        kind: MissingAllowedRegion,
        severity: RetentionHigh,
        subject: rule.id,
        message: "rule has no allowed storage region",
        remedy: "add at least one allowed region",
      })
    }
    if rule.owner.trim().is_empty() {
      findings.push({
        kind: MissingRuleOwner,
        severity: RetentionMedium,
        subject: rule.id,
        message: "rule has no owner for operator handoff",
        remedy: "assign an owner such as privacy, finance, support, or data-platform",
      })
    }
    if !rule_has_capsule(rule, capsules) {
      findings.push({
        kind: RuleWithoutCapsule,
        severity: RetentionLow,
        subject: rule.id,
        message: "rule is currently not used by any capsule",
        remedy: "keep it if it is a planned policy; otherwise remove it from the catalog",
      })
    }
  }
}

///|
fn audit_capsules(
  rules : Array[RetentionRule],
  capsules : Array[DataCapsule],
  findings : Array[CatalogFinding],
) -> Unit {
  for capsule in capsules {
    if !catalog_has_rule(capsule, rules) {
      findings.push({
        kind: CapsuleWithoutRule,
        severity: RetentionHigh,
        subject: capsule.id,
        message: "capsule has no matching retention rule",
        remedy: "add a rule for purpose '" +
        capsule.purpose +
        "' and data kind '" +
        capsule.data_kind +
        "'",
      })
    }
    if capsule.sensitivity.requires_encryption() && !capsule.encrypted {
      findings.push({
        kind: SensitivePlainCapsule,
        severity: RetentionHigh,
        subject: capsule.id,
        message: "sensitive capsule is stored without encryption metadata",
        remedy: "encrypt the capsule or downgrade the sensitivity only if appropriate",
      })
    }
    if capsule.sensitivity.requires_encryption() &&
      capsule.sharing.is_external() {
      findings.push({
        kind: SensitiveExternalSharing,
        severity: RetentionMedium,
        subject: capsule.id,
        message: "sensitive capsule is shared outside the application team",
        remedy: "confirm processor contracts or keep the capsule internal",
      })
    }
    if capsule.sensitivity == RestrictedData && capsule.sharing == PublicShared {
      findings.push({
        kind: PublicSharedRestrictedCapsule,
        severity: RetentionCritical,
        subject: capsule.id,
        message: "restricted capsule cannot be publicly shared",
        remedy: "stop public sharing and rotate affected exports",
      })
    }
  }
}

///|
fn rule_has_capsule(
  rule : RetentionRule,
  capsules : Array[DataCapsule],
) -> Bool {
  capsules.any(capsule => rule_matches_capsule(rule, capsule))
}

///|
fn catalog_has_rule(
  capsule : DataCapsule,
  rules : Array[RetentionRule],
) -> Bool {
  rules.any(rule => rule_matches_capsule(rule, capsule))
}

///|
fn rule_matches_capsule(rule : RetentionRule, capsule : DataCapsule) -> Bool {
  normalize_token(rule.purpose) == normalize_token(capsule.purpose) &&
  (
    normalize_token(rule.data_kind) == normalize_token(capsule.data_kind) ||
    normalize_token(rule.data_kind) == "*"
  )
}

///|
fn sort_catalog_findings(
  values : Array[CatalogFinding],
) -> Array[CatalogFinding] {
  let mut sorted : Array[CatalogFinding] = []
  for value in values {
    let next : Array[CatalogFinding] = []
    let mut inserted = false
    for existing in sorted {
      if !inserted && compare_catalog_finding(value, existing) < 0 {
        next.push(value)
        inserted = true
      }
      next.push(existing)
    }
    if !inserted {
      next.push(value)
    }
    sorted = next
  }
  sorted
}

///|
fn compare_catalog_finding(a : CatalogFinding, b : CatalogFinding) -> Int {
  let severity_delta = b.severity.weight() - a.severity.weight()
  if severity_delta != 0 {
    severity_delta
  } else {
    let subject_delta = a.subject.lexical_compare(b.subject)
    if subject_delta != 0 {
      subject_delta
    } else {
      a.kind.label().lexical_compare(b.kind.label())
    }
  }
}

///|
fn catalog_findings_to_json(items : Array[CatalogFinding]) -> Json {
  let values : Array[Json] = []
  for item in items {
    values.push(
      Json::object(
        Map([
          ("kind", Json::string(item.kind.label())),
          ("severity", Json::string(item.severity.label())),
          ("subject", Json::string(item.subject)),
          ("message", Json::string(item.message)),
          ("remedy", Json::string(item.remedy)),
        ]),
      ),
    )
  }
  Json::array(values)
}