// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0

// The formats that name a host or an address: the two IP literals, the two
// hostnames, and the two mailboxes.
//
// A hostname is a name in the DNS as IDNA2008 defines it, and the tables that
// decide which code points a label may hold are generated from RFC 5892 into
// `idna_classes.mbt`. The mapping, the normalisation and the Punycode are
// moonurl's: a U-label and its A-label are one name written two ways, and this
// asks the same library that writes them which is which.

///|
/// Which of the two spellings a format names: the ASCII one every resolver in
/// the world reads, or the international one that carries its own script.
priv enum Reach {
  Ascii
  International
}

///|
/// Whether a code point is in a table of inclusive ranges, by bisection.
fn in_ranges(ranges : Array[Int32], code : Int32) -> Bool {
  let mut low = 0
  let mut high = ranges.length() / 2 - 1
  while low <= high {
    let middle = (low + high) / 2
    let start = ranges[middle * 2]
    let end = ranges[middle * 2 + 1]
    if code < start {
      high = middle - 1
    } else if code > end {
      low = middle + 1
    } else {
      return true
    }
  }
  false
}

///|
fn in_span(code : Int32, low : Int32, high : Int32) -> Bool {
  code >= low && code <= high
}

///|
fn hex_value(code : Int32) -> Int32 {
  if code >= 0x30 && code <= 0x39 {
    code - 0x30
  } else if code >= 0x41 && code <= 0x46 {
    code - 0x41 + 10
  } else if code >= 0x61 && code <= 0x66 {
    code - 0x61 + 10
  } else {
    -1
  }
}

///|
/// An RFC 2673 dotted quad, as RFC 3986 §3.2.2 narrowed it: four decimal
/// octets, each 0 to 255, and no leading zero — a `010` is an octal spelling
/// some parsers honour, so it is not read here at all.
fn is_ipv4(text : String) -> Bool {
  let view = text[:]
  let mut i = 0
  for part = 0; part < 4; part = part + 1 {
    let start = i
    let mut value = 0
    while i < view.length() && digit_at(view, i) >= 0 {
      value = value * 10 + digit_at(view, i)
      i = i + 1
    }
    let width = i - start
    guard width > 0 && width <= 3 else { return false }
    guard value <= 255 else { return false }
    guard width == 1 || view[start] != '0' else { return false }
    if part < 3 {
      guard i < view.length() && view[i] == '.' else { return false }
      i = i + 1
    }
  }
  i == view.length()
}

///|
/// An RFC 4291 address: up to eight groups of one to four hexadecimal digits,
/// and at most one `::` standing for the groups left out.
fn is_ipv6(text : String) -> Bool {
  let view = text[:]
  let n = view.length()
  guard n > 0 else { return false }
  let mut i = 0
  let mut groups = 0
  let mut gap = false
  // A leading `::` is the elision, and `::` alone is the address of zeros.
  if view[0] == ':' {
    guard n > 1 && view[1] == ':' else { return false }
    gap = true
    i = 2
    if i == n {
      return true
    }
  }
  while i < n {
    let start = i
    let mut width = 0
    while i < n && hex_value(view[i].to_int()) >= 0 {
      i = i + 1
      width = width + 1
    }
    guard width > 0 else { return false }
    if i < n && view[i] == '.' {
      // What was read as hexadecimal is the first octet of an embedded IPv4
      // address, which stands for the last two groups.
      guard is_ipv4(view[start:].to_owned()) else { return false }
      groups = groups + 2
      i = n
      break
    }
    guard width <= 4 else { return false }
    groups = groups + 1
    if i == n {
      break
    }
    guard view[i] == ':' else { return false }
    if i + 1 < n && view[i + 1] == ':' {
      guard !gap else { return false }
      gap = true
      i = i + 2
      if i == n {
        break
      }
    } else {
      i = i + 1
      guard i < n else { return false }
    }
  }
  // The elision stands for at least one group, so it cannot be there when all
  // eight are written out.
  if gap {
    groups < 8
  } else {
    groups == 8
  }
}

///|
/// Whether every code point is ASCII, which `hostname` and `email` are and
/// their IDN twins are not.
fn is_ascii(text : String) -> Bool {
  for c in text {
    if c.to_int() > 0x7F {
      return false
    }
  }
  true
}

