///|
pub fn validate_calendar(
  calendar : Calendar,
  options : ValidationOptions,
) -> ValidationReport {
  let findings : Array[Finding] = []
  validate_calendar_properties(calendar, options, findings)
  validate_components(calendar, options, findings)
  if findings.length() == 0 {
    findings.push({
      severity: Info,
      code: "accepted",
      message: "calendar passed validation",
      line: None,
    })
  }
  { accepted: !has_deny(findings), findings }
}

///|
fn validate_calendar_properties(
  calendar : Calendar,
  options : ValidationOptions,
  findings : Array[Finding],
) -> Unit {
  if options.require_version && calendar.get("VERSION") is None {
    findings.push({
      severity: Deny,
      code: "missing-version",
      message: "VCALENDAR should contain VERSION:2.0",
      line: None,
    })
  }
  match calendar.get("VERSION") {
    Some(version) =>
      if version != "2.0" {
        findings.push({
          severity: Deny,
          code: "unsupported-version",
          message: "only iCalendar VERSION:2.0 is supported",
          line: None,
        })
      }
    None => ()
  }
  if options.require_prodid && !calendar_has_value(calendar, "PRODID") {
    findings.push({
      severity: Deny,
      code: "missing-prodid",
      message: "VCALENDAR must contain PRODID in strict mode",
      line: None,
    })
  }
  validate_calendar_property_catalog(calendar, findings)
  let events = calendar.events()
  match options.max_events {
    Some(limit) =>
      if events.length() > limit {
        findings.push({
          severity: Deny,
          code: "too-many-events",
          message: "calendar has \{events.length()} events, above limit \{limit}",
          line: None,
        })
      }
    None => ()
  }
}

///|
fn validate_components(
  calendar : Calendar,
  options : ValidationOptions,
  findings : Array[Finding],
) -> Unit {
  for component in calendar.components {
    validate_component(component, "VCALENDAR", options, findings)
  }
}

///|
fn validate_component(
  component : Component,
  parent : String,
  options : ValidationOptions,
  findings : Array[Finding],
) -> Unit {
  validate_component_property_catalog(component, findings)
  if !component_allowed_under(component.name, parent) &&
    !options.allow_unknown_components {
    findings.push({
      severity: Warn,
      code: "component-placement",
      message: "component \{component.name} is unusual under \{parent}",
      line: Some(component.line),
    })
  }
  if component.name == "VEVENT" {
    validate_event(component, options, findings)
  } else if component.name == "VALARM" {
    validate_alarm(component, findings)
  } else if component.name == "VTODO" {
    validate_todo(component, options, findings)
  } else if component.name == "VJOURNAL" {
    validate_journal(component, options, findings)
  } else if component.name == "VTIMEZONE" {
    validate_timezone(component, findings)
  } else if component.name == "STANDARD" || component.name == "DAYLIGHT" {
    validate_timezone_observance(component, findings)
  } else if !options.allow_unknown_components {
    findings.push({
      severity: Warn,
      code: "unknown-component",
      message: "component \{component.name} is not explicitly supported",
      line: Some(component.line),
    })
  }
  for child in component.components {
    validate_component(child, component.name, options, findings)
  }
}

///|
fn component_allowed_under(name : String, parent : String) -> Bool {
  if parent == "VCALENDAR" {
    name == "VEVENT" ||
    name == "VTODO" ||
    name == "VJOURNAL" ||
    name == "VTIMEZONE"
  } else if parent == "VEVENT" || parent == "VTODO" {
    name == "VALARM"
  } else if parent == "VTIMEZONE" {
    name == "STANDARD" || name == "DAYLIGHT"
  } else {
    false
  }
}

///|
fn validate_calendar_property_catalog(
  calendar : Calendar,
  findings : Array[Finding],
) -> Unit {
  for prop in calendar.properties {
    validate_property_against_role(prop, CalendarRole, findings)
  }
  validate_property_multiplicity(calendar.properties, CalendarRole, findings)
}

