///|
/// One parsed Content-Security-Policy directive.
pub struct CspDirective {
  name : String
  values : Array[String]
  raw : String
  index : Int
} derive(Eq, @debug.Debug)

///|
/// Parsed Content-Security-Policy header.
pub struct CspPolicy {
  directives : Array[CspDirective]
  warnings : Array[ParseWarning]
} derive(Eq, @debug.Debug)

///|
/// Compact Content-Security-Policy summary.
pub struct CspSummary {
  directive_count : Int
  source_directives : Int
  wildcard_sources : Int
  unsafe_inline : Int
  unsafe_eval : Int
  nonce_sources : Int
  hash_sources : Int
  insecure_sources : Int
  missing_core : Array[String]
} derive(Eq, @debug.Debug)

///|
/// One interpreted CSP source expression.
pub struct SourceExpression {
  value : String
  kind : String
  secure : Bool
  wildcard : Bool
} derive(Eq, @debug.Debug)

///|
/// Result of auditing one security header family.
pub struct HeaderCheck {
  header : String
  present : Bool
  score : Int
  max_score : Int
  findings : Array[Finding]
  observed : String
  recommended : String
} derive(Eq, @debug.Debug)

///|
/// Aggregated security score for a response.
pub struct SecurityScore {
  points : Int
  max_points : Int
  percent : Int
  grade : String
  high : Int
  warning : Int
  info : Int
} derive(Eq, @debug.Debug)

///|
/// Full response security-header audit.
pub struct SecurityAudit {
  headers : HeaderSet
  csp : CspPolicy?
  permissions : HeaderAudit
  checks : Array[HeaderCheck]
  score : SecurityScore
} derive(Eq, @debug.Debug)

///|
/// Summary of findings grouped by severity.
pub struct SecurityFindingSummary {
  total : Int
  high : Int
  warning : Int
  info : Int
  high_codes : Array[String]
  warning_codes : Array[String]
  info_codes : Array[String]
} derive(Eq, @debug.Debug)

///|
/// Human-facing recommendation for a security header.
pub struct HeaderRecommendation {
  header : String
  value : String
  reason : String
  priority : String
} derive(Eq, @debug.Debug)

///|
/// Return the core response security headers that `permscope` can audit.
pub fn security_header_names() -> Array[String] {
  [
    "content-security-policy", "strict-transport-security", "permissions-policy",
    "referrer-policy", "x-frame-options", "x-content-type-options", "cross-origin-opener-policy",
    "cross-origin-embedder-policy", "cross-origin-resource-policy",
  ]
}

///|
/// Return the CSP source directives handled by the simplified CSP auditor.
pub fn csp_source_directive_names() -> Array[String] {
  [
    "default-src", "script-src", "style-src", "img-src", "connect-src", "font-src",
    "object-src", "base-uri", "frame-ancestors", "form-action", "worker-src", "media-src",
    "manifest-src", "child-src", "frame-src",
  ]
}

///|
/// Return the core CSP directives expected for public web applications.
pub fn csp_core_directives() -> Array[String] {
  [
    "default-src", "script-src", "object-src", "base-uri", "frame-ancestors", "form-action",
  ]
}

///|
/// Parse a Content-Security-Policy header.
pub fn parse_csp(header : String) -> CspPolicy {
  let directives : Array[CspDirective] = []
  let warnings : Array[ParseWarning] = []
  let clean = trim(header)
  if clean == "" {
    warnings.push({ index: 0, message: "empty csp header", text: header })
    return { directives, warnings }
  }
  let mut index = 0
  for part_view in clean.split(";") {
    index = index + 1
    let part = trim(part_view.to_owned())
    if part == "" {
      continue
    }
    match parse_csp_directive(part, index, warnings) {
      None => ()
      Some(directive) => {
        if csp_has_directive(directives, directive.name) {
          warnings.push({
            index,
            message: "duplicate csp directive: " + directive.name,
            text: part,
          })
        }
        directives.push(directive)
      }
    }
  }
  { directives, warnings }
}

///|
/// Return a CSP directive by name.
pub fn csp_directive(policy : CspPolicy, name : String) -> CspDirective? {
  let wanted = normalize_csp_name(name)
  for directive in policy.directives {
    if directive.name == wanted {
      return Some(directive)
    }
  }
  None
}

///|
/// Return values for a CSP directive, falling back where CSP semantics expect it.
pub fn csp_effective_values(policy : CspPolicy, name : String) -> Array[String] {
  let wanted = normalize_csp_name(name)
  match csp_directive(policy, wanted) {
    Some(directive) => directive.values
    None =>
      match csp_fallback_directive(wanted) {
        Some(fallback) =>
          match csp_directive(policy, fallback) {
            Some(directive) => directive.values
            None => []
          }
        None => []
      }
  }
}

///|
/// Check whether a CSP directive contains a source expression.
pub fn csp_has_source(
  policy : CspPolicy,
  directive : String,
  source : String,
) -> Bool {
  let wanted = normalize_csp_source(source)
  for value in csp_effective_values(policy, directive) {
    if normalize_csp_source(value) == wanted {
      return true
    }
  }
  false
}

///|
/// Check whether inline scripts are allowed.
pub fn csp_allows_inline_script(policy : CspPolicy) -> Bool {
  csp_has_source(policy, "script-src", "'unsafe-inline'")
}

///|
/// Check whether dynamic code evaluation is allowed.
pub fn csp_allows_eval(policy : CspPolicy) -> Bool {
  csp_has_source(policy, "script-src", "'unsafe-eval'")
}

///|
/// Check whether a directive has a nonce or hash source.
pub fn csp_has_nonce_or_hash(policy : CspPolicy, directive : String) -> Bool {
  for value in csp_effective_values(policy, directive) {
    let normalized = normalize_csp_source(value)
    if csp_source_is_nonce(normalized) || csp_source_is_hash(normalized) {
      return true
    }
  }
  false
}

