///|
pub fn BrandBook::audit(self : BrandBook) -> AuditReport {
  let findings : Array[Finding] = []
  if self.name.trim() == "" {
    findings.push(Finding::{
      severity: Error,
      path: "name",
      message: "brand book name is required",
    })
  }
  if self.palette.colors.length() < 3 {
    findings.push(Finding::{
      severity: Warning,
      path: "palette.colors",
      message: "a usable brand system normally needs at least three color tokens",
    })
  }
  for i, color in self.palette.colors {
    if !is_hex_color(color.value) {
      findings.push(Finding::{
        severity: Error,
        path: "palette.colors[\{i}].value",
        message: "expected six-digit hex color, got \{color.value}",
      })
    }
    if color.role.trim() == "" {
      findings.push(Finding::{
        severity: Warning,
        path: "palette.colors[\{i}].role",
        message: "color token has no usage role",
      })
    }
    for j in (i + 1)..= 360 {
      findings.push(Finding::{
        severity: Error,
        path: "palette.gradients[" + i.to_string() + "].angle",
        message: "gradient angle must be between 0 and 359 degrees",
      })
    }
    for stop_index, stop in gradient.stops {
      if !is_hex_color(stop) {
        findings.push(Finding::{
          severity: Error,
          path: "palette.gradients[" +
          i.to_string() +
          "].stops[" +
          stop_index.to_string() +
          "]",
          message: "gradient stop must be a six-digit hex color",
        })
      }
    }
  }
  if self.assets.primary_logo.min_width_px < 16 {
    findings.push(Finding::{
      severity: Error,
      path: "assets.primary_logo.min_width_px",
      message: "logo minimum width is too small to be legible",
    })
  }
  if self.assets.primary_logo.path.trim().to_owned() == "" {
    findings.push(Finding::{
      severity: Error,
      path: "assets.primary_logo.path",
      message: "primary logo asset path is required",
    })
  }
  match self.assets.primary_logo.clear_space {
    Pixels(px) if px <= 0 =>
      findings.push(Finding::{
        severity: Error,
        path: "assets.primary_logo.clear_space",
        message: "logo clear space must be positive",
      })
    Multiplier(value) if value <= 0.0 =>
      findings.push(Finding::{
        severity: Error,
        path: "assets.primary_logo.clear_space",
        message: "logo clear-space multiplier must be positive",
      })
    _ => ()
  }
  for i, step in self.typography.scale {
    if step.size_px <= 0 {
      findings.push(Finding::{
        severity: Error,
        path: "typography.scale[" + i.to_string() + "].size_px",
        message: "font size must be positive",
      })
    }
    if step.line_height_px < step.size_px {
      findings.push(Finding::{
        severity: Error,
        path: "typography.scale[\{i}]",
        message: "line height must be greater than or equal to font size",
      })
    }
  }
  for i, family in self.typography.families {
    if family.name.trim().to_owned() == "" ||
      family.fallback.trim().to_owned() == "" {
      findings.push(Finding::{
        severity: Error,
        path: "typography.families[" + i.to_string() + "]",
        message: "font family and fallback are required",
      })
    }
  }
  for i, component in self.components {
    if component.tokens.length() == 0 {
      findings.push(Finding::{
        severity: Warning,
        path: "components[\{i}].tokens",
        message: "component has no token references",
      })
    }
    if component.states.length() == 0 {
      findings.push(Finding::{
        severity: Warning,
        path: "components[\{i}].states",
        message: "component has no interaction states",
      })
    }
    for token_index, token_name in component.tokens {
      if !has_brand_token(self, token_name) {
        findings.push(Finding::{
          severity: Error,
          path: "components[" +
          i.to_string() +
          "].tokens[" +
          token_index.to_string() +
          "]",
          message: "component references an unknown token: " + token_name,
        })
      }
    }
  }
  AuditReport::{ findings, }
}

///|
fn has_brand_token(book : BrandBook, name : String) -> Bool {
  if book.palette.colors.any(fn(color) { color.name == name }) {
    true
  } else {
    book.typography.scale.any(fn(step) { step.name == name }) ||
    book.typography.families.any(fn(family) { family.name == name })
  }
}

///|
fn is_hex_color(text : String) -> Bool {
  if text.length() != 7 || text[0] != '#' {
    false
  } else {
    for i in 1..<7 {
      let ch = text[i]
      if !((ch >= '0' && ch <= '9') ||
        (ch >= 'A' && ch <= 'F') ||
        (ch >= 'a' && ch <= 'f')) {
        return false
      }
    }
    true
  }
}