///|
pub(all) enum RiskLevel {
  RiskLow
  RiskMedium
  RiskHigh
  RiskCritical
} derive(Eq)

///|
pub(all) struct PolicyAdvice {
  path : String
  current : CachePolicy
  recommended : CachePolicy
  reason : String
} derive(Eq)

///|
pub(all) struct QualityReport {
  score : Int
  risk : RiskLevel
  entries : Int
  total_bytes : Int
  errors : Int
  warnings : Int
  generated_assets : Int
  runtime_assets : Int
  no_store_assets : Int
  immutable_assets : Int
  missing_tag_assets : Int
  oversized_assets : Int
  duplicate_fingerprint_assets : Int
  directory_count : Int
  extension_count : Int
  policy_advice : Array[PolicyAdvice]
  findings : Array[Finding]
} derive(Eq)

///|
pub(all) struct DirectoryProfile {
  directory : String
  entries : Int
  bytes : Int
  generated_entries : Int
  runtime_entries : Int
  media_entries : Int
  source_entries : Int
  document_entries : Int
  warnings : Int
} derive(Eq)

///|
pub fn RiskLevel::to_wire(self : RiskLevel) -> String {
  match self {
    RiskLow => "low"
    RiskMedium => "medium"
    RiskHigh => "high"
    RiskCritical => "critical"
  }
}

///|
pub fn parse_risk_level(raw : String) -> RiskLevel {
  match raw.trim().to_owned().to_lower() {
    "low" => RiskLow
    "medium" => RiskMedium
    "high" => RiskHigh
    "critical" => RiskCritical
    _ => RiskMedium
  }
}

///|
pub fn assess_quality(manifest : Manifest) -> QualityReport {
  let base_findings = validate_manifest(manifest)
  let extra_findings = quality_findings(manifest)
  let all_findings = merge_findings(base_findings, extra_findings)
  let score = quality_score(manifest, all_findings)
  let summary = summarize(manifest)
  let policy_advice = policy_advice_for_manifest(manifest)
  {
    score,
    risk: risk_from_score(score, all_findings),
    entries: manifest.entries.length(),
    total_bytes: summary.total_bytes,
    errors: count_findings(all_findings, Error),
    warnings: count_findings(all_findings, Warning),
    generated_assets: assets_with_bucket(manifest, BucketGenerated).length(),
    runtime_assets: assets_with_bucket(manifest, BucketRuntime).length(),
    no_store_assets: assets_with_policy(manifest, NoStore).length(),
    immutable_assets: assets_with_policy(manifest, Immutable).length(),
    missing_tag_assets: assets_without_tags(manifest).length(),
    oversized_assets: oversized_assets(manifest, 1048576).length(),
    duplicate_fingerprint_assets: duplicate_asset_count(manifest),
    directory_count: manifest_directories(manifest).length(),
    extension_count: manifest_extensions(manifest).length(),
    policy_advice,
    findings: all_findings,
  }
}

///|
pub fn quality_score(manifest : Manifest, findings : Array[Finding]) -> Int {
  let mut score = 100
  for finding in findings {
    match finding.level {
      Error => score = score - 20
      Warning => score = score - 6
      Info => score = score - 1
    }
  }
  if manifest.entries.is_empty() {
    score = score - 30
  }
  if manifest.entries.length() > 0 && manifest.entries.length() < 3 {
    score = score - 8
  }
  if manifest_directories(manifest).length() == 1 &&
    manifest.entries.length() > 8 {
    score = score - 4
  }
  if assets_without_tags(manifest).length() > 0 {
    score = score - 4
  }
  if policy_advice_for_manifest(manifest).length() > 0 {
    score = score - 4
  }
  clamp_score(score)
}

///|
pub fn risk_from_score(score : Int, findings : Array[Finding]) -> RiskLevel {
  if count_findings(findings, Error) > 0 {
    return RiskCritical
  }
  if score >= 85 {
    RiskLow
  } else if score >= 70 {
    RiskMedium
  } else if score >= 50 {
    RiskHigh
  } else {
    RiskCritical
  }
}

///|
pub fn quality_findings(manifest : Manifest) -> Array[Finding] {
  let findings : Array[Finding] = []
  for entry in manifest.entries {
    if entry.tags.is_empty() {
      findings.push(
        quality_warning(
          "tag-missing",
          Some(entry.path),
          "asset has no tag and may be harder to audit",
        ),
      )
    }
    if entry.bytes > 1048576 {
      findings.push(
        quality_warning(
          "asset-large",
          Some(entry.path),
          "asset is larger than one mebibyte",
        ),
      )
    }
    if entry.bytes == 0 {
      findings.push(
        quality_warning("asset-empty", Some(entry.path), "asset has zero bytes"),
      )
    }
    if classify_asset(entry) == BucketGenerated && entry.policy == Immutable {
      findings.push(
        quality_info(
          "generated-immutable",
          Some(entry.path),
          "generated asset is immutable; ensure rebuilds update the fingerprint",
        ),
      )
    }
    if classify_asset(entry) == BucketRuntime && entry.policy == Immutable {
      findings.push(
        quality_warning(
          "runtime-immutable",
          Some(entry.path),
          "runtime assets should usually revalidate or avoid storage",
        ),
      )
    }
    if path_is_hidden(entry.path) is Ok(true) {
      findings.push(
        quality_warning(
          "hidden-asset",
          Some(entry.path),
          "hidden files are easy to miss during release review",
        ),
      )
    }
  }
  for row in duplicate_fingerprint_rows(manifest) {
    findings.push(
      quality_info(
        "fingerprint-reused",
        Some(row.key),
        "same fingerprint is shared by multiple assets",
      ),
    )
  }
  findings
}