///|
/// Return fallback chain used by this package for a source directive.
pub fn csp_fallback_chain(name : String) -> Array[String] {
  let result : Array[String] = []
  let normalized = normalize_csp_name(name)
  result.push(normalized)
  match csp_fallback_directive(normalized) {
    Some(fallback) => result.push(fallback)
    None => ()
  }
  result
}

///|
/// Interpret one CSP source expression.
pub fn csp_source_expression(value : String) -> SourceExpression {
  let normalized = normalize_csp_source(value)
  {
    value: normalized,
    kind: csp_source_kind(normalized),
    secure: csp_source_secure(normalized),
    wildcard: csp_source_wildcard(normalized),
  }
}

///|
/// Interpret every source expression in a directive.
pub fn csp_sources(
  policy : CspPolicy,
  directive : String,
) -> Array[SourceExpression] {
  let sources : Array[SourceExpression] = []
  for value in csp_effective_values(policy, directive) {
    sources.push(csp_source_expression(value))
  }
  sources
}

///|
/// Render CSP back to normalized syntax.
pub fn render_csp(policy : CspPolicy) -> String {
  let parts : Array[String] = []
  for directive in policy.directives {
    if directive.values.length() == 0 {
      parts.push(directive.name)
    } else {
      parts.push(directive.name + " " + directive.values.join(" "))
    }
  }
  parts.join("; ")
}

///|
/// Summarize a CSP policy for dashboards and release notes.
pub fn summarize_csp(policy : CspPolicy) -> CspSummary {
  let missing_core : Array[String] = []
  let mut source_directives = 0
  let mut wildcard_sources = 0
  let mut unsafe_inline = 0
  let mut unsafe_eval = 0
  let mut nonce_sources = 0
  let mut hash_sources = 0
  let mut insecure_sources = 0
  for directive in policy.directives {
    if csp_is_source_directive(directive.name) {
      source_directives = source_directives + 1
    }
    for value in directive.values {
      let source = csp_source_expression(value)
      if source.wildcard {
        wildcard_sources = wildcard_sources + 1
      }
      if source.value == "'unsafe-inline'" {
        unsafe_inline = unsafe_inline + 1
      }
      if source.value == "'unsafe-eval'" {
        unsafe_eval = unsafe_eval + 1
      }
      if csp_source_is_nonce(source.value) {
        nonce_sources = nonce_sources + 1
      }
      if csp_source_is_hash(source.value) {
        hash_sources = hash_sources + 1
      }
      if !source.secure {
        insecure_sources = insecure_sources + 1
      }
    }
  }
  for required in csp_core_directives() {
    if csp_directive(policy, required) is None {
      missing_core.push(required)
    }
  }
  {
    directive_count: policy.directives.length(),
    source_directives,
    wildcard_sources,
    unsafe_inline,
    unsafe_eval,
    nonce_sources,
    hash_sources,
    insecure_sources,
    missing_core,
  }
}

///|
/// Render a compact CSP summary.
pub fn render_csp_summary(summary : CspSummary) -> String {
  "csp directives=" +
  summary.directive_count.to_string() +
  " source=" +
  summary.source_directives.to_string() +
  " wildcard=" +
  summary.wildcard_sources.to_string() +
  " unsafe_inline=" +
  summary.unsafe_inline.to_string() +
  " unsafe_eval=" +
  summary.unsafe_eval.to_string() +
  " nonce=" +
  summary.nonce_sources.to_string() +
  " hash=" +
  summary.hash_sources.to_string() +
  " insecure=" +
  summary.insecure_sources.to_string() +
  " missing=" +
  summary.missing_core.join("|")
}

///|
/// Audit a Content-Security-Policy header.
pub fn audit_csp(policy : CspPolicy) -> AuditReport {
  let findings : Array[Finding] = []
  if policy.directives.length() == 0 {
    findings.push({
      severity: SeverityHigh,
      code: "missing-csp",
      feature: Some("content-security-policy"),
      message: "Content-Security-Policy header is missing or empty",
    })
  }
  for warning in policy.warnings {
    findings.push({
      severity: SeverityWarning,
      code: "csp-parse-warning",
      feature: None,
      message: "segment " + warning.index.to_string() + ": " + warning.message,
    })
  }
  audit_csp_required(policy, findings)
  audit_csp_script(policy, findings)
  audit_csp_style(policy, findings)
  audit_csp_objects(policy, findings)
  audit_csp_frames(policy, findings)
  audit_csp_network(policy, findings)
  audit_csp_insecure_sources(policy, findings)
  if findings.length() == 0 {
    findings.push({
      severity: SeverityInfo,
      code: "csp-baseline-satisfied",
      feature: Some("content-security-policy"),
      message: "CSP satisfies the built-in baseline",
    })
  }
  { ok: !has_high(findings), findings }
}

///|
/// Render a CSP audit report with a CSP-specific heading.
pub fn render_csp_audit(report : AuditReport) -> String {
  "permscope csp audit\n" + render_report(report)
}

///|
/// Build a strict starter Content-Security-Policy for static applications.
pub fn strict_csp_header() -> String {
  [
    "default-src 'self'", "script-src 'self'", "style-src 'self'", "img-src 'self' data:",
    "font-src 'self'", "connect-src 'self'", "object-src 'none'", "base-uri 'self'",
    "frame-ancestors 'none'", "form-action 'self'", "upgrade-insecure-requests",
  ].join("; ")
}

///|
/// Build a CSP for an app that needs selected trusted API and media origins.
pub fn app_csp_header(
  api_origins : Array[String],
  media_origins : Array[String],
) -> String {
  let connect_values : Array[String] = ["'self'"]
  for origin in api_origins {
    connect_values.push(normalize_csp_source(origin))
  }
  let media_values : Array[String] = ["'self'"]
  for origin in media_origins {
    media_values.push(normalize_csp_source(origin))
  }
  [
    "default-src 'self'",
    "script-src 'self'",
    "style-src 'self'",
    "img-src 'self' data: " + media_values.join(" "),
    "font-src 'self'",
    "connect-src " + connect_values.join(" "),
    "object-src 'none'",
    "base-uri 'self'",
    "frame-ancestors 'none'",
    "form-action 'self'",
  ].join("; ")
}