///|
fn validate_component_property_catalog(
  component : Component,
  findings : Array[Finding],
) -> Unit {
  let role = role_for_component(component.name)
  for prop in component.properties {
    validate_property_against_role(prop, role, findings)
  }
  validate_property_multiplicity(component.properties, role, findings)
}

///|
fn validate_property_against_role(
  prop : Property,
  role : ComponentRole,
  findings : Array[Finding],
) -> Unit {
  match find_property_spec(prop.name) {
    Some(item) =>
      if !spec_allows_role(item, role) {
        findings.push({
          severity: Warn,
          code: "property-placement",
          message: "\{prop.name} is not normally allowed in \{role.name()}",
          line: Some(prop.line),
        })
      }
    None =>
      if !is_x_name(prop.name) {
        findings.push({
          severity: Info,
          code: "unknown-property",
          message: "\{prop.name} is not in the built-in property catalog",
          line: Some(prop.line),
        })
      }
  }
}

///|
fn validate_property_multiplicity(
  properties : Array[Property],
  role : ComponentRole,
  findings : Array[Finding],
) -> Unit {
  for item in all_property_specs() {
    if spec_allows_role(item, role) &&
      !cardinality_allows_many(item.cardinality) {
      let count = count_properties(properties, item.name)
      if count > 1 {
        findings.push({
          severity: Warn,
          code: "property-duplicate",
          message: "\{role.name()} contains \{count} copies of \{item.name}",
          line: first_property_line(properties, item.name),
        })
      }
    }
  }
}

///|
fn count_properties(properties : Array[Property], name : String) -> Int {
  let mut count = 0
  for prop in properties {
    if prop.name == name {
      count += 1
    }
  }
  count
}

///|
fn first_property_line(properties : Array[Property], name : String) -> Int? {
  for prop in properties {
    if prop.name == name {
      return Some(prop.line)
    }
  }
  None
}

///|
fn is_x_name(name : String) -> Bool {
  name.length() > 2 && name[0] == 88 && name[1] == 45
}

///|
fn validate_event(
  event : Component,
  options : ValidationOptions,
  findings : Array[Finding],
) -> Unit {
  if options.require_uid && !component_has_value(event, "UID") {
    findings.push({
      severity: Deny,
      code: "event-missing-uid",
      message: "VEVENT must contain UID",
      line: None,
    })
  }
  if options.require_dtstamp && !component_has_value(event, "DTSTAMP") {
    findings.push({
      severity: Deny,
      code: "event-missing-dtstamp",
      message: "VEVENT must contain DTSTAMP",
      line: None,
    })
  }
  if options.require_uid && !component_has_value(event, "DTSTART") {
    findings.push({
      severity: Deny,
      code: "event-missing-dtstart",
      message: "VEVENT must contain DTSTART in strict mode",
      line: Some(event.line),
    })
  }
  if event.count("DTSTART") > 1 {
    findings.push({
      severity: Deny,
      code: "event-duplicate-dtstart",
      message: "VEVENT should not contain multiple DTSTART values",
      line: None,
    })
  }
  if event.has("DTEND") && event.has("DURATION") {
    findings.push({
      severity: Deny,
      code: "event-dtend-duration-conflict",
      message: "VEVENT must not contain both DTEND and DURATION",
      line: None,
    })
  }
  validate_time_value(event, "DTSTAMP", true, options, findings)
  validate_time_value(event, "DTSTART", false, options, findings)
  validate_time_value(event, "DTEND", false, options, findings)
  validate_order(event, findings)
  validate_duration_property(event, "DURATION", findings)
  validate_recurrence(event, findings)
  validate_recurrence_dates(event, options, findings)
  validate_event_status(event, findings)
  validate_event_transparency(event, findings)
  validate_event_priority(event, findings)
  validate_event_alarms(event, findings)
}

