///|
/// Severity of a security or forensic audit finding.
pub(all) enum AuditSeverity {
  Info
  Low
  Medium
  High
} derive(Debug, Eq)

///|
/// Machine-stable finding categories. These codes are part of the public API.
pub(all) enum AuditCode {
  DuplicateSingletonHeader
  ConflictingSingletonHeader
  UnsafeAttachmentFilename
  ExecutableAttachmentName
  NestedBoundaryReuse
  OpaqueEmbeddedMessage
  ParserRecovery
  NonCanonicalLineEnding
} derive(Debug, Eq)

///|
/// One evidence-backed observation tied to an entity and source byte range.
pub(all) struct AuditFinding {
  code : AuditCode
  severity : AuditSeverity
  message : String
  entity_path : Array[Int]
  evidence_range : @model.ByteRange
} derive(Debug, Eq)

///|
/// Deterministic audit result suitable for CI policy decisions.
pub(all) struct AuditReport {
  findings : Array[AuditFinding]
  risk_score : Int
  high : Int
  medium : Int
  low : Int
  info : Int
} derive(Debug, Eq)

///|
/// Audit a parsed message without decoding bodies or writing attachments.
/// The checks target parser-differential and unsafe-extraction primitives;
/// they do not claim to classify spam or malware.
pub fn audit_message(message : @model.InternetMessage) -> AuditReport {
  let findings : Array[AuditFinding] = []
  audit_entity(message.root, [], findings)
  for diagnostic in message.diagnostics {
    match diagnostic.code {
      @model.BareLineFeed | @model.BareCarriageReturn =>
        findings.push({
          code: NonCanonicalLineEnding,
          severity: Medium,
          message: "non-canonical line ending can change parser boundaries",
          entity_path: diagnostic.entity_path.copy(),
          evidence_range: diagnostic.range,
        })
      _ =>
        findings.push({
          code: ParserRecovery,
          severity: Low,
          message: "compatible parsing recovered non-canonical syntax: " +
          diagnostic.code.to_code(),
          entity_path: diagnostic.entity_path.copy(),
          evidence_range: diagnostic.range,
        })
    }
  }
  summarize(findings)
}

///|
pub fn AuditReport::has_at_least(
  self : AuditReport,
  threshold : AuditSeverity,
) -> Bool {
  match threshold {
    High => self.high > 0
    Medium => self.high + self.medium > 0
    Low => self.high + self.medium + self.low > 0
    Info => !self.findings.is_empty()
  }
}

///|
pub fn AuditSeverity::to_string(self : AuditSeverity) -> String {
  match self {
    Info => "info"
    Low => "low"
    Medium => "medium"
    High => "high"
  }
}

///|
pub fn AuditCode::to_code(self : AuditCode) -> String {
  match self {
    DuplicateSingletonHeader => "duplicate-singleton-header"
    ConflictingSingletonHeader => "conflicting-singleton-header"
    UnsafeAttachmentFilename => "unsafe-attachment-filename"
    ExecutableAttachmentName => "executable-attachment-name"
    NestedBoundaryReuse => "nested-boundary-reuse"
    OpaqueEmbeddedMessage => "opaque-embedded-message"
    ParserRecovery => "parser-recovery"
    NonCanonicalLineEnding => "non-canonical-line-ending"
  }
}

///|
fn audit_entity(
  entity : @model.MimeEntity,
  ancestor_boundaries : Array[String],
  findings : Array[AuditFinding],
) -> Unit {
  for name in singleton_names() {
    audit_singleton(entity, name, findings)
  }
  match entity.filename() {
    Some(filename) => audit_filename(entity, filename, findings)
    None => ()
  }
  let child_boundaries = ancestor_boundaries.copy()
  if entity.media_type.is_multipart() {
    match entity.media_type.parameter("boundary") {
      Some(boundary) => {
        for ancestor in ancestor_boundaries {
          if boundary == ancestor {
            findings.push({
              code: NestedBoundaryReuse,
              severity: High,
              message: "nested multipart reuses an ancestor boundary",
              entity_path: entity.path.copy(),
              evidence_range: entity.headers.range,
            })
          }
        }
        child_boundaries.push(boundary)
      }
      None => ()
    }
  }
  if entity.kind == @model.EmbeddedMessage && entity.children.is_empty() {
    findings.push({
      code: OpaqueEmbeddedMessage,
      severity: Medium,
      message: "encoded message/rfc822 body was not structurally inspected",
      entity_path: entity.path.copy(),
      evidence_range: entity.body_range,
    })
  }
  for child in entity.children {
    audit_entity(child, child_boundaries, findings)
  }
}

///|
fn singleton_names() -> Array[String] {
  [
    "content-type", "content-disposition", "content-transfer-encoding", "mime-version",
    "subject", "from", "sender", "reply-to", "message-id",
  ]
}

///|
fn audit_singleton(
  entity : @model.MimeEntity,
  name : String,
  findings : Array[AuditFinding],
) -> Unit {
  let fields = entity.headers.all(name)
  if fields.length() <= 1 {
    return
  }
  let first = fields[0].value
  let mut conflicting = false
  for index in 1.. Unit {
  let bytes = @utf8.encode(filename)
  let mut suspicious = filename == "." || filename == ".."
  for byte in bytes {
    if byte == b'/' ||
      byte == b'\\' ||
      byte == b':' ||
      byte < b' ' ||
      byte == b'\x7f' {
      suspicious = true
    }
  }
  if suspicious || filename.contains("../") || filename.contains("..\\") {
    findings.push({
      code: UnsafeAttachmentFilename,
      severity: High,
      message: "attachment filename contains path or control semantics",
      entity_path: entity.path.copy(),
      evidence_range: entity.headers.range,
    })
  }
  let lower = ascii_lower(filename)
  for suffix in executable_suffixes() {
    if lower.has_suffix(suffix) {
      findings.push({
        code: ExecutableAttachmentName,
        severity: Medium,
        message: "attachment uses an executable or script filename suffix",
        entity_path: entity.path.copy(),
        evidence_range: entity.headers.range,
      })
      return
    }
  }
}

///|
fn executable_suffixes() -> Array[String] {
  [".exe", ".com", ".bat", ".cmd", ".ps1", ".js", ".vbs", ".scr", ".msi"]
}

///|
fn ascii_lower(value : String) -> String {
  let bytes = @utf8.encode(value)
  let output : Array[Byte] = []
  for byte in bytes {
    if byte >= b'A' && byte <= b'Z' {
      output.push((byte.to_int() + 32).to_byte())
    } else {
      output.push(byte)
    }
  }
  @utf8.decode(Bytes::from_array(output)) catch {
    _ => value
  }
}

///|
fn summarize(findings : Array[AuditFinding]) -> AuditReport {
  let mut high = 0
  let mut medium = 0
  let mut low = 0
  let mut info = 0
  let mut score = 0
  for finding in findings {
    match finding.severity {
      High => {
        high = high + 1
        score = score + 25
      }
      Medium => {
        medium = medium + 1
        score = score + 10
      }
      Low => {
        low = low + 1
        score = score + 3
      }
      Info => info = info + 1
    }
  }
  if score > 100 {
    score = 100
  }
  { findings, risk_score: score, high, medium, low, info, }
}