///|
/// Audit a raw HTTP response header block for multiple security headers.
pub fn audit_security_header_block(
  block : String,
  document_origin : String,
) -> SecurityAudit {
  let headers = parse_header_block(block)
  audit_security_headers(headers, document_origin)
}

///|
/// Audit parsed HTTP response headers for multiple security headers.
pub fn audit_security_headers(
  headers : HeaderSet,
  document_origin : String,
) -> SecurityAudit {
  let csp_value = header_value(headers, "content-security-policy")
  let csp_policy = match csp_value {
    Some(value) => Some(parse_csp(value))
    None => None
  }
  let permissions = audit_header_set_permissions(
    headers,
    catalog_baseline(document_origin),
  )
  let checks : Array[HeaderCheck] = []
  checks.push(check_content_security_policy(csp_policy))
  checks.push(check_strict_transport_security(headers))
  checks.push(check_permissions_policy(permissions))
  checks.push(check_referrer_policy(headers))
  checks.push(check_x_frame_options(headers))
  checks.push(check_x_content_type_options(headers))
  checks.push(check_cross_origin_opener_policy(headers))
  checks.push(check_cross_origin_embedder_policy(headers))
  checks.push(check_cross_origin_resource_policy(headers))
  let score = score_security_checks(checks)
  { headers, csp: csp_policy, permissions, checks, score }
}

///|
/// Return a check by header name.
pub fn security_check(audit : SecurityAudit, header : String) -> HeaderCheck? {
  let wanted = normalize_header_lookup(header)
  for check in audit.checks {
    if normalize_header_lookup(check.header) == wanted {
      return Some(check)
    }
  }
  None
}

///|
/// Render one header check.
pub fn render_header_check(check : HeaderCheck) -> String {
  let lines : Array[String] = []
  let present = if check.present { "present" } else { "missing" }
  lines.push(
    check.header +
    " " +
    present +
    " score=" +
    check.score.to_string() +
    "/" +
    check.max_score.to_string(),
  )
  if check.observed != "" {
    lines.push("observed=" + check.observed)
  }
  if check.recommended != "" {
    lines.push("recommended=" + check.recommended)
  }
  for finding in check.findings {
    lines.push(render_finding_line(finding))
  }
  lines.join("\n")
}

///|
/// Render a full security-header audit as plain text.
pub fn render_security_audit(audit : SecurityAudit) -> String {
  let lines : Array[String] = []
  lines.push("permscope security audit")
  lines.push(render_security_score(audit.score))
  for check in audit.checks {
    lines.push(render_header_check(check))
  }
  lines.join("\n")
}

///|
/// Render an audit score.
pub fn render_security_score(score : SecurityScore) -> String {
  "score " +
  score.points.to_string() +
  "/" +
  score.max_points.to_string() +
  " percent=" +
  score.percent.to_string() +
  " grade=" +
  score.grade +
  " high=" +
  score.high.to_string() +
  " warning=" +
  score.warning.to_string() +
  " info=" +
  score.info.to_string()
}

///|
/// Render a Markdown report for issue comments or release notes.
pub fn render_security_markdown(audit : SecurityAudit) -> String {
  let lines : Array[String] = []
  lines.push("# permscope security audit")
  lines.push("")
  lines.push(
    "- Score: " +
    audit.score.points.to_string() +
    "/" +
    audit.score.max_points.to_string(),
  )
  lines.push("- Percent: " + audit.score.percent.to_string())
  lines.push("- Grade: " + audit.score.grade)
  lines.push("- High findings: " + audit.score.high.to_string())
  lines.push("- Warning findings: " + audit.score.warning.to_string())
  lines.push("")
  lines.push("| Header | Present | Score | Findings |")
  lines.push("| --- | --- | ---: | ---: |")
  for check in audit.checks {
    lines.push(
      "| " +
      check.header +
      " | " +
      bool_word(check.present) +
      " | " +
      check.score.to_string() +
      "/" +
      check.max_score.to_string() +
      " | " +
      check.findings.length().to_string() +
      " |",
    )
  }
  lines.push("")
  for check in audit.checks {
    lines.push("## " + check.header)
    lines.push("")
    lines.push("- Observed: `" + markdown_inline(check.observed) + "`")
    lines.push("- Recommended: `" + markdown_inline(check.recommended) + "`")
    for finding in check.findings {
      lines.push(
        "- " +
        severity_label(finding.severity) +
        ": " +
        finding.code +
        " - " +
        finding.message,
      )
    }
    lines.push("")
  }
  lines.join("\n")
}

///|
/// Render a deterministic JSON-like text report without external dependencies.
pub fn render_security_json(audit : SecurityAudit) -> String {
  let lines : Array[String] = []
  lines.push("{")
  lines.push("  \"score\": {")
  lines.push("    \"points\": " + audit.score.points.to_string() + ",")
  lines.push("    \"max_points\": " + audit.score.max_points.to_string() + ",")
  lines.push("    \"percent\": " + audit.score.percent.to_string() + ",")
  lines.push("    \"grade\": \"" + audit.score.grade + "\",")
  lines.push("    \"high\": " + audit.score.high.to_string() + ",")
  lines.push("    \"warning\": " + audit.score.warning.to_string() + ",")
  lines.push("    \"info\": " + audit.score.info.to_string())
  lines.push("  },")
  lines.push("  \"checks\": [")
  let mut index = 0
  for check in audit.checks {
    index = index + 1
    lines.push("    {")
    lines.push("      \"header\": \"" + json_escape(check.header) + "\",")
    lines.push("      \"present\": " + check.present.to_string() + ",")
    lines.push("      \"score\": " + check.score.to_string() + ",")
    lines.push("      \"max_score\": " + check.max_score.to_string() + ",")
    lines.push("      \"observed\": \"" + json_escape(check.observed) + "\",")
    lines.push(
      "      \"recommended\": \"" + json_escape(check.recommended) + "\",",
    )
    lines.push("      \"findings\": " + check.findings.length().to_string())
    if index == audit.checks.length() {
      lines.push("    }")
    } else {
      lines.push("    },")
    }
  }
  lines.push("  ]")
  lines.push("}")
  lines.join("\n")
}