///|
fn validate_todo(
  todo : Component,
  options : ValidationOptions,
  findings : Array[Finding],
) -> Unit {
  if options.require_uid && !component_has_value(todo, "UID") {
    findings.push({
      severity: Deny,
      code: "todo-missing-uid",
      message: "VTODO must contain UID in strict mode",
      line: Some(todo.line),
    })
  }
  if options.require_dtstamp && !component_has_value(todo, "DTSTAMP") {
    findings.push({
      severity: Deny,
      code: "todo-missing-dtstamp",
      message: "VTODO must contain DTSTAMP in strict mode",
      line: Some(todo.line),
    })
  }
  validate_time_value(todo, "DTSTAMP", true, options, findings)
  validate_time_value(todo, "DUE", false, options, findings)
  validate_time_value(todo, "COMPLETED", true, options, findings)
  validate_duration_property(todo, "DURATION", findings)
  if todo.has("DUE") && todo.has("DURATION") {
    findings.push({
      severity: Deny,
      code: "todo-due-duration-conflict",
      message: "VTODO must not contain both DUE and DURATION",
      line: Some(todo.line),
    })
  }
  validate_percent_complete(todo, findings)
  validate_todo_status(todo, findings)
  validate_recurrence(todo, findings)
  validate_recurrence_dates(todo, options, findings)
  validate_event_alarms(todo, findings)
}

///|
fn validate_journal(
  journal : Component,
  options : ValidationOptions,
  findings : Array[Finding],
) -> Unit {
  if options.require_uid && !component_has_value(journal, "UID") {
    findings.push({
      severity: Deny,
      code: "journal-missing-uid",
      message: "VJOURNAL must contain UID in strict mode",
      line: Some(journal.line),
    })
  }
  if options.require_dtstamp && !component_has_value(journal, "DTSTAMP") {
    findings.push({
      severity: Deny,
      code: "journal-missing-dtstamp",
      message: "VJOURNAL must contain DTSTAMP in strict mode",
      line: Some(journal.line),
    })
  }
  validate_time_value(journal, "DTSTAMP", true, options, findings)
  validate_time_value(journal, "DTSTART", false, options, findings)
  validate_journal_status(journal, findings)
}

///|
fn validate_timezone(timezone : Component, findings : Array[Finding]) -> Unit {
  if !component_has_value(timezone, "TZID") {
    findings.push({
      severity: Deny,
      code: "timezone-missing-tzid",
      message: "VTIMEZONE must contain TZID",
      line: Some(timezone.line),
    })
  }
  if timezone.children("STANDARD").length() == 0 &&
    timezone.children("DAYLIGHT").length() == 0 {
    findings.push({
      severity: Warn,
      code: "timezone-no-observance",
      message: "VTIMEZONE normally contains STANDARD or DAYLIGHT observance",
      line: Some(timezone.line),
    })
  }
}

///|
fn validate_timezone_observance(
  component : Component,
  findings : Array[Finding],
) -> Unit {
  if !component_has_value(component, "DTSTART") {
    findings.push({
      severity: Deny,
      code: "timezone-observance-missing-dtstart",
      message: "\{component.name} observance must contain DTSTART",
      line: Some(component.line),
    })
  }
  if !component_has_value(component, "TZOFFSETFROM") {
    findings.push({
      severity: Deny,
      code: "timezone-observance-missing-offset-from",
      message: "\{component.name} observance must contain TZOFFSETFROM",
      line: Some(component.line),
    })
  }
  if !component_has_value(component, "TZOFFSETTO") {
    findings.push({
      severity: Deny,
      code: "timezone-observance-missing-offset-to",
      message: "\{component.name} observance must contain TZOFFSETTO",
      line: Some(component.line),
    })
  }
}