///|
pub fn policy_advice_for_manifest(manifest : Manifest) -> Array[PolicyAdvice] {
  let advice : Array[PolicyAdvice] = []
  for entry in manifest.entries {
    match policy_advice_for_asset(entry) {
      Some(item) => advice.push(item)
      None => ()
    }
  }
  advice.sort_by((a, b) => a.path.compare(b.path))
  advice
}

///|
pub fn policy_advice_for_asset(entry : Asset) -> PolicyAdvice? {
  let recommended = recommended_policy(entry)
  if recommended == entry.policy {
    return None
  }
  let reason = policy_reason(entry, recommended)
  Some({ path: entry.path, current: entry.policy, recommended, reason })
}

///|
pub fn recommended_policy(entry : Asset) -> CachePolicy {
  let bucket = classify_asset(entry)
  match bucket {
    BucketRuntime => Revalidate
    BucketGenerated => Revalidate
    BucketSource => Revalidate
    BucketDocument => Revalidate
    BucketConfig => Revalidate
    BucketArchive => Immutable
    BucketMedia => if entry.bytes == 0 { NoStore } else { Immutable }
    BucketOther =>
      match entry.kind {
        Text | Data => Revalidate
        Binary | Image | Audio | Font => Immutable
        Other(_) => Revalidate
      }
  }
}

///|
pub fn cache_policy_weight(policy : CachePolicy) -> Int {
  match policy {
    Immutable => 4
    Revalidate => 3
    Runtime => 2
    NoStore => 1
  }
}

///|
pub fn cache_stability_score(manifest : Manifest) -> Int {
  if manifest.entries.is_empty() {
    return 0
  }
  let mut total = 0
  let mut max = 0
  for entry in manifest.entries {
    total = total + cache_policy_weight(entry.policy)
    max = max + 4
  }
  total * 100 / max
}

///|
pub fn assets_without_tags(manifest : Manifest) -> Array[Asset] {
  let result : Array[Asset] = []
  for entry in manifest.entries {
    if entry.tags.is_empty() {
      result.push(entry)
    }
  }
  result
}

///|
pub fn oversized_assets(manifest : Manifest, threshold : Int) -> Array[Asset] {
  let result : Array[Asset] = []
  for entry in manifest.entries {
    if entry.bytes > threshold {
      result.push(entry)
    }
  }
  result
}

///|
pub fn zero_byte_assets(manifest : Manifest) -> Array[Asset] {
  let result : Array[Asset] = []
  for entry in manifest.entries {
    if entry.bytes == 0 {
      result.push(entry)
    }
  }
  result
}

///|
pub fn directory_profiles(manifest : Manifest) -> Array[DirectoryProfile] {
  let profiles : Array[DirectoryProfile] = []
  for row in count_by_directory(manifest) {
    let entries = assets_under_directory(manifest, row.key)
    profiles.push(profile_directory(row.key, entries))
  }
  profiles.sort_by((a, b) => {
    if a.warnings == b.warnings {
      b.bytes.compare(a.bytes)
    } else {
      b.warnings.compare(a.warnings)
    }
  })
  profiles
}

///|
pub fn profile_directory(
  directory : String,
  entries : Array[Asset],
) -> DirectoryProfile {
  let mut generated_entries = 0
  let mut runtime_entries = 0
  let mut media_entries = 0
  let mut source_entries = 0
  let mut document_entries = 0
  let mut warnings = 0
  for entry in entries {
    match classify_asset(entry) {
      BucketGenerated => generated_entries = generated_entries + 1
      BucketRuntime => runtime_entries = runtime_entries + 1
      BucketMedia => media_entries = media_entries + 1
      BucketSource => source_entries = source_entries + 1
      BucketDocument => document_entries = document_entries + 1
      _ => ()
    }
    if entry.tags.is_empty() || entry.policy == NoStore || entry.bytes == 0 {
      warnings = warnings + 1
    }
  }
  {
    directory,
    entries: entries.length(),
    bytes: profile_total_bytes(entries),
    generated_entries,
    runtime_entries,
    media_entries,
    source_entries,
    document_entries,
    warnings,
  }
}

///|
pub fn directories_with_warnings(
  manifest : Manifest,
) -> Array[DirectoryProfile] {
  let result : Array[DirectoryProfile] = []
  for profile in directory_profiles(manifest) {
    if profile.warnings > 0 {
      result.push(profile)
    }
  }
  result
}

