///|
/// PAN validation and Track 2 payment data helpers.
pub(all) struct PanInfo {
  digits : String
  length : Int
  issuer_prefix : String
  masked : String
} derive(Eq, Debug)

///|
/// Parsed Track 2 data.
pub(all) struct Track2 {
  pan : String
  expiration : String
  service_code : String
  discretionary : String
  separator : UInt16
} derive(Eq, Debug)

///|
/// Validate a PAN with length and Luhn constraints.
pub fn parse_pan(value : String) -> Result[PanInfo, IsoError] {
  if value.length() < 12 || value.length() > 19 {
    return Err(InvalidPan(value))
  }
  match validate_numeric_text(2, value) {
    Err(_) => return Err(InvalidPan(value))
    Ok(_) => ()
  }
  if !pan_luhn_valid(value) {
    return Err(InvalidPan(value))
  }
  let prefix_length = if value.length() >= 6 { 6 } else { value.length() }
  Ok({
    digits: value,
    length: value.length(),
    issuer_prefix: value[0:prefix_length].to_owned(),
    masked: mask_pan(value),
  })
}

///|
/// Check the Luhn checksum of a numeric PAN.
pub fn pan_luhn_valid(value : String) -> Bool {
  if value.length() == 0 {
    return false
  }
  let mut sum = 0
  let mut double_digit = false
  let mut i = value.length() - 1
  while i >= 0 {
    let digit = value[i].to_int() - 48
    if digit < 0 || digit > 9 {
      return false
    }
    let adjusted = if double_digit {
      let doubled = digit * 2
      if doubled > 9 {
        doubled - 9
      } else {
        doubled
      }
    } else {
      digit
    }
    sum += adjusted
    double_digit = !double_digit
    i -= 1
  }
  sum % 10 == 0
}

///|
/// Produce a log-safe PAN mask while preserving the first six and last four digits.
pub fn mask_pan(value : String) -> String {
  if value.length() <= 10 {
    let out = StringBuilder()
    for _ in 0.. Int? {
  for i = 0; i < value.length(); i = i + 1 {
    if value[i] == '=' || value[i] == 'D' {
      return Some(i)
    }
  }
  None
}

///|
/// Parse PAN, expiry, service code and discretionary data from Track 2.
pub fn parse_track2(value : String) -> Result[Track2, IsoError] {
  let separator_position = match track2_separator(value) {
    Some(position) => position
    None => return Err(InvalidTrack2("missing separator"))
  }
  if separator_position < 12 || separator_position > 19 {
    return Err(InvalidTrack2("PAN length is outside 12..19"))
  }
  for i = 0; i < value.length(); i = i + 1 {
    let c = value[i]
    if i == separator_position {
      continue
    }
    if !ascii_digit(c) {
      return Err(InvalidTrack2("non-decimal Track 2 character"))
    }
  }
  let tail_length = value.length() - separator_position - 1
  if tail_length < 7 {
    return Err(InvalidTrack2("expiry and service code are incomplete"))
  }
  let pan = value[0:separator_position].to_owned()
  let tail = value[separator_position + 1:].to_owned()
  match parse_pan(pan) {
    Err(_) => return Err(InvalidTrack2("PAN checksum failed"))
    Ok(_) => ()
  }
  Ok({
    pan,
    expiration: tail[0:4].to_owned(),
    service_code: tail[4:7].to_owned(),
    discretionary: tail[7:].to_owned(),
    separator: value[separator_position],
  })
}

///|
/// Render Track 2 with a canonical '=' separator.
pub fn Track2::to_string(self : Track2) -> String {
  self.pan + "=" + self.expiration + self.service_code + self.discretionary
}

///|
/// Render a masked Track 2 value for diagnostics.
pub fn Track2::masked(self : Track2) -> String {
  mask_pan(self.pan) +
  "=" +
  self.expiration +
  self.service_code +
  self.discretionary
}

///|
/// Replace the separator and preserve the other Track 2 components.
pub fn Track2::with_separator(
  self : Track2,
  separator : UInt16,
) -> Result[Track2, IsoError] {
  if separator != '=' && separator != 'D' {
    return Err(InvalidTrack2("separator must be '=' or 'D'"))
  }
  Ok({
    pan: self.pan,
    expiration: self.expiration,
    service_code: self.service_code,
    discretionary: self.discretionary,
    separator,
  })
}

///|
/// Pack Track 2 into BCD nibbles using D as separator and F as pad.
pub fn track2_to_bcd(value : Track2) -> Result[Bytes, IsoError] {
  let text = value.to_string()
  let separator_position = match track2_separator(text) {
    Some(position) => position
    None => return Err(InvalidTrack2("missing separator"))
  }
  let nibbles : Array[Int] = []
  for i = 0; i < text.length(); i = i + 1 {
    if i == separator_position {
      nibbles.push(13)
    } else {
      nibbles.push(text[i].to_int() - 48)
    }
  }
  if nibbles.length() % 2 == 1 {
    nibbles.push(15)
  }
  let bytes : Array[Byte] = []
  let mut i = 0
  while i < nibbles.length() {
    bytes.push(((nibbles[i] << 4) | nibbles[i + 1]).to_byte())
    i += 2
  }
  Ok(Bytes::from_array(bytes))
}

///|
/// Decode Track 2 BCD nibbles, accepting either D or '=' in the API.
pub fn track2_from_bcd(data : Bytes) -> Result[Track2, IsoError] {
  if data.length() == 0 {
    return Err(InvalidTrack2("empty BCD data"))
  }
  let out = StringBuilder()
  let mut separator_count = 0
  for i = 0; i < data.length(); i = i + 1 {
    let value = data[i].to_int()
    let high = (value >> 4) & 15
    let low = value & 15
    for nibble_index = 0; nibble_index < 2; nibble_index = nibble_index + 1 {
      let nibble = if nibble_index == 0 { high } else { low }
      if nibble == 13 {
        out.write_char('=')
        separator_count += 1
      } else if nibble == 15 && i == data.length() - 1 && nibble_index == 1 {
        ()
      } else if nibble <= 9 {
        out.write_char((48 + nibble).unsafe_to_char())
      } else {
        return Err(InvalidTrack2("invalid BCD nibble"))
      }
    }
  }
  if separator_count != 1 {
    return Err(InvalidTrack2("expected one BCD separator"))
  }
  parse_track2(out.to_string())
}

///|
/// Extract the issuer identification number prefix from a valid PAN.
pub fn pan_issuer_prefix(value : String) -> Result[String, IsoError] {
  match parse_pan(value) {
    Ok(info) => Ok(info.issuer_prefix)
    Err(error) => Err(error)
  }
}

///|
/// Compare two PANs without exposing their digits to callers.
pub fn pan_equal(left : String, right : String) -> Bool {
  bytes_equal(text_to_ascii(2, left).unwrap(), text_to_ascii(2, right).unwrap())
}