///|
pub enum ValidationRule {
  SheetGeometry
  FrameNames
  Durations
  AnimationTags
  SliceKeyframes
  CollisionBoxes
  AtlasConstraints
} derive(Debug, Eq, ToJson, FromJson)

///|
pub struct RuleFinding {
  rule : ValidationRule
  diagnostic : Diagnostic
} derive(Debug, Eq, ToJson, FromJson)

///|
pub struct ValidationResult {
  findings : Array[RuleFinding]
  errors : Int
  warnings : Int
  passed : Bool
} derive(Debug, Eq, ToJson, FromJson)

///|
pub fn ValidationResult::has_code(
  self : ValidationResult,
  code : String,
) -> Bool {
  for finding in self.findings {
    if finding.diagnostic.code == code {
      return true
    }
  }
  false
}

///|
pub fn ValidationResult::findings_for(
  self : ValidationResult,
  rule : ValidationRule,
) -> Array[RuleFinding] {
  self.findings.filter(fn(finding) { finding.rule == rule })
}

///|
pub fn ValidationResult::diagnostics(
  self : ValidationResult,
) -> Array[Diagnostic] {
  self.findings.map(fn(finding) { finding.diagnostic })
}

///|
pub fn ValidationResult::to_json_string(
  self : ValidationResult,
  indent? : Int = 2,
) -> String {
  self.to_json().stringify(indent~)
}

///|
fn add_findings(
  result : Array[RuleFinding],
  rule : ValidationRule,
  diagnostics : Array[Diagnostic],
) -> Array[RuleFinding] {
  let output = result.copy()
  for diagnostic in diagnostics {
    output.push({ rule, diagnostic })
  }
  output
}

///|
fn rule_sheet_geometry(sheet : SpriteSheet) -> Array[Diagnostic] {
  let diagnostics : Array[Diagnostic] = []
  if sheet.size.w <= 0 || sheet.size.h <= 0 {
    diagnostics.push({
      severity: Error,
      code: "invalid_sheet_size",
      message: "sheet dimensions must be positive",
    })
  }
  let bounds = { x: 0, y: 0, w: sheet.size.w, h: sheet.size.h }
  for frame in sheet.frames {
    if !bounds.contains_rect(frame.frame) {
      diagnostics.push({
        severity: Error,
        code: "frame_out_of_bounds",
        message: frame.filename,
      })
    }
    if frame.frame.is_empty() {
      diagnostics.push({
        severity: Error,
        code: "empty_frame_rect",
        message: frame.filename,
      })
    }
  }
  diagnostics
}

