///|
/// A normalized report from a portable coverage interchange format.
pub(all) struct ExternalCoverage {
  format : String
  total : Int
  covered : Int
  percentage : Int
  files : Array[FileCoverage]
} derive(Eq)

///|
/// Parse LCOV or Cobertura line coverage into MoonSeal's common model.
pub fn parse_external_coverage(
  input : String,
  format : String,
) -> Result[ExternalCoverage, String] {
  match format.to_lower() {
    "lcov" => parse_lcov(input)
    "cobertura" => parse_cobertura(input)
    _ => Err("unsupported coverage format: " + format)
  }
}

///|
/// Render normalized external coverage for a CI log.
pub fn render_external_coverage(report : ExternalCoverage) -> String {
  let out = StringBuilder()
  out.write_string("MoonSeal external coverage: " + report.format + "\n")
  out.write_string(
    "total: " +
    report.covered.to_string() +
    "/" +
    report.total.to_string() +
    " (" +
    report.percentage.to_string() +
    "%)\n",
  )
  for file in report.files {
    out.write_string(
      "- " +
      file.path +
      ": " +
      file.covered.to_string() +
      "/" +
      file.total.to_string() +
      " (" +
      file.percentage.to_string() +
      "%)\n",
    )
  }
  out.to_string()
}

///|
fn parse_lcov(input : String) -> Result[ExternalCoverage, String] {
  let files : Array[FileCoverage] = []
  let mut current_path = ""
  let mut current_total = 0
  let mut current_covered = 0
  let mut total = 0
  let mut covered = 0
  for raw in input.replace_all(old="\r\n", new="\n").split("\n") {
    let line = raw.to_owned().trim().to_owned()
    if line.has_prefix("SF:") {
      if current_path.length() > 0 {
        files.push(
          make_external_file(current_path, current_covered, current_total),
        )
      }
      current_path = line[3:].to_owned()
      current_total = 0
      current_covered = 0
    } else if line.has_prefix("DA:") {
      let fields = line[3:].split(",").map(fn(s) { s.to_owned() }).to_array()
      if fields.length() < 2 {
        return Err("LCOV DA record must contain line and hit count")
      }
      let hits = parse_int(fields[1])
      if !all_digits(fields[1]) {
        return Err("LCOV DA hit count is not an integer: " + fields[1])
      }
      current_total += 1
      if hits > 0 {
        current_covered += 1
      }
    } else if line == "end_of_record" && current_path.length() > 0 {
      files.push(
        make_external_file(current_path, current_covered, current_total),
      )
      total += current_total
      covered += current_covered
      current_path = ""
    }
  }
  if current_path.length() > 0 {
    files.push(make_external_file(current_path, current_covered, current_total))
    total += current_total
    covered += current_covered
  }
  Ok({
    format: "lcov",
    total,
    covered,
    percentage: coverage_percentage(covered, total),
    files,
  })
}

///|
fn parse_cobertura(input : String) -> Result[ExternalCoverage, String] {
  let files : Array[FileCoverage] = []
  let mut current_path = ""
  let mut current_total = 0
  let mut current_covered = 0
  for raw in input.replace_all(old="\r\n", new="\n").split("\n") {
    let line = raw.to_owned()
    if line.contains(" 0 {
        current_covered += 1
      }
    }
    if line.contains("") && current_path.length() > 0 {
      files.push(
        make_external_file(current_path, current_covered, current_total),
      )
      current_path = ""
    }
  }
  if current_path.length() > 0 {
    files.push(make_external_file(current_path, current_covered, current_total))
  }
  let mut total = 0
  let mut covered = 0
  for file in files {
    total += file.total
    covered += file.covered
  }
  Ok({
    format: "cobertura",
    total,
    covered,
    percentage: coverage_percentage(covered, total),
    files,
  })
}

///|
fn make_external_file(
  path : String,
  covered : Int,
  total : Int,
) -> FileCoverage {
  { path, covered, total, percentage: coverage_percentage(covered, total) }
}

///|
fn coverage_percentage(covered : Int, total : Int) -> Int {
  if total == 0 {
    100
  } else {
    covered * 100 / total
  }
}

///|
fn all_digits(value : String) -> Bool {
  if value.length() == 0 {
    return false
  }
  for ch in value {
    if ch < '0' || ch > '9' {
      return false
    }
  }
  true
}

///|
fn attribute(line : String, name : String) -> String {
  let marker = name + "=\""
  match line.find(marker) {
    Some(start) => {
      let value_start = start + marker.length()
      match line[value_start:].find("\"") {
        Some(end) => line[value_start:value_start + end].to_owned()
        None => ""
      }
    }
    None => ""
  }
}

///|
pub impl ToJson for ExternalCoverage with fn to_json(self : ExternalCoverage) -> Json {
  let files = Array::new()
  for file in self.files {
    files.push(file.to_json())
  }
  Json::object({
    "format": Json::string(self.format),
    "total": Json::number(self.total.to_double()),
    "covered": Json::number(self.covered.to_double()),
    "percentage": Json::number(self.percentage.to_double()),
    "files": Json::array(files),
  })
}