///|
/// An RFC 1123 host name: labels of letters, digits and hyphens, joined by dots,
/// none of them empty, longer than 63, or beginning or ending with a hyphen.
///
/// An `xn--` label is an A-label, and an A-label is only a host name if what it
/// stands for is one: it has to decode to a label IDNA2008 permits, and encode
/// back to itself, because two spellings of one name is one name too many.
fn is_hostname(text : String) -> Bool {
  guard is_ascii(text) else { return false }
  labels_ok(text, Ascii)
}

///|
/// An RFC 5890 internationalised host name: the same name, written in whatever
/// script its owner writes it in, with the A-labels every other resolver sees.
fn is_idn_hostname(text : String) -> Bool {
  let outcome = @idna.to_ascii(text, options=@idna.strict)
  guard outcome.is_ok() else { return false }
  guard labels_ok(outcome.name, International) else { return false }
  // The mapping rewrites a name it can read, so an A-label that was not
  // canonical comes back canonical and would be waved through. It was written
  // wrong, and a name is not one because the reader could repair it.
  input_a_labels_ok(text)
}

///|
/// Every A-label as it was written, before any mapping: a label spelled `xn--`
/// has to decode and encode back to itself.
fn input_a_labels_ok(text : String) -> Bool {
  let out : Array[String] = []
  let piece = StringBuilder()
  for c in text {
    // The four dots IDNA treats as separators.
    if c == '.' || c == '\u{3002}' || c == '\u{FF0E}' || c == '\u{FF61}' {
      out.push(piece.to_string())
      piece.reset()
    } else {
      piece.write_char(c)
    }
  }
  out.push(piece.to_string())
  for label in out {
    let lower = lower(label)
    if lower.length() > 4 && lower[0:4].to_owned() == "xn--" {
      // The body is case-insensitive, and a Punycode body in capitals still
      // decodes; what has to hold is that it decodes to what it says.
      guard a_label_ok(lower) else { return false }
    }
  }
  true
}

///|
/// The labels of a name, each checked on its own and the whole measured as the
/// DNS measures it.
fn labels_ok(text : String, reach : Reach) -> Bool {
  guard text.length() > 0 && text.length() <= 253 else { return false }
  let labels = split_labels(text)
  guard labels.length() > 0 else { return false }
  for label in labels {
    guard label.length() > 0 && label.length() <= 63 else { return false }
    if reach is Ascii {
      for c in label {
        let code = c.to_int()
        let alnum = (code >= 0x30 && code <= 0x39) ||
          (code >= 0x41 && code <= 0x5A) ||
          (code >= 0x61 && code <= 0x7A)
        guard alnum || code == 0x2D else { return false }
      }
      guard label[0] != '-' && label[label.length() - 1] != '-' else {
        return false
      }
    }
    // The ACE prefix is read without regard to case, and the body below it is
    // Punycode, which has none either.
    let spelled = lower(label)
    if spelled.length() > 4 && spelled[0:4].to_owned() == "xn--" {
      guard a_label_ok(spelled) else { return false }
    }
  }
  true
}

///|
fn split_labels(text : String) -> Array[String] {
  let out : Array[String] = []
  let piece = StringBuilder()
  for c in text {
    if c == '.' {
      out.push(piece.to_string())
      piece.reset()
    } else {
      piece.write_char(c)
    }
  }
  out.push(piece.to_string())
  out
}

///|
/// An A-label: `xn--` and a Punycode body that decodes to a label IDNA2008
/// permits, and that encodes back to the label it came from.
fn a_label_ok(label : String) -> Bool {
  let body = label[4:].to_owned()
  let decoded = @idna.decode(body) catch { _ => return false }
  // A label whose A-label form is all ASCII has no A-label form at all.
  guard !is_ascii(decoded) else { return false }
  guard u_label_ok(decoded) else { return false }
  // `xn---9uc` and its kind decode, but not to themselves.
  @idna.encode(decoded) == body
}

///|
/// Whether a U-label is one IDNA2008 permits: every code point allowed, and
/// every contextual one where the standard says it may sit.
fn u_label_ok(label : String) -> Bool {
  let points : Array[Int32] = []
  for c in label {
    points.push(c.to_int())
  }
  guard points.length() > 0 && points.length() <= 63 else { return false }
  for point in points {
    guard in_ranges(idna_allowed, point) else { return false }
  }
  // A combining mark needs something to combine with, and a label does not
  // begin with one.
  guard !in_ranges(idna_marks, points[0]) else { return false }
  // The third and fourth characters may not both be a hyphen: that is how an
  // A-label announces itself, and a U-label that looks like one is a confusion
  // the standard refuses (RFC 5891 §4.2.3.1).
  if points.length() >= 4 && points[2] == 0x2D && points[3] == 0x2D {
    return false
  }
  contextj_ok(points) && contexto_ok(points)
}