///|
fn rule_frame_names(sheet : SpriteSheet) -> Array[Diagnostic] {
  let diagnostics : Array[Diagnostic] = []
  for i in 0.. Array[Diagnostic] {
  let diagnostics : Array[Diagnostic] = []
  for frame in sheet.frames {
    if frame.duration <= 0 {
      diagnostics.push({
        severity: Error,
        code: "invalid_duration",
        message: frame.filename,
      })
    }
    if frame.duration > 60000 {
      diagnostics.push({
        severity: Warning,
        code: "long_duration",
        message: frame.filename,
      })
    }
  }
  diagnostics
}

///|
fn rule_animation_tags(sheet : SpriteSheet) -> Array[Diagnostic] {
  let diagnostics : Array[Diagnostic] = []
  for tag in sheet.tags {
    if tag.name == "" {
      diagnostics.push({
        severity: Warning,
        code: "empty_tag_name",
        message: "animation tag has no name",
      })
    }
    if tag.from < 0 || tag.to < tag.from || tag.to >= sheet.frames.length() {
      diagnostics.push({
        severity: Error,
        code: "tag_out_of_range",
        message: tag.name,
      })
    }
  }
  diagnostics
}

///|
fn rule_slice_keyframes(sheet : SpriteSheet) -> Array[Diagnostic] {
  let diagnostics : Array[Diagnostic] = []
  let bounds = { x: 0, y: 0, w: sheet.size.w, h: sheet.size.h }
  for slice in sheet.slices {
    if slice.keys.length() == 0 {
      diagnostics.push({
        severity: Warning,
        code: "empty_slice_keys",
        message: slice.name,
      })
    }
    for key in slice.keys {
      if key.frame < 0 || key.frame >= sheet.frames.length() {
        diagnostics.push({
          severity: Error,
          code: "slice_frame_out_of_range",
          message: slice.name,
        })
      }
      if !bounds.contains_rect(key.bounds) {
        diagnostics.push({
          severity: Warning,
          code: "slice_out_of_bounds",
          message: slice.name,
        })
      }
      match key.center {
        Some(center) =>
          if !key.bounds.contains_rect(center) {
            diagnostics.push({
              severity: Warning,
              code: "nine_patch_center_out_of_bounds",
              message: slice.name,
            })
          }
        None => ()
      }
    }
  }
  diagnostics
}

///|
fn rule_collision_boxes(sheet : SpriteSheet) -> Array[Diagnostic] {
  let diagnostics : Array[Diagnostic] = []
  for frame in sheet.frames {
    let bounds = { x: 0, y: 0, w: frame.source_size.w, h: frame.source_size.h }
    for box in frame.boxes {
      if box.rect.is_empty() {
        diagnostics.push({
          severity: Warning,
          code: "empty_collision_box",
          message: frame.filename,
        })
      }
      if !bounds.contains_rect(box.rect) {
        diagnostics.push({
          severity: Warning,
          code: "collision_box_out_of_bounds",
          message: frame.filename,
        })
      }
    }
  }
  diagnostics
}

///|
pub fn run_validation_rules(sheet : SpriteSheet) -> ValidationResult {
  let mut findings : Array[RuleFinding] = []
  findings = add_findings(findings, SheetGeometry, rule_sheet_geometry(sheet))
  findings = add_findings(findings, FrameNames, rule_frame_names(sheet))
  findings = add_findings(findings, Durations, rule_durations(sheet))
  findings = add_findings(findings, AnimationTags, rule_animation_tags(sheet))
  findings = add_findings(findings, SliceKeyframes, rule_slice_keyframes(sheet))
  findings = add_findings(findings, CollisionBoxes, rule_collision_boxes(sheet))
  let mut errors = 0
  let mut warnings = 0
  for finding in findings {
    match finding.diagnostic.severity {
      Error => errors += 1
      Warning => warnings += 1
    }
  }
  { findings, errors, warnings, passed: errors == 0 }
}

///|
pub fn validate_with_rules(
  sheet : SpriteSheet,
  rules : Array[ValidationRule],
) -> ValidationResult {
  let full = run_validation_rules(sheet)
  let findings = full.findings.filter(fn(finding) {
    rules.contains(finding.rule)
  })
  let mut errors = 0
  let mut warnings = 0
  for finding in findings {
    match finding.diagnostic.severity {
      Error => errors += 1
      Warning => warnings += 1
    }
  }
  { findings, errors, warnings, passed: errors == 0 }
}

///|
pub fn validation_rule_names() -> Array[String] {
  [
    "sheet_geometry", "frame_names", "durations", "animation_tags", "slice_keyframes",
    "collision_boxes", "atlas_constraints",
  ]
}

///|
pub fn validation_result_summary(result : ValidationResult) -> String {
  if result.passed {
    "passed: {result.warnings} warnings"
  } else {
    "failed: {result.errors} errors, {result.warnings} warnings"
  }
}

///|
pub fn validation_is_clean(result : ValidationResult) -> Bool {
  result.errors == 0 && result.warnings == 0
}

///|
pub fn validation_codes(result : ValidationResult) -> Array[String] {
  result.findings.map(fn(finding) { finding.diagnostic.code })
}

///|
pub fn validation_error_codes(result : ValidationResult) -> Array[String] {
  result.findings.filter_map(fn(finding) {
    match finding.diagnostic.severity {
      Error => Some(finding.diagnostic.code)
      Warning => None
    }
  })
}

///|
pub fn validation_warning_codes(result : ValidationResult) -> Array[String] {
  result.findings.filter_map(fn(finding) {
    match finding.diagnostic.severity {
      Error => None
      Warning => Some(finding.diagnostic.code)
    }
  })
}