///|
/// Repository-specific release policy loaded from `moonseal-policy.json`.
pub(all) struct AuditPolicy {
  minimum_score : Int
  minimum_source_lines : Int
  maximum_warnings : Int
  maximum_dependencies : Int
  require_repository : Bool
  require_ci : Bool
  require_changelog : Bool
  require_security_policy : Bool
  require_pinned_dependencies : Bool
  allowed_licenses : Array[String]
  denied_licenses : Array[String]
  required_files : Array[String]
} derive(Eq, Debug)

///|
/// One policy-specific violation. These use the `MP` prefix so they remain
/// distinguishable from built-in `MS` audit findings.
pub(all) struct PolicyViolation {
  code : String
  path : String
  message : String
} derive(Eq, Debug)

///|
/// Result of applying repository policy to an audit.
pub(all) struct PolicyResult {
  policy : AuditPolicy
  violations : Array[PolicyViolation]
} derive(Eq, Debug)

///|
pub fn PolicyResult::is_allowed(self : PolicyResult) -> Bool {
  self.violations.is_empty()
}

///|
pub fn default_policy() -> AuditPolicy {
  {
    minimum_score: 0,
    minimum_source_lines: 1,
    maximum_warnings: 2147483647,
    maximum_dependencies: 2147483647,
    require_repository: false,
    require_ci: false,
    require_changelog: false,
    require_security_policy: false,
    require_pinned_dependencies: false,
    allowed_licenses: [],
    denied_licenses: [],
    required_files: [],
  }
}

///|
fn policy_json_int(value : Json?, fallback : Int) -> Int {
  match value {
    Some(Number(number, ..)) => number.to_int()
    _ => fallback
  }
}

///|
fn policy_json_bool(value : Json?, fallback : Bool) -> Bool {
  match value {
    Some(true) => true
    Some(false) => false
    _ => fallback
  }
}

///|
fn policy_json_strings(value : Json?) -> Array[String] {
  let values = []
  match value {
    Some(Array(items)) =>
      for item in items {
        match item {
          String(value) => values.push(value)
          _ => ()
        }
      }
    _ => ()
  }
  values
}

///|
/// Parse a MoonSeal policy document. Unknown keys are deliberately ignored so
/// that policy files remain forwards compatible.
pub fn parse_policy(text : StringView) -> AuditPolicy raise {
  let value = @json.parse(text)
  guard value is Object(fields) else {
    fail("MoonSeal policy must be a JSON object")
  }
  let defaults = default_policy()
  {
    minimum_score: policy_json_int(
      fields.get("minimumScore"),
      defaults.minimum_score,
    ),
    minimum_source_lines: policy_json_int(
      fields.get("minimumSourceLines"),
      defaults.minimum_source_lines,
    ),
    maximum_warnings: policy_json_int(
      fields.get("maximumWarnings"),
      defaults.maximum_warnings,
    ),
    maximum_dependencies: policy_json_int(
      fields.get("maximumDependencies"),
      defaults.maximum_dependencies,
    ),
    require_repository: policy_json_bool(
      fields.get("requireRepository"),
      defaults.require_repository,
    ),
    require_ci: policy_json_bool(fields.get("requireCi"), defaults.require_ci),
    require_changelog: policy_json_bool(
      fields.get("requireChangelog"),
      defaults.require_changelog,
    ),
    require_security_policy: policy_json_bool(
      fields.get("requireSecurityPolicy"),
      defaults.require_security_policy,
    ),
    require_pinned_dependencies: policy_json_bool(
      fields.get("requirePinnedDependencies"),
      defaults.require_pinned_dependencies,
    ),
    allowed_licenses: policy_json_strings(fields.get("allowedLicenses")),
    denied_licenses: policy_json_strings(fields.get("deniedLicenses")),
    required_files: policy_json_strings(fields.get("requiredFiles")),
  }
}

///|
fn policy_normalize_path(path : StringView) -> String {
  let normalized = path.replace_all(old="\\", new="/")
  match normalized.strip_prefix("./") {
    Some(relative) => relative.to_owned().to_lower()
    None => normalized.to_owned().to_lower()
  }
}

///|
fn policy_has_file(files : Array[String], expected : StringView) -> Bool {
  let expected = policy_normalize_path(expected)
  files.any(fn(file) { policy_normalize_path(file) == expected })
}

///|
fn policy_has_workflow(files : Array[String]) -> Bool {
  files.any(fn(file) {
    let path = policy_normalize_path(file)
    path.has_prefix(".github/workflows/") &&
    (path.has_suffix(".yml") || path.has_suffix(".yaml"))
  })
}

///|
fn policy_has_changelog(files : Array[String]) -> Bool {
  policy_has_file(files, "CHANGELOG.md") || policy_has_file(files, "CHANGES.md")
}