///|
fn validate_time_value(
  event : Component,
  name : String,
  must_be_utc : Bool,
  options : ValidationOptions,
  findings : Array[Finding],
) -> Unit {
  match event.get(name) {
    None => ()
    Some(value) => {
      let valid_time = is_date(value) || is_datetime(value)
      if !valid_time {
        findings.push({
          severity: Deny,
          code: "bad-time-value",
          message: "\{name} has invalid DATE or DATE-TIME value: \{value}",
          line: None,
        })
      }
      if valid_time &&
        must_be_utc &&
        (!is_datetime(value) || !ends_with_z(value)) {
        findings.push({
          severity: Deny,
          code: "dtstamp-not-utc",
          message: "DTSTAMP must be UTC DATE-TIME ending with Z",
          line: None,
        })
      }
      if !options.allow_floating_time && is_floating_datetime(value) {
        match event.param_value(name, "TZID") {
          None =>
            findings.push({
              severity: Deny,
              code: "floating-time",
              message: "\{name} is floating time without TZID",
              line: None,
            })
          Some(_) => ()
        }
      }
    }
  }
}

///|
fn validate_duration_property(
  component : Component,
  name : String,
  findings : Array[Finding],
) -> Unit {
  for prop in component.properties_named(name) {
    match parse_duration(prop.value) {
      Ok(value) =>
        if value.is_zero() {
          findings.push({
            severity: Warn,
            code: "zero-duration",
            message: "\{name} is zero length",
            line: Some(prop.line),
          })
        }
      Err(err) =>
        findings.push({
          severity: Deny,
          code: "bad-duration",
          message: "\{name} has invalid duration: \{err.message()}",
          line: Some(prop.line),
        })
    }
  }
}

///|
fn validate_recurrence(
  component : Component,
  findings : Array[Finding],
) -> Unit {
  let rules = component.properties_named("RRULE")
  if rules.length() > 1 {
    findings.push({
      severity: Deny,
      code: "duplicate-rrule",
      message: "\{component.name} must not contain multiple RRULE properties",
      line: Some(rules[1].line),
    })
  }
  for prop in rules {
    match parse_recurrence_rule(prop.value) {
      Ok(rule) => {
        if !rule.is_finite() {
          findings.push({
            severity: Warn,
            code: "rrule-unbounded",
            message: "RRULE has no COUNT or UNTIL limit",
            line: Some(prop.line),
          })
        }
        if rule.looks_dense_for_feed() {
          findings.push({
            severity: Warn,
            code: "rrule-dense",
            message: "RRULE frequency may create a very dense calendar feed",
            line: Some(prop.line),
          })
        }
        if rule.frequency is Monthly &&
          rule.by_day.length() > 0 &&
          rule.by_set_pos.length() == 0 {
          findings.push({
            severity: Info,
            code: "rrule-monthly-byday",
            message: "monthly BYDAY without BYSETPOS expands every matching weekday",
            line: Some(prop.line),
          })
        }
      }
      Err(err) =>
        findings.push({
          severity: Deny,
          code: "bad-rrule",
          message: err.message(),
          line: Some(prop.line),
        })
    }
  }
}

///|
fn validate_recurrence_dates(
  component : Component,
  options : ValidationOptions,
  findings : Array[Finding],
) -> Unit {
  for prop in component.properties_named("EXDATE") {
    validate_date_list_property(prop, options, findings)
  }
  for prop in component.properties_named("RDATE") {
    validate_date_list_property(prop, options, findings)
  }
  if component.has("EXRULE") {
    findings.push({
      severity: Warn,
      code: "deprecated-exrule",
      message: "EXRULE is deprecated; prefer EXDATE with RRULE",
      line: component_property_line(component, "EXRULE"),
    })
  }
}

///|
fn validate_date_list_property(
  prop : Property,
  options : ValidationOptions,
  findings : Array[Finding],
) -> Unit {
  let values = split_by_char(prop.value, 44)
  for value in values {
    if !(is_date(value) || is_datetime(value)) {
      findings.push({
        severity: Deny,
        code: "bad-date-list-value",
        message: "\{prop.name} contains invalid date value: \{value}",
        line: Some(prop.line),
      })
    }
    if !options.allow_floating_time && is_floating_datetime(value) {
      match prop.param_value("TZID") {
        Some(_) => ()
        None =>
          findings.push({
            severity: Deny,
            code: "floating-recurrence-date",
            message: "\{prop.name} has floating DATE-TIME without TZID",
            line: Some(prop.line),
          })
      }
    }
  }
}