///|
/// Summarize findings across every header check.
pub fn summarize_security_findings(
  audit : SecurityAudit,
) -> SecurityFindingSummary {
  let high_codes : Array[String] = []
  let warning_codes : Array[String] = []
  let info_codes : Array[String] = []
  let mut total = 0
  let mut high = 0
  let mut warning = 0
  let mut info = 0
  for check in audit.checks {
    for finding in check.findings {
      total = total + 1
      match finding.severity {
        SeverityHigh => {
          high = high + 1
          push_unique(high_codes, finding.code)
        }
        SeverityWarning => {
          warning = warning + 1
          push_unique(warning_codes, finding.code)
        }
        SeverityInfo => {
          info = info + 1
          push_unique(info_codes, finding.code)
        }
      }
    }
  }
  { total, high, warning, info, high_codes, warning_codes, info_codes }
}

///|
/// Return findings that match a severity label.
pub fn security_findings_by_severity(
  audit : SecurityAudit,
  severity : String,
) -> Array[Finding] {
  let findings : Array[Finding] = []
  let wanted = severity.to_lower()
  for check in audit.checks {
    for finding in check.findings {
      if severity_label(finding.severity) == wanted {
        findings.push(finding)
      }
    }
  }
  findings
}

///|
/// Return unique finding codes across the audit.
pub fn security_finding_codes(audit : SecurityAudit) -> Array[String] {
  let codes : Array[String] = []
  for check in audit.checks {
    for finding in check.findings {
      push_unique(codes, finding.code)
    }
  }
  codes
}

///|
/// Render finding summary as stable line-oriented text.
pub fn render_security_finding_summary(
  summary : SecurityFindingSummary,
) -> String {
  "findings total=" +
  summary.total.to_string() +
  " high=" +
  summary.high.to_string() +
  " warning=" +
  summary.warning.to_string() +
  " info=" +
  summary.info.to_string() +
  " high_codes=" +
  summary.high_codes.join("|") +
  " warning_codes=" +
  summary.warning_codes.join("|") +
  " info_codes=" +
  summary.info_codes.join("|")
}

///|
/// Render a compact table-like check list for CI logs.
pub fn render_security_check_table(audit : SecurityAudit) -> String {
  let lines : Array[String] = []
  lines.push("header present score severity")
  for check in audit.checks {
    lines.push(
      check.header +
      " " +
      bool_word(check.present) +
      " " +
      check.score.to_string() +
      "/" +
      check.max_score.to_string() +
      " " +
      header_check_severity(check),
    )
  }
  lines.join("\n")
}

///|
/// Return fix recommendations for missing or weak headers.
pub fn security_recommendations(
  audit : SecurityAudit,
) -> Array[HeaderRecommendation] {
  let recommendations : Array[HeaderRecommendation] = []
  for check in audit.checks {
    if check.score < check.max_score {
      recommendations.push({
        header: check.header,
        value: check.recommended,
        reason: recommendation_reason(check),
        priority: recommendation_priority(check),
      })
    }
  }
  recommendations
}

///|
/// Render fix recommendations as line-oriented text.
pub fn render_security_recommendations(
  recommendations : Array[HeaderRecommendation],
) -> String {
  if recommendations.length() == 0 {
    return "permscope recommendations: no changes"
  }
  let lines : Array[String] = ["permscope recommendations:"]
  for item in recommendations {
    lines.push(
      item.priority +
      " " +
      item.header +
      " -> " +
      item.value +
      " - " +
      item.reason,
    )
  }
  lines.join("\n")
}

///|
/// Return the highest severity in a check.
pub fn header_check_severity(check : HeaderCheck) -> String {
  let mut high = false
  let mut warning = false
  for finding in check.findings {
    match finding.severity {
      SeverityHigh => high = true
      SeverityWarning => warning = true
      SeverityInfo => ()
    }
  }
  if high {
    "high"
  } else if warning {
    "warning"
  } else {
    "info"
  }
}

///|
/// Return whether an audit meets a minimum grade.
pub fn security_grade_at_least(audit : SecurityAudit, grade : String) -> Bool {
  grade_rank(audit.score.grade) <= grade_rank(grade)
}

///|
/// Return whether an audit has no high-severity findings.
pub fn security_has_no_high(audit : SecurityAudit) -> Bool {
  audit.score.high == 0
}

///|
/// Return a stable list of default secure header recommendations.
pub fn default_security_recommendations() -> Array[HeaderRecommendation] {
  [
    {
      header: "Content-Security-Policy",
      value: strict_csp_header(),
      reason: "limits script, object, frame, form, and network capabilities",
      priority: "high",
    },
    {
      header: "Strict-Transport-Security",
      value: "max-age=31536000; includeSubDomains; preload",
      reason: "keeps browsers on HTTPS and enables preload readiness",
      priority: "high",
    },
    {
      header: "Permissions-Policy",
      value: recommended_header(),
      reason: "disables sensitive browser capabilities by default",
      priority: "high",
    },
    {
      header: "Referrer-Policy",
      value: "strict-origin-when-cross-origin",
      reason: "reduces cross-site URL leakage while preserving useful origins",
      priority: "warning",
    },
    {
      header: "X-Frame-Options",
      value: "DENY",
      reason: "blocks legacy clickjacking vectors",
      priority: "warning",
    },
    {
      header: "X-Content-Type-Options",
      value: "nosniff",
      reason: "prevents MIME type sniffing",
      priority: "warning",
    },
    {
      header: "Cross-Origin-Opener-Policy",
      value: "same-origin",
      reason: "isolates the browsing context group",
      priority: "warning",
    },
    {
      header: "Cross-Origin-Embedder-Policy",
      value: "require-corp",
      reason: "requires explicit opt-in for cross-origin embeds",
      priority: "warning",
    },
    {
      header: "Cross-Origin-Resource-Policy",
      value: "same-origin",
      reason: "limits who can load the response as a subresource",
      priority: "warning",
    },
  ]
}