///|
fn policy_has_security(files : Array[String]) -> Bool {
  policy_has_file(files, "SECURITY.md") ||
  policy_has_file(files, ".github/SECURITY.md")
}

///|
fn policy_license_in(list : Array[String], license : StringView) -> Bool {
  let normalized = normalize_license(license)
  list.any(fn(candidate) { normalize_license(candidate) == normalized })
}

///|
fn add_policy_violation(
  violations : Array[PolicyViolation],
  code : String,
  path : String,
  message : String,
) -> Unit {
  violations.push({ code, path, message })
}

///|
/// Apply repository policy to already-collected facts and an audit report.
pub fn evaluate_policy(
  policy : AuditPolicy,
  facts : ScanFacts,
  report : AuditReport,
) -> PolicyResult {
  let violations = []
  if report.score < policy.minimum_score {
    add_policy_violation(
      violations,
      "MP001",
      "",
      "Audit score \{report.score} is below required score \{policy.minimum_score}.",
    )
  }
  if facts.moonbit_source_lines < policy.minimum_source_lines {
    add_policy_violation(
      violations,
      "MP002",
      "",
      "MoonBit source has \{facts.moonbit_source_lines} lines; policy requires \{policy.minimum_source_lines}.",
    )
  }
  if report.warning_count() > policy.maximum_warnings {
    add_policy_violation(
      violations,
      "MP003",
      "",
      "Audit has \{report.warning_count()} warnings; policy allows \{policy.maximum_warnings}.",
    )
  }
  if facts.project.dependencies.length() > policy.maximum_dependencies {
    add_policy_violation(
      violations,
      "MP004",
      "moon.mod",
      "Project has \{facts.project.dependencies.length()} dependencies; policy allows \{policy.maximum_dependencies}.",
    )
  }
  if policy.require_repository && facts.project.repository.is_empty() {
    add_policy_violation(
      violations, "MP005", "moon.mod", "Repository URL is required by policy.",
    )
  }
  if policy.require_ci && !policy_has_workflow(facts.files) {
    add_policy_violation(
      violations, "MP006", ".github/workflows", "A GitHub Actions workflow is required by policy.",
    )
  }
  if policy.require_changelog && !policy_has_changelog(facts.files) {
    add_policy_violation(
      violations, "MP007", "CHANGELOG.md", "A changelog is required by policy.",
    )
  }
  if policy.require_security_policy && !policy_has_security(facts.files) {
    add_policy_violation(
      violations, "MP008", "SECURITY.md", "A security policy is required by policy.",
    )
  }
  if !facts.project.license.is_empty() &&
    !policy.allowed_licenses.is_empty() &&
    !policy_license_in(policy.allowed_licenses, facts.project.license) {
    add_policy_violation(
      violations,
      "MP009",
      "moon.mod",
      "License \{facts.project.license} is not in the policy allow list.",
    )
  }
  if !facts.project.license.is_empty() &&
    policy_license_in(policy.denied_licenses, facts.project.license) {
    add_policy_violation(
      violations,
      "MP010",
      "moon.mod",
      "License \{facts.project.license} is denied by policy.",
    )
  }
  if policy.require_pinned_dependencies {
    for dependency in facts.project.dependencies {
      if dependency.version.is_empty() {
        add_policy_violation(
          violations,
          "MP011",
          "moon.mod",
          "Dependency \{dependency.name} does not have a pinned version.",
        )
      }
    }
  }
  for required in policy.required_files {
    if !policy_has_file(facts.files, required) {
      add_policy_violation(
        violations,
        "MP012",
        required,
        "Required file is missing: \{required}.",
      )
    }
  }
  { policy, violations }
}

///|
pub fn render_policy_text(result : PolicyResult) -> String {
  let out = StringBuilder::new()
  if result.is_allowed() {
    out.write_string("POLICY PASSED  All repository policy checks passed.\n")
  } else {
    out.write_string(
      "POLICY BLOCKED  \{result.violations.length()} violation(s)\n\n",
    )
    for violation in result.violations {
      let path = if violation.path.is_empty() {
        ""
      } else {
        " [\{violation.path}]"
      }
      out.write_string("ERROR \{violation.code}\{path}: \{violation.message}\n")
    }
  }
  out.to_string()
}

///|
pub fn render_policy_json(result : PolicyResult, indent? : Int = 2) -> String {
  let violations : Array[Json] = result.violations.map(fn(violation) {
    {
      "code": violation.code,
      "path": violation.path,
      "message": violation.message,
    }
  })
  let value : Json = {
    "allowed": result.is_allowed(),
    "summary": {
      "violations": result.violations.length(),
      "minimumScore": result.policy.minimum_score,
      "minimumSourceLines": result.policy.minimum_source_lines,
      "maximumWarnings": result.policy.maximum_warnings,
      "maximumDependencies": result.policy.maximum_dependencies,
    },
    "violations": violations,
  }
  value.stringify(indent~)
}