///|
fn validate_event_alarms(
  component : Component,
  findings : Array[Finding],
) -> Unit {
  let alarms = component.alarms()
  if alarms.length() > 10 {
    findings.push({
      severity: Warn,
      code: "event-many-alarms",
      message: "\{component.name} contains \{alarms.length()} alarms",
      line: Some(component.line),
    })
  }
  for child in component.components {
    if child.name != "VALARM" {
      findings.push({
        severity: Warn,
        code: "event-unknown-child",
        message: "\{component.name} contains unsupported child \{child.name}",
        line: Some(child.line),
      })
    }
  }
}

///|
fn validate_alarm(alarm : Component, findings : Array[Finding]) -> Unit {
  let summary = summarize_alarm(alarm)
  if alarm.count("ACTION") != 1 {
    findings.push({
      severity: Deny,
      code: "alarm-action-count",
      message: "VALARM must contain exactly one ACTION",
      line: Some(alarm.line),
    })
  }
  if !alarm_is_action_supported(summary.action) {
    findings.push({
      severity: Deny,
      code: "alarm-action-unsupported",
      message: "VALARM ACTION is unsupported: \{summary.action.name()}",
      line: component_property_line(alarm, "ACTION"),
    })
  }
  if alarm.count("TRIGGER") != 1 {
    findings.push({
      severity: Deny,
      code: "alarm-trigger-count",
      message: "VALARM must contain exactly one TRIGGER",
      line: Some(alarm.line),
    })
  } else if !trigger_is_relative(summary.trigger) &&
    !trigger_is_absolute(summary.trigger) {
    findings.push({
      severity: Deny,
      code: "alarm-bad-trigger",
      message: "VALARM TRIGGER must be duration or DATE-TIME",
      line: component_property_line(alarm, "TRIGGER"),
    })
  }
  if alarm_requires_description(summary.action) && summary.description is None {
    findings.push({
      severity: Deny,
      code: "alarm-missing-description",
      message: "\{summary.action.name()} alarm must contain DESCRIPTION",
      line: Some(alarm.line),
    })
  }
  if alarm_requires_summary(summary.action) && summary.summary is None {
    findings.push({
      severity: Deny,
      code: "alarm-missing-summary",
      message: "EMAIL alarm must contain SUMMARY",
      line: Some(alarm.line),
    })
  }
  if alarm_requires_attendee(summary.action) && summary.attendee_count == 0 {
    findings.push({
      severity: Deny,
      code: "alarm-missing-attendee",
      message: "EMAIL alarm must contain at least one ATTENDEE",
      line: Some(alarm.line),
    })
  }
  validate_alarm_repeat(alarm, findings)
}

///|
fn validate_alarm_repeat(alarm : Component, findings : Array[Finding]) -> Unit {
  if alarm.has("REPEAT") && !alarm.has("DURATION") {
    findings.push({
      severity: Deny,
      code: "alarm-repeat-without-duration",
      message: "VALARM REPEAT requires DURATION",
      line: component_property_line(alarm, "REPEAT"),
    })
  }
  if alarm.has("DURATION") && !alarm.has("REPEAT") {
    findings.push({
      severity: Deny,
      code: "alarm-duration-without-repeat",
      message: "VALARM DURATION requires REPEAT",
      line: component_property_line(alarm, "DURATION"),
    })
  }
  validate_duration_property(alarm, "DURATION", findings)
  match alarm.get("REPEAT") {
    Some(value) =>
      if !is_positive_plain_int(value) {
        findings.push({
          severity: Deny,
          code: "alarm-bad-repeat",
          message: "VALARM REPEAT must be a positive integer",
          line: component_property_line(alarm, "REPEAT"),
        })
      }
    None => ()
  }
}