///|
fn parse_csp_directive(
  part : String,
  index : Int,
  warnings : Array[ParseWarning],
) -> CspDirective? {
  let tokens = split_words(part)
  if tokens.length() == 0 {
    warnings.push({ index, message: "empty csp directive", text: part })
    return None
  }
  let name = normalize_csp_name(tokens[0])
  if !csp_name_ok(name) {
    warnings.push({
      index,
      message: "csp directive name contains unusual characters",
      text: part,
    })
  }
  let values : Array[String] = []
  let mut i = 1
  while i < tokens.length() {
    values.push(normalize_csp_source(tokens[i]))
    i = i + 1
  }
  Some({ name, values, raw: part, index })
}

///|
fn audit_csp_required(policy : CspPolicy, findings : Array[Finding]) -> Unit {
  for required in csp_core_directives() {
    if csp_directive(policy, required) is None {
      findings.push({
        severity: csp_required_severity(required),
        code: "csp-missing-directive",
        feature: Some(required),
        message: "recommended CSP directive is missing",
      })
    }
  }
}

///|
fn audit_csp_script(policy : CspPolicy, findings : Array[Finding]) -> Unit {
  if csp_allows_inline_script(policy) &&
    !csp_has_nonce_or_hash(policy, "script-src") {
    findings.push({
      severity: SeverityHigh,
      code: "csp-unsafe-inline-script",
      feature: Some("script-src"),
      message: "script-src allows unsafe-inline without a nonce or hash",
    })
  }
  if csp_allows_eval(policy) {
    findings.push({
      severity: SeverityHigh,
      code: "csp-unsafe-eval",
      feature: Some("script-src"),
      message: "script-src allows unsafe-eval",
    })
  }
  if csp_has_source(policy, "script-src", "*") {
    findings.push({
      severity: SeverityHigh,
      code: "csp-script-wildcard",
      feature: Some("script-src"),
      message: "script-src allows every origin",
    })
  }
  if csp_has_source(policy, "script-src", "http:") {
    findings.push({
      severity: SeverityHigh,
      code: "csp-script-http",
      feature: Some("script-src"),
      message: "script-src allows insecure HTTP scheme",
    })
  }
}

///|
fn audit_csp_style(policy : CspPolicy, findings : Array[Finding]) -> Unit {
  if csp_has_source(policy, "style-src", "'unsafe-inline'") &&
    !csp_has_nonce_or_hash(policy, "style-src") {
    findings.push({
      severity: SeverityWarning,
      code: "csp-unsafe-inline-style",
      feature: Some("style-src"),
      message: "style-src allows unsafe-inline without a nonce or hash",
    })
  }
  if csp_has_source(policy, "style-src", "*") {
    findings.push({
      severity: SeverityWarning,
      code: "csp-style-wildcard",
      feature: Some("style-src"),
      message: "style-src allows every origin",
    })
  }
}

///|
fn audit_csp_objects(policy : CspPolicy, findings : Array[Finding]) -> Unit {
  if !csp_has_source(policy, "object-src", "'none'") {
    findings.push({
      severity: SeverityHigh,
      code: "csp-object-src-not-none",
      feature: Some("object-src"),
      message: "object-src should be 'none'",
    })
  }
  if csp_has_source(policy, "base-uri", "*") ||
    csp_directive(policy, "base-uri") is None {
    findings.push({
      severity: SeverityWarning,
      code: "csp-base-uri-weak",
      feature: Some("base-uri"),
      message: "base-uri should usually be 'self' or 'none'",
    })
  }
}

///|
fn audit_csp_frames(policy : CspPolicy, findings : Array[Finding]) -> Unit {
  if csp_directive(policy, "frame-ancestors") is None {
    findings.push({
      severity: SeverityHigh,
      code: "csp-frame-ancestors-missing",
      feature: Some("frame-ancestors"),
      message: "frame-ancestors is missing, leaving clickjacking protection to legacy headers",
    })
  } else if csp_has_source(policy, "frame-ancestors", "*") {
    findings.push({
      severity: SeverityHigh,
      code: "csp-frame-ancestors-wildcard",
      feature: Some("frame-ancestors"),
      message: "frame-ancestors allows every origin",
    })
  }
  if csp_directive(policy, "form-action") is None {
    findings.push({
      severity: SeverityWarning,
      code: "csp-form-action-missing",
      feature: Some("form-action"),
      message: "form-action is missing",
    })
  }
}

///|
fn audit_csp_network(policy : CspPolicy, findings : Array[Finding]) -> Unit {
  if csp_has_source(policy, "connect-src", "*") {
    findings.push({
      severity: SeverityWarning,
      code: "csp-connect-wildcard",
      feature: Some("connect-src"),
      message: "connect-src allows every origin",
    })
  }
  if csp_has_source(policy, "img-src", "*") {
    findings.push({
      severity: SeverityWarning,
      code: "csp-img-wildcard",
      feature: Some("img-src"),
      message: "img-src allows every origin",
    })
  }
}

///|
fn audit_csp_insecure_sources(
  policy : CspPolicy,
  findings : Array[Finding],
) -> Unit {
  for directive in policy.directives {
    for value in directive.values {
      let source = csp_source_expression(value)
      if !source.secure {
        findings.push({
          severity: SeverityWarning,
          code: "csp-insecure-source",
          feature: Some(directive.name),
          message: "source expression is not HTTPS, self, nonce, hash, data, or none: " +
          source.value,
        })
      }
    }
  }
}