///|
/// RFC 5892 Appendix A: a join control follows a virama, and a zero width
/// non-joiner may also sit between two letters that a joining script would join.
fn contextj_ok(points : Array[Int32]) -> Bool {
  for index, point in points {
    if in_ranges(idna_contextj, point) {
      let virama = index > 0 && in_ranges(idna_virama, points[index - 1])
      if point == 0x200D {
        // A zero width joiner has no second chance.
        guard virama else { return false }
      } else {
        guard virama || joined_across(points, index) else { return false }
      }
    }
  }
  true
}

///|
/// Whether the letters around a zero width non-joiner are ones it may separate.
fn joined_across(points : Array[Int32], index : Int32) -> Bool {
  let mut left = index - 1
  while left >= 0 && in_ranges(idna_join_transparent, points[left]) {
    left = left - 1
  }
  guard left >= 0 && in_ranges(idna_join_left, points[left]) else {
    return false
  }
  let mut right = index + 1
  while right < points.length() &&
        in_ranges(idna_join_transparent, points[right]) {
    right = right + 1
  }
  guard right < points.length() else { return false }
  in_ranges(idna_join_right, points[right])
}

///|
/// RFC 5892 §2's contextual rules: the code points whose meaning depends on
/// their neighbours, and the neighbours they need.
fn contexto_ok(points : Array[Int32]) -> Bool {
  let mut japanese = false
  let mut arabic_indic = false
  let mut extended_arabic = false
  for point in points {
    // The scripts, not the blocks: the middle dot sits in the Katakana block
    // and belongs to no script, so counting it would let a label of nothing but
    // a middle dot vouch for itself.
    if is_japanese(point) {
      japanese = true
    }
    if in_span(point, 0x660, 0x669) {
      arabic_indic = true
    }
    if in_span(point, 0x6F0, 0x6F9) {
      extended_arabic = true
    }
  }
  // The two Arabic-Indic digit blocks are one numbering, and a label in both is
  // a label in neither.
  guard !(arabic_indic && extended_arabic) else { return false }
  for index, point in points {
    match point {
      0xB7 =>
        // A middle dot stands between two `l`s, which is the Catalan spelling.
        if index == 0 ||
          index + 1 >= points.length() ||
          points[index - 1] != 0x6C ||
          points[index + 1] != 0x6C {
          return false
        }
      0x375 =>
        // A Greek keraia takes a Greek letter after it.
        if index + 1 >= points.length() || !is_greek(points[index + 1]) {
          return false
        }
      0x5F3 | 0x5F4 =>
        // A Hebrew geresh or gershayim takes a Hebrew letter before it.
        if index == 0 || !is_hebrew(points[index - 1]) {
          return false
        }
      0x30FB => if !japanese { return false }
      _ => ()
    }
  }
  true
}

///|
/// Hiragana, Katakana or Han, which is what a Katakana middle dot needs beside
/// it to be a word rather than a dot.
fn is_japanese(point : Int32) -> Bool {
  in_span(point, 0x3041, 0x3096) ||
  in_span(point, 0x309D, 0x309F) ||
  in_span(point, 0x30A1, 0x30FA) ||
  in_span(point, 0x30FD, 0x30FF) ||
  in_span(point, 0x31F0, 0x31FF) ||
  in_span(point, 0xFF66, 0xFF9D) ||
  point == 0x3005 ||
  point == 0x3007 ||
  in_span(point, 0x3021, 0x3029) ||
  in_span(point, 0x3038, 0x303B) ||
  in_span(point, 0x3400, 0x4DBF) ||
  in_span(point, 0x4E00, 0x9FFF) ||
  in_span(point, 0xF900, 0xFAFF) ||
  in_span(point, 0x20000, 0x2FA1F)
}

///|
fn is_greek(point : Int32) -> Bool {
  in_span(point, 0x370, 0x3FF) || in_span(point, 0x1F00, 0x1FFF)
}

///|
fn is_hebrew(point : Int32) -> Bool {
  in_span(point, 0x590, 0x5FF) || in_span(point, 0xFB1D, 0xFB4F)
}

