///|
/// An invalid non-comment line in a PSL conformance test file.
pub(all) enum ConformanceParseError {
  InvalidConformanceCase(Int, String, String)
} derive(Eq, Debug)

///|
pub fn ConformanceParseError::message(self : ConformanceParseError) -> String {
  match self {
    InvalidConformanceCase(line, text, reason) =>
      "invalid PSL conformance case on line \{line} ('\{text}'): \{reason}"
  }
}

///|
/// The observable result produced for one conformance input.
pub(all) enum ConformanceActual {
  NullInput
  RejectedDomain(DomainError)
  RejectedHostname(String)
  NoRegistrableDomain
  RegistrableDomain(String)
} derive(Eq, Debug)

///|
/// One conformance case whose actual result differs from its expectation.
pub struct ConformanceFailure {
  line_ : Int
  input_ : String?
  expected_ : String?
  actual_ : ConformanceActual
}

///|
pub fn ConformanceFailure::line(self : ConformanceFailure) -> Int {
  self.line_
}

///|
pub fn ConformanceFailure::input(self : ConformanceFailure) -> String? {
  self.input_
}

///|
pub fn ConformanceFailure::expected(self : ConformanceFailure) -> String? {
  self.expected_
}

///|
pub fn ConformanceFailure::actual(
  self : ConformanceFailure,
) -> ConformanceActual {
  self.actual_
}

///|
/// Summary and ordered failures from an upstream-style PSL conformance run.
pub struct ConformanceReport {
  total_count_ : Int
  passed_count_ : Int
  failures_ : ReadOnlyArray[ConformanceFailure]
}

///|
pub fn ConformanceReport::total_count(self : ConformanceReport) -> Int {
  self.total_count_
}

///|
pub fn ConformanceReport::passed_count(self : ConformanceReport) -> Int {
  self.passed_count_
}

///|
pub fn ConformanceReport::failed_count(self : ConformanceReport) -> Int {
  self.failures_.length()
}

///|
pub fn ConformanceReport::failures(
  self : ConformanceReport,
) -> ReadOnlyArray[ConformanceFailure] {
  self.failures_
}

///|
pub fn ConformanceReport::is_success(self : ConformanceReport) -> Bool {
  self.failures_.length() == 0
}

///|
fn parse_case_value(value : String) -> Result[String?, String] {
  let token = value.trim().to_owned()
  if token == "null" {
    return Ok(None)
  }
  if token.length() < 2 || token[0] != '\'' || token[token.length() - 1] != '\'' {
    return Err("expected null or a single-quoted domain")
  }
  let domain = token[1:token.length() - 1].to_owned()
  if domain.contains("'") || domain.contains("\\") {
    return Err("quoted domains must not contain escapes or quotes")
  }
  Ok(Some(domain))
}

///|
fn parse_conformance_case(
  line_number : Int,
  line : String,
) -> Result[(String?, String?)?, ConformanceParseError] {
  let trimmed = line.trim().to_owned()
  if trimmed == "" || trimmed.has_prefix("//") {
    return Ok(None)
  }
  let prefix = "checkPublicSuffix("
  if !trimmed.has_prefix(prefix) || !trimmed.has_suffix(");") {
    return Err(
      InvalidConformanceCase(
        line_number, trimmed, "expected checkPublicSuffix(input, expected);",
      ),
    )
  }
  let arguments = trimmed[prefix.length():trimmed.length() - 2].to_owned()
  let comma = match arguments.find(",") {
    Some(value) => value
    None =>
      return Err(
        InvalidConformanceCase(
          line_number, trimmed, "expected two comma-separated arguments",
        ),
      )
  }
  if arguments[comma + 1:].contains(",") {
    return Err(
      InvalidConformanceCase(
        line_number, trimmed, "expected exactly two arguments",
      ),
    )
  }
  let input = match parse_case_value(arguments[:comma].to_owned()) {
    Ok(value) => value
    Err(reason) =>
      return Err(InvalidConformanceCase(line_number, trimmed, reason))
  }
  let expected = match parse_case_value(arguments[comma + 1:].to_owned()) {
    Ok(value) => value
    Err(reason) =>
      return Err(InvalidConformanceCase(line_number, trimmed, reason))
  }
  Ok(Some((input, expected)))
}