///|
pub fn render_quality_report(report : QualityReport) -> String {
  let out = StringBuilder()
  out.write_string("quality ")
  out.write_string(report.score.to_string())
  out.write_string(" ")
  out.write_string(report.risk.to_wire())
  out.write_string("\nentries ")
  out.write_string(report.entries.to_string())
  out.write_string("\nbytes ")
  out.write_string(report.total_bytes.to_string())
  out.write_string("\nerrors ")
  out.write_string(report.errors.to_string())
  out.write_string("\nwarnings ")
  out.write_string(report.warnings.to_string())
  out.write_string("\ngenerated ")
  out.write_string(report.generated_assets.to_string())
  out.write_string("\nruntime ")
  out.write_string(report.runtime_assets.to_string())
  out.write_string("\nmissing-tags ")
  out.write_string(report.missing_tag_assets.to_string())
  out.write_string("\npolicy-advice ")
  out.write_string(report.policy_advice.length().to_string())
  out.write_string("\n")
  if !report.policy_advice.is_empty() {
    out.write_string("\n[policy]\n")
    for advice in report.policy_advice {
      out.write_string(advice.path)
      out.write_string(" ")
      out.write_string(advice.current.to_wire())
      out.write_string(" -> ")
      out.write_string(advice.recommended.to_wire())
      out.write_string(" ")
      out.write_string(advice.reason)
      out.write_string("\n")
    }
  }
  if !report.findings.is_empty() {
    out.write_string("\n[findings]\n")
    out.write_string(render_findings(report.findings))
  }
  out.to_string()
}

///|
pub fn render_findings(findings : Array[Finding]) -> String {
  if findings.is_empty() {
    return "(none)\n"
  }
  let out = StringBuilder()
  for finding in findings {
    out.write_string(finding_level_wire(finding.level))
    out.write_string(" ")
    out.write_string(finding.code)
    match finding.path {
      Some(path) => {
        out.write_string(" ")
        out.write_string(path)
      }
      None => ()
    }
    out.write_string(" - ")
    out.write_string(finding.message)
    out.write_string("\n")
  }
  out.to_string()
}

///|
pub fn render_directory_profiles(profiles : Array[DirectoryProfile]) -> String {
  if profiles.is_empty() {
    return "(none)\n"
  }
  let out = StringBuilder()
  for profile in profiles {
    out.write_string(profile.directory)
    out.write_string(" entries=")
    out.write_string(profile.entries.to_string())
    out.write_string(" bytes=")
    out.write_string(profile.bytes.to_string())
    out.write_string(" warnings=")
    out.write_string(profile.warnings.to_string())
    out.write_string(" generated=")
    out.write_string(profile.generated_entries.to_string())
    out.write_string(" media=")
    out.write_string(profile.media_entries.to_string())
    out.write_string(" source=")
    out.write_string(profile.source_entries.to_string())
    out.write_string("\n")
  }
  out.to_string()
}

///|
fn quality_warning(code : String, path : String?, message : String) -> Finding {
  { level: Warning, code, path, message }
}

///|
fn quality_info(code : String, path : String?, message : String) -> Finding {
  { level: Info, code, path, message }
}

///|
fn merge_findings(a : Array[Finding], b : Array[Finding]) -> Array[Finding] {
  let result : Array[Finding] = []
  for finding in a {
    result.push(finding)
  }
  for finding in b {
    result.push(finding)
  }
  result
}

///|
fn count_findings(findings : Array[Finding], level : FindingLevel) -> Int {
  let mut count = 0
  for finding in findings {
    if finding.level == level {
      count = count + 1
    }
  }
  count
}

///|
fn clamp_score(score : Int) -> Int {
  if score < 0 {
    0
  } else if score > 100 {
    100
  } else {
    score
  }
}

///|
fn policy_reason(entry : Asset, recommended : CachePolicy) -> String {
  match recommended {
    Immutable =>
      match classify_asset(entry) {
        BucketMedia => "media assets with fingerprints are stable cache targets"
        BucketArchive => "archives are usually versioned release artifacts"
        _ => "fingerprinted binary assets can be cached immutably"
      }
    Revalidate =>
      match classify_asset(entry) {
        BucketSource =>
          "source files change during development and should revalidate"
        BucketDocument => "documentation is usually edited between releases"
        BucketConfig => "configuration changes should be visible quickly"
        BucketGenerated =>
          "generated outputs should refresh with the producing pipeline"
        BucketRuntime => "runtime data should stay refreshable"
        _ => "asset is safer with normal revalidation"
      }
    NoStore => "zero-byte or transient assets should not be stored"
    Runtime => "runtime policy is reserved for caller-managed resources"
  }
}

///|
fn finding_level_wire(level : FindingLevel) -> String {
  match level {
    Error => "error"
    Warning => "warning"
    Info => "info"
  }
}

///|
fn profile_total_bytes(entries : Array[Asset]) -> Int {
  let mut total = 0
  for entry in entries {
    total = total + entry.bytes
  }
  total
}