///|
/// An RFC 5321 mailbox: `local@domain`, where the local part is a dot-separated
/// run of atoms or one quoted string, and the domain is a host name or an
/// address literal.
///
/// `reach` is the difference between `email` and `idn-email`: RFC 6531 widened
/// the local part to UTF-8 and left everything else alone.
fn is_email(text : String, reach : Reach) -> Bool {
  let view = text[:]
  let n = view.length()
  let at = match local_end(view, reach) {
    Some(found) => found
    None => return false
  }
  guard at < n && view[at] == '@' else { return false }
  let domain = view[at + 1:].to_owned()
  guard domain.length() > 0 else { return false }
  if domain[0] == '[' {
    guard domain[domain.length() - 1] == ']' else { return false }
    return is_address_literal(domain[1:domain.length() - 1].to_owned())
  }
  if reach is Ascii {
    is_hostname(domain)
  } else {
    is_idn_hostname(domain)
  }
}

///|
/// Where the local part ends, which is the `@` that separates it from the
/// domain — and not one inside a quoted string.
fn local_end(text : StringView, reach : Reach) -> Int32? {
  let n = text.length()
  guard n > 0 else { return None }
  if text[0] == '"' {
    return quoted_end(text, reach)
  }
  // A dot-atom: atoms joined by single dots, and no dot at either end.
  let mut i = 0
  while i < n {
    let start = i
    while i < n && is_atext(text[i].to_int(), reach) {
      i = i + 1
    }
    guard i > start else { return None }
    if i < n && text[i] == '.' {
      i = i + 1
      guard i < n else { return None }
    } else {
      break
    }
  }
  Some(i)
}

///|
/// The `@` that ends a quoted local part, the closing quote being part of it.
fn quoted_end(text : StringView, reach : Reach) -> Int32? {
  let n = text.length()
  let mut i = 1
  while i < n {
    let c = text[i]
    if c == '"' {
      return Some(i + 1)
    }
    if c == '\\' {
      // A quoted pair: the backslash and whatever it protects.
      guard i + 1 < n else { return None }
      guard quoted_pair_ok(text[i + 1].to_int(), reach) else { return None }
      i = i + 2
      continue
    }
    guard qtext_ok(c.to_int(), reach) else { return None }
    i = i + 1
  }
  None
}

///|
/// An RFC 5321 atom character, widened to UTF-8 by RFC 6531 when the format is
/// the international one.
fn is_atext(code : Int32, reach : Reach) -> Bool {
  if code > 0x7F {
    return reach is International
  }
  let alnum = (code >= 0x30 && code <= 0x39) ||
    (code >= 0x41 && code <= 0x5A) ||
    (code >= 0x61 && code <= 0x7A)
  if alnum {
    return true
  }
  match code {
    0x21
    | 0x23
    | 0x24
    | 0x25
    | 0x26
    | 0x27
    | 0x2A
    | 0x2B
    | 0x2D
    | 0x2F
    | 0x3D
    | 0x3F
    | 0x5E
    | 0x5F
    | 0x60
    | 0x7B
    | 0x7C
    | 0x7D
    | 0x7E => true
    _ => false
  }
}

///|
/// What may stand inside a quoted string: printable ASCII except the quote and
/// the backslash, which are the two the quoting exists for.
fn qtext_ok(code : Int32, reach : Reach) -> Bool {
  if code > 0x7F {
    return reach is International
  }
  (code >= 0x20 && code <= 0x21) ||
  (code >= 0x23 && code <= 0x5B) ||
  (code >= 0x5D && code <= 0x7E)
}

///|
fn quoted_pair_ok(code : Int32, reach : Reach) -> Bool {
  if code > 0x7F {
    return reach is International
  }
  (code >= 0x20 && code <= 0x7E) || code == 0x09
}

///|
/// The inside of an RFC 5321 address literal: an IPv4 address, or `IPv6:` and
/// one. Nothing else, which is what rules out `[1.2.3]` and the narrative forms
/// the standard once allowed.
fn is_address_literal(inner : String) -> Bool {
  if inner.length() >= 5 && lower(inner[0:5].to_owned()) == "ipv6:" {
    return is_ipv6(inner[5:].to_owned())
  }
  is_ipv4(inner)
}

///|
fn lower(text : String) -> String {
  let out = StringBuilder()
  for c in text {
    let code = c.to_int()
    if code >= 0x41 && code <= 0x5A {
      out.write_char((code + 0x20).unsafe_to_char())
    } else {
      out.write_char(c)
    }
  }
  out.to_string()
}