///|
fn SuffixList::conformance_actual(
  self : SuffixList,
  input : String?,
  normalize : (String) -> Result[String, String],
) -> ConformanceActual {
  match input {
    None => NullInput
    Some(domain) =>
      match normalize(domain) {
        Err(reason) => RejectedHostname(reason)
        Ok(normalized) =>
          match self.registrable_domain(normalized) {
            Err(error) => RejectedDomain(error)
            Ok(None) => NoRegistrableDomain
            Ok(Some(value)) => RegistrableDomain(value)
          }
      }
  }
}

///|
fn conformance_matches(expected : String?, actual : ConformanceActual) -> Bool {
  match (expected, actual) {
    (None, NullInput)
    | (None, RejectedDomain(_))
    | (None, RejectedHostname(_))
    | (None, NoRegistrableDomain) => true
    (Some(wanted), RegistrableDomain(found)) => wanted == found
    _ => false
  }
}

///|
/// Run `checkPublicSuffix(input, expected);` cases in upstream file order.
///
/// Blank and `//` comment lines are ignored. An expected `null` accepts either
/// a rejected domain or a valid public suffix with no registrable domain, while
/// the report preserves that distinction for failed non-null expectations.
pub fn SuffixList::verify_psl_test_file(
  self : SuffixList,
  test_text : String,
) -> Result[ConformanceReport, ConformanceParseError] {
  self.verify_psl_test_file_with_normalizer(test_text, fn(
    value : String,
  ) -> Result[String, String] {
    Ok(value)
  })
}

///|
/// Verify upstream-style cases after normalizing both inputs and expected
/// registrable domains with the same profile used to compile PSL rules.
///
/// The caller owns the normalization policy. This is useful for IDNA-aware
/// conformance checks without adding Unicode dependencies to the core.
pub fn SuffixList::verify_psl_test_file_with_normalizer(
  self : SuffixList,
  test_text : String,
  normalize : (String) -> Result[String, String],
) -> Result[ConformanceReport, ConformanceParseError] {
  let failures : Array[ConformanceFailure] = []
  let mut total_count = 0
  let mut passed_count = 0
  let mut line_number = 0
  for line in test_text.split("\n") {
    line_number = line_number + 1
    let parsed = match parse_conformance_case(line_number, line.to_owned()) {
      Ok(value) => value
      Err(error) => return Err(error)
    }
    match parsed {
      None => ()
      Some((input, expected)) => {
        total_count = total_count + 1
        let normalized_expected = match expected {
          None => None
          Some(value) =>
            match normalize(value) {
              Ok(normalized) => Some(normalized)
              Err(reason) =>
                return Err(
                  InvalidConformanceCase(
                    line_number,
                    line.to_owned(),
                    "expected domain cannot be normalized: " + reason,
                  ),
                )
            }
        }
        let actual = self.conformance_actual(input, normalize)
        if conformance_matches(normalized_expected, actual) {
          passed_count = passed_count + 1
        } else {
          failures.push({
            line_: line_number,
            input_: input,
            expected_: expected,
            actual_: actual,
          })
        }
      }
    }
  }
  Ok({
    total_count_: total_count,
    passed_count_: passed_count,
    failures_: ReadOnlyArray::from_array(failures),
  })
}

///|
fn conformance_value_text(value : String?) -> String {
  match value {
    None => "null"
    Some(text) => text
  }
}

///|
fn conformance_actual_text(actual : ConformanceActual) -> String {
  match actual {
    NullInput => "null_input"
    RejectedDomain(error) => "rejected: " + error.message()
    RejectedHostname(reason) => "rejected hostname: " + reason
    NoRegistrableDomain => "no_registrable_domain"
    RegistrableDomain(domain) => domain
  }
}

///|
/// Export failed cases in source order as deterministic, fully quoted CSV.
pub fn ConformanceReport::to_csv(self : ConformanceReport) -> String {
  let output = StringBuilder()
  output.write_string("\"line\",\"input\",\"expected\",\"actual\"\n")
  for failure in self.failures_ {
    write_csv_row(output, [
      failure.line_.to_string(),
      conformance_value_text(failure.input_),
      conformance_value_text(failure.expected_),
      conformance_actual_text(failure.actual_),
    ])
  }
  output.to_string()
}