///|
fn validate_event_status(event : Component, findings : Array[Finding]) -> Unit {
  match event.get("STATUS") {
    Some(value) => {
      let normalized = ascii_upper(trim_ascii(value))
      if normalized != "TENTATIVE" &&
        normalized != "CONFIRMED" &&
        normalized != "CANCELLED" {
        findings.push({
          severity: Warn,
          code: "event-bad-status",
          message: "VEVENT STATUS is unusual: \{value}",
          line: component_property_line(event, "STATUS"),
        })
      }
    }
    None => ()
  }
}

///|
fn validate_todo_status(todo : Component, findings : Array[Finding]) -> Unit {
  match todo.get("STATUS") {
    Some(value) => {
      let normalized = ascii_upper(trim_ascii(value))
      if normalized != "NEEDS-ACTION" &&
        normalized != "COMPLETED" &&
        normalized != "IN-PROCESS" &&
        normalized != "CANCELLED" {
        findings.push({
          severity: Warn,
          code: "todo-bad-status",
          message: "VTODO STATUS is unusual: \{value}",
          line: component_property_line(todo, "STATUS"),
        })
      }
    }
    None => ()
  }
}

///|
fn validate_journal_status(
  journal : Component,
  findings : Array[Finding],
) -> Unit {
  match journal.get("STATUS") {
    Some(value) => {
      let normalized = ascii_upper(trim_ascii(value))
      if normalized != "DRAFT" &&
        normalized != "FINAL" &&
        normalized != "CANCELLED" {
        findings.push({
          severity: Warn,
          code: "journal-bad-status",
          message: "VJOURNAL STATUS is unusual: \{value}",
          line: component_property_line(journal, "STATUS"),
        })
      }
    }
    None => ()
  }
}

///|
fn validate_event_transparency(
  event : Component,
  findings : Array[Finding],
) -> Unit {
  match event.get("TRANSP") {
    Some(value) => {
      let normalized = ascii_upper(trim_ascii(value))
      if normalized != "OPAQUE" && normalized != "TRANSPARENT" {
        findings.push({
          severity: Warn,
          code: "event-bad-transparency",
          message: "VEVENT TRANSP should be OPAQUE or TRANSPARENT",
          line: component_property_line(event, "TRANSP"),
        })
      }
    }
    None => ()
  }
}

///|
fn validate_event_priority(
  event : Component,
  findings : Array[Finding],
) -> Unit {
  match event.get("PRIORITY") {
    Some(value) =>
      if !is_priority_value(value) {
        findings.push({
          severity: Warn,
          code: "event-bad-priority",
          message: "PRIORITY should be an integer from 0 to 9",
          line: component_property_line(event, "PRIORITY"),
        })
      }
    None => ()
  }
}

///|
fn validate_percent_complete(
  todo : Component,
  findings : Array[Finding],
) -> Unit {
  match todo.get("PERCENT-COMPLETE") {
    Some(value) =>
      if !is_percent_value(value) {
        findings.push({
          severity: Warn,
          code: "todo-bad-percent",
          message: "PERCENT-COMPLETE should be an integer from 0 to 100",
          line: component_property_line(todo, "PERCENT-COMPLETE"),
        })
      }
    None => ()
  }
}

///|
fn validate_order(event : Component, findings : Array[Finding]) -> Unit {
  match (event.get("DTSTART"), event.get("DTEND")) {
    (Some(start), Some(end)) =>
      if (is_date(start) && is_datetime(end)) ||
        (is_datetime(start) && is_date(end)) {
        findings.push({
          severity: Deny,
          code: "event-time-kind-mismatch",
          message: "DTSTART and DTEND must both be DATE or both be DATE-TIME",
          line: None,
        })
      } else if comparable_datetime(start) &&
        comparable_datetime(end) &&
        end <= start {
        findings.push({
          severity: Deny,
          code: "event-non-positive-duration",
          message: "DTEND should be later than DTSTART",
          line: None,
        })
      }
    _ => ()
  }
}