///|
fn check_content_security_policy(csp : CspPolicy?) -> HeaderCheck {
  let recommended = strict_csp_header()
  match csp {
    None => {
      let findings : Array[Finding] = [
        {
          severity: SeverityHigh,
          code: "missing-content-security-policy",
          feature: Some("content-security-policy"),
          message: "Content-Security-Policy is missing",
        },
      ]
      {
        header: "Content-Security-Policy",
        present: false,
        score: 0,
        max_score: 20,
        findings,
        observed: "",
        recommended,
      }
    }
    Some(policy) => {
      let report = audit_csp(policy)
      {
        header: "Content-Security-Policy",
        present: true,
        score: score_from_report(report, 20),
        max_score: 20,
        findings: report.findings,
        observed: render_csp(policy),
        recommended,
      }
    }
  }
}

///|
fn check_strict_transport_security(headers : HeaderSet) -> HeaderCheck {
  let value = header_value(headers, "strict-transport-security").unwrap_or("")
  let findings : Array[Finding] = []
  let recommended = "max-age=31536000; includeSubDomains; preload"
  let mut score = 0
  if value == "" {
    findings.push({
      severity: SeverityHigh,
      code: "missing-hsts",
      feature: Some("strict-transport-security"),
      message: "Strict-Transport-Security is missing",
    })
  } else {
    let normalized = value.to_lower()
    let max_age = header_parameter(normalized, "max-age")
    match max_age {
      None =>
        findings.push({
          severity: SeverityHigh,
          code: "hsts-missing-max-age",
          feature: Some("strict-transport-security"),
          message: "HSTS does not declare max-age",
        })
      Some(age) => {
        let seconds = parse_decimal(age)
        if seconds >= 31536000 {
          score = score + 7
        } else if seconds >= 10886400 {
          score = score + 4
          findings.push({
            severity: SeverityWarning,
            code: "hsts-short-max-age",
            feature: Some("strict-transport-security"),
            message: "HSTS max-age is shorter than one year",
          })
        } else {
          findings.push({
            severity: SeverityHigh,
            code: "hsts-too-short",
            feature: Some("strict-transport-security"),
            message: "HSTS max-age is too short for production",
          })
        }
      }
    }
    if normalized.contains("includesubdomains") {
      score = score + 2
    } else {
      findings.push({
        severity: SeverityWarning,
        code: "hsts-no-subdomains",
        feature: Some("strict-transport-security"),
        message: "HSTS does not include subdomains",
      })
    }
    if normalized.contains("preload") {
      score = score + 1
    }
  }
  {
    header: "Strict-Transport-Security",
    present: value != "",
    score,
    max_score: 10,
    findings,
    observed: value,
    recommended,
  }
}

///|
fn check_permissions_policy(audit : HeaderAudit) -> HeaderCheck {
  let value = render(audit.effective)
  {
    header: "Permissions-Policy",
    present: audit.effective.directives.length() > 0,
    score: score_from_report(audit.report, 15),
    max_score: 15,
    findings: audit.report.findings,
    observed: value,
    recommended: recommended_header(),
  }
}

///|
fn check_referrer_policy(headers : HeaderSet) -> HeaderCheck {
  let value = header_value(headers, "referrer-policy").unwrap_or("")
  let normalized = value.to_lower()
  let findings : Array[Finding] = []
  let mut score = 0
  if value == "" {
    findings.push({
      severity: SeverityWarning,
      code: "missing-referrer-policy",
      feature: Some("referrer-policy"),
      message: "Referrer-Policy is missing",
    })
  } else if referrer_policy_strong(normalized) {
    score = 8
  } else if referrer_policy_acceptable(normalized) {
    score = 5
    findings.push({
      severity: SeverityInfo,
      code: "referrer-policy-acceptable",
      feature: Some("referrer-policy"),
      message: "Referrer-Policy is usable but not the strictest choice",
    })
  } else {
    findings.push({
      severity: SeverityWarning,
      code: "referrer-policy-weak",
      feature: Some("referrer-policy"),
      message: "Referrer-Policy may leak more URL data than necessary",
    })
  }
  {
    header: "Referrer-Policy",
    present: value != "",
    score,
    max_score: 8,
    findings,
    observed: value,
    recommended: "strict-origin-when-cross-origin",
  }
}

///|
fn check_x_frame_options(headers : HeaderSet) -> HeaderCheck {
  let value = header_value(headers, "x-frame-options").unwrap_or("")
  let normalized = value.to_lower()
  let findings : Array[Finding] = []
  let mut score = 0
  if value == "" {
    findings.push({
      severity: SeverityWarning,
      code: "missing-x-frame-options",
      feature: Some("x-frame-options"),
      message: "X-Frame-Options is missing for legacy browser protection",
    })
  } else if normalized == "deny" || normalized == "sameorigin" {
    score = 6
  } else {
    findings.push({
      severity: SeverityWarning,
      code: "x-frame-options-weak",
      feature: Some("x-frame-options"),
      message: "X-Frame-Options should be DENY or SAMEORIGIN",
    })
  }
  {
    header: "X-Frame-Options",
    present: value != "",
    score,
    max_score: 6,
    findings,
    observed: value,
    recommended: "DENY",
  }
}

///|
fn check_x_content_type_options(headers : HeaderSet) -> HeaderCheck {
  let value = header_value(headers, "x-content-type-options").unwrap_or("")
  let findings : Array[Finding] = []
  let mut score = 0
  if value.to_lower() == "nosniff" {
    score = 6
  } else if value == "" {
    findings.push({
      severity: SeverityWarning,
      code: "missing-x-content-type-options",
      feature: Some("x-content-type-options"),
      message: "X-Content-Type-Options is missing",
    })
  } else {
    findings.push({
      severity: SeverityWarning,
      code: "x-content-type-options-invalid",
      feature: Some("x-content-type-options"),
      message: "X-Content-Type-Options should be nosniff",
    })
  }
  {
    header: "X-Content-Type-Options",
    present: value != "",
    score,
    max_score: 6,
    findings,
    observed: value,
    recommended: "nosniff",
  }
}