///|
fn component_property_line(component : Component, name : String) -> Int? {
  for prop in component.properties_named(name) {
    return Some(prop.line)
  }
  Some(component.line)
}

///|
fn calendar_has_value(calendar : Calendar, name : String) -> Bool {
  match calendar.get(name) {
    Some(value) => trim_ascii(value).length() > 0
    None => false
  }
}

///|
fn component_has_value(component : Component, name : String) -> Bool {
  match component.get(name) {
    Some(value) => trim_ascii(value).length() > 0
    None => false
  }
}

///|
fn is_positive_plain_int(value : String) -> Bool {
  if !all_digits(value) {
    return false
  }
  let mut non_zero = false
  let mut i = 0
  while i < value.length() {
    if value[i] != 48 {
      non_zero = true
    }
    i += 1
  }
  non_zero
}

///|
fn is_priority_value(value : String) -> Bool {
  let cleaned = trim_ascii(value)
  cleaned.length() == 1 && cleaned[0] >= 48 && cleaned[0] <= 57
}

///|
fn is_percent_value(value : String) -> Bool {
  let cleaned = trim_ascii(value)
  if !all_digits(cleaned) {
    return false
  }
  if cleaned.length() == 1 {
    return true
  }
  if cleaned.length() == 2 {
    return true
  }
  cleaned == "100"
}

///|
fn is_date(s : String) -> Bool {
  if s.length() != 8 || !all_digits(s) {
    return false
  }
  let year = digits_to_int(s[0:4].to_owned())
  let month = digits_to_int(s[4:6].to_owned())
  let day = digits_to_int(s[6:8].to_owned())
  if month < 1 || month > 12 || day < 1 {
    return false
  }
  day <= days_in_month(year, month)
}

///|
fn is_datetime(s : String) -> Bool {
  let has_z = s.length() == 16 && s[15] == 90
  let has_no_z = s.length() == 15
  if !has_z && !has_no_z {
    return false
  }
  if s[8] != 84 ||
    !is_date(s[0:8].to_owned()) ||
    !all_digits(s[9:15].to_owned()) {
    return false
  }
  let hour = digits_to_int(s[9:11].to_owned())
  let minute = digits_to_int(s[11:13].to_owned())
  let second = digits_to_int(s[13:15].to_owned())
  hour <= 23 && minute <= 59 && second <= 60
}

///|
fn digits_to_int(s : String) -> Int {
  let mut value = 0
  let mut i = 0
  while i < s.length() {
    value = value * 10 + s[i].to_int() - 48
    i += 1
  }
  value
}

///|
fn days_in_month(year : Int, month : Int) -> Int {
  if month == 2 {
    if is_leap_year(year) {
      29
    } else {
      28
    }
  } else if month == 4 || month == 6 || month == 9 || month == 11 {
    30
  } else {
    31
  }
}

///|
fn is_leap_year(year : Int) -> Bool {
  year % 400 == 0 || (year % 4 == 0 && year % 100 != 0)
}

///|
fn all_digits(s : String) -> Bool {
  if s.length() == 0 {
    return false
  }
  let mut i = 0
  while i < s.length() {
    if s[i] < 48 || s[i] > 57 {
      return false
    }
    i += 1
  }
  true
}

///|
fn ends_with_z(s : String) -> Bool {
  s.length() > 0 && s[s.length() - 1] == 90
}

///|
fn is_floating_datetime(s : String) -> Bool {
  s.length() == 15 && is_datetime(s)
}

///|
fn comparable_datetime(s : String) -> Bool {
  is_date(s) || is_datetime(s)
}

///|
fn has_deny(findings : Array[Finding]) -> Bool {
  for finding in findings {
    if finding.severity is Deny {
      return true
    }
  }
  false
}