///|
fn check_cross_origin_opener_policy(headers : HeaderSet) -> HeaderCheck {
  check_token_header(
    headers,
    "Cross-Origin-Opener-Policy",
    "same-origin",
    ["same-origin"],
    5,
    "missing-coop",
    "COOP is missing",
  )
}

///|
fn check_cross_origin_embedder_policy(headers : HeaderSet) -> HeaderCheck {
  check_token_header(
    headers,
    "Cross-Origin-Embedder-Policy",
    "require-corp",
    ["require-corp", "credentialless"],
    5,
    "missing-coep",
    "COEP is missing",
  )
}

///|
fn check_cross_origin_resource_policy(headers : HeaderSet) -> HeaderCheck {
  check_token_header(
    headers,
    "Cross-Origin-Resource-Policy",
    "same-origin",
    ["same-origin", "same-site"],
    5,
    "missing-corp",
    "CORP is missing",
  )
}

///|
fn check_token_header(
  headers : HeaderSet,
  display_name : String,
  recommended : String,
  accepted : Array[String],
  max_score : Int,
  missing_code : String,
  missing_message : String,
) -> HeaderCheck {
  let value = header_value(headers, display_name).unwrap_or("")
  let normalized = value.to_lower()
  let findings : Array[Finding] = []
  let mut score = 0
  if value == "" {
    findings.push({
      severity: SeverityWarning,
      code: missing_code,
      feature: Some(display_name.to_lower()),
      message: missing_message,
    })
  } else if string_in(accepted, normalized) {
    score = max_score
  } else {
    findings.push({
      severity: SeverityWarning,
      code: "weak-" + display_name.to_lower(),
      feature: Some(display_name.to_lower()),
      message: display_name + " has a weak or unknown value",
    })
  }
  {
    header: display_name,
    present: value != "",
    score,
    max_score,
    findings,
    observed: value,
    recommended,
  }
}

///|
fn audit_header_set_permissions(
  headers : HeaderSet,
  baseline : Baseline,
) -> HeaderAudit {
  let modern_value = permissions_policy_header(headers)
  let legacy_value = feature_policy_header(headers)
  let modern = match modern_value {
    Some(value) => Some(parse(value))
    None => None
  }
  let legacy = match legacy_value {
    Some(value) => Some(parse_feature_policy(value))
    None => None
  }
  let effective = policy_from_headers(headers)
  let report = audit(effective, baseline)
  let migration = match legacy_value {
    Some(value) => migrate_feature_policy(value)
    None => ""
  }
  {
    headers,
    modern,
    legacy,
    effective,
    report,
    used_legacy: modern_value is None && legacy_value is Some(_),
    migration,
  }
}

///|
fn score_security_checks(checks : Array[HeaderCheck]) -> SecurityScore {
  let mut points = 0
  let mut max_points = 0
  let mut high = 0
  let mut warning = 0
  let mut info = 0
  for check in checks {
    points = points + check.score
    max_points = max_points + check.max_score
    for finding in check.findings {
      match finding.severity {
        SeverityHigh => high = high + 1
        SeverityWarning => warning = warning + 1
        SeverityInfo => info = info + 1
      }
    }
  }
  let percent = if max_points == 0 { 0 } else { points * 100 / max_points }
  {
    points,
    max_points,
    percent,
    grade: grade_for(percent, high),
    high,
    warning,
    info,
  }
}

///|
fn score_from_report(report : AuditReport, max_score : Int) -> Int {
  let mut score = max_score
  for finding in report.findings {
    match finding.severity {
      SeverityHigh => score = score - 6
      SeverityWarning => score = score - 2
      SeverityInfo => ()
    }
  }
  if score < 0 {
    0
  } else {
    score
  }
}

///|
fn grade_for(percent : Int, high : Int) -> String {
  if high > 0 && percent < 80 {
    return "D"
  }
  if percent >= 90 {
    "A"
  } else if percent >= 80 {
    "B"
  } else if percent >= 70 {
    "C"
  } else if percent >= 55 {
    "D"
  } else {
    "F"
  }
}

///|
fn grade_rank(grade : String) -> Int {
  match grade.to_upper() {
    "A" => 1
    "B" => 2
    "C" => 3
    "D" => 4
    _ => 5
  }
}

///|
fn header_value(headers : HeaderSet, name : String) -> String? {
  let wanted = normalize_header_lookup(name)
  for line in headers.lines {
    if normalize_header_lookup(line.name) == wanted {
      return Some(line.value)
    }
  }
  None
}

///|
pub fn header_values(headers : HeaderSet, name : String) -> Array[String] {
  let values : Array[String] = []
  let wanted = normalize_header_lookup(name)
  for line in headers.lines {
    if normalize_header_lookup(line.name) == wanted {
      values.push(line.value)
    }
  }
  values
}

///|
fn header_parameter(value : String, name : String) -> String? {
  let wanted = name.to_lower()
  for part_view in value.split(";") {
    let part = trim(part_view.to_owned()).to_lower()
    match part.find("=") {
      None => ()
      Some(eq) => {
        let key = trim(part[:eq].to_owned())
        if key == wanted {
          return Some(trim(part[eq + 1:].to_owned()))
        }
      }
    }
  }
  None
}

///|
fn parse_decimal(value : String) -> Int {
  let mut result = 0
  for ch in trim(value) {
    let digit = decimal_digit(ch)
    if digit < 0 {
      return result
    }
    result = result * 10 + digit
  }
  result
}

///|
fn decimal_digit(ch : Char) -> Int {
  match ch {
    '0' => 0
    '1' => 1
    '2' => 2
    '3' => 3
    '4' => 4
    '5' => 5
    '6' => 6
    '7' => 7
    '8' => 8
    '9' => 9
    _ => -1
  }
}

///|
fn split_words(value : String) -> Array[String] {
  let result : Array[String] = []
  for part_view in value.split(" ") {
    let part = trim(part_view.to_owned())
    if part != "" {
      result.push(part)
    }
  }
  result
}

///|
fn csp_has_directive(directives : Array[CspDirective], name : String) -> Bool {
  for directive in directives {
    if directive.name == name {
      return true
    }
  }
  false
}

///|
fn csp_fallback_directive(name : String) -> String? {
  match normalize_csp_name(name) {
    "script-src"
    | "style-src"
    | "img-src"
    | "connect-src"
    | "font-src"
    | "object-src"
    | "media-src"
    | "manifest-src"
    | "worker-src"
    | "frame-src"
    | "child-src" => Some("default-src")
    _ => None
  }
}

///|
fn csp_is_source_directive(name : String) -> Bool {
  string_in(csp_source_directive_names(), normalize_csp_name(name))
}

///|
fn csp_required_severity(name : String) -> Severity {
  match normalize_csp_name(name) {
    "default-src" | "script-src" | "object-src" | "frame-ancestors" =>
      SeverityHigh
    _ => SeverityWarning
  }
}

///|
fn csp_source_kind(value : String) -> String {
  let normalized = normalize_csp_source(value)
  if normalized == "*" {
    "wildcard"
  } else if normalized == "'self'" ||
    normalized == "'none'" ||
    normalized == "'unsafe-inline'" ||
    normalized == "'unsafe-eval'" {
    "keyword"
  } else if csp_source_is_nonce(normalized) {
    "nonce"
  } else if csp_source_is_hash(normalized) {
    "hash"
  } else if normalized.has_suffix(":") {
    "scheme"
  } else if normalized.has_prefix("https://") ||
    normalized.has_prefix("http://") {
    "origin"
  } else if normalized == "data:" ||
    normalized == "blob:" ||
    normalized == "filesystem:" {
    "scheme"
  } else {
    "host"
  }
}

///|
fn csp_source_secure(value : String) -> Bool {
  let normalized = normalize_csp_source(value)
  normalized == "'self'" ||
  normalized == "'none'" ||
  csp_source_is_nonce(normalized) ||
  csp_source_is_hash(normalized) ||
  normalized.has_prefix("https://") ||
  normalized == "https:" ||
  normalized == "data:" ||
  normalized == "blob:" ||
  normalized == "'strict-dynamic'"
}

///|
fn csp_source_wildcard(value : String) -> Bool {
  let normalized = normalize_csp_source(value)
  normalized == "*" ||
  normalized.has_prefix("*.") ||
  normalized.contains("://*.") ||
  normalized == "http:" ||
  normalized == "https:"
}

///|
fn csp_name_ok(name : String) -> Bool {
  if name == "" {
    return false
  }
  for ch in name {
    if ch == '-' || ch.is_ascii_digit() {
      continue
    }
    let piece = ch.to_string()
    if piece < "a" || piece > "z" {
      return false
    }
  }
  true
}

///|
fn normalize_csp_name(value : String) -> String {
  strip_quotes(trim(value)).to_lower()
}

///|
fn normalize_csp_source(value : String) -> String {
  let clean = strip_quotes(trim(value)).to_lower()
  if clean == "self" {
    "'self'"
  } else if clean == "none" {
    "'none'"
  } else if clean == "unsafe-inline" {
    "'unsafe-inline'"
  } else if clean == "unsafe-eval" {
    "'unsafe-eval'"
  } else if clean.has_suffix("/") && clean.length() > 1 {
    clean[:clean.length() - 1].to_owned()
  } else {
    clean
  }
}

///|
fn csp_source_is_nonce(value : String) -> Bool {
  let normalized = normalize_csp_source(value)
  normalized.has_prefix("nonce-") || normalized.has_prefix("'nonce-")
}

///|
fn csp_source_is_hash(value : String) -> Bool {
  let normalized = normalize_csp_source(value)
  normalized.has_prefix("sha256-") ||
  normalized.has_prefix("sha384-") ||
  normalized.has_prefix("sha512-") ||
  normalized.has_prefix("'sha256-") ||
  normalized.has_prefix("'sha384-") ||
  normalized.has_prefix("'sha512-")
}

///|
fn referrer_policy_strong(value : String) -> Bool {
  value == "no-referrer" ||
  value == "strict-origin" ||
  value == "strict-origin-when-cross-origin"
}

///|
fn referrer_policy_acceptable(value : String) -> Bool {
  value == "same-origin" ||
  value == "origin" ||
  value == "origin-when-cross-origin"
}

///|
fn render_finding_line(finding : Finding) -> String {
  let feature = match finding.feature {
    None => "-"
    Some(value) => value
  }
  severity_label(finding.severity) +
  " " +
  finding.code +
  " " +
  feature +
  " - " +
  finding.message
}

///|
fn recommendation_reason(check : HeaderCheck) -> String {
  if check.findings.length() == 0 {
    "score can be improved"
  } else {
    check.findings[0].message
  }
}

///|
fn recommendation_priority(check : HeaderCheck) -> String {
  match header_check_severity(check) {
    "high" => "high"
    "warning" => "warning"
    _ => "info"
  }
}

///|
fn normalize_header_lookup(name : String) -> String {
  trim(name).to_lower()
}

///|
fn string_in(values : Array[String], wanted : String) -> Bool {
  for value in values {
    if value == wanted {
      return true
    }
  }
  false
}

///|
fn push_unique(values : Array[String], value : String) -> Unit {
  if !string_in(values, value) {
    values.push(value)
  }
}

///|
fn bool_word(value : Bool) -> String {
  if value {
    "yes"
  } else {
    "no"
  }
}

///|
fn markdown_inline(value : String) -> String {
  if value == "" {
    "-"
  } else {
    value
  }
}

///|
fn json_escape(value : String) -> String {
  let parts : Array[String] = []
  for ch in value {
    match ch {
      '"' => parts.push("\\\"")
      '\\' => parts.push("\\\\")
      '\n' => parts.push("\\n")
      '\r' => parts.push("\\r")
      '\t' => parts.push("\\t")
      _ => parts.push(ch.to_string())
    }
  }
  parts.join("")
}