///|
/// Represents a URL host per the WHATWG URL Standard.
/// See: https://url.spec.whatwg.org/#host-representation
/// Can be a domain name, IPv4 address, IPv6 address, or opaque string.
pub enum Host {
  Domain(String)
  IPv4(IPv4)
  IPv6(IPv6)
  Opaque(String)
} derive(ToJson, Debug)

///|
/// Serialize host per WHATWG URL spec
pub fn Host::to_string(self : Host) -> String {
  match self {
    Domain(domain) => domain
    IPv4(ipv4) => ipv4.to_string()
    IPv6(ipv6) => "[\{ipv6.to_string()}]"
    Opaque(opaque_) => opaque_
  }
}

///|
/// Implement Show trait for Host, outputting the serialized host string
pub impl Show for Host with fn output(self : Host, logger : &Logger) -> Unit {
  logger.write_string(self.to_string())
}

///|
/// Convert host to Unicode form for display (Punycode → Unicode)
pub fn Host::to_unicode(self : Host) -> String {
  match self {
    Domain(domain) =>
      @idna.to_unicode(domain) catch {
        _ => domain // If IDNA conversion fails, return as-is
      }
    IPv4(ipv4) => ipv4.to_string()
    IPv6(ipv6) => "[\{ipv6.to_string()}]"
    Opaque(s) => s
  }
}

///|
/// URL parsing validation errors per the WHATWG URL Standard.
/// Each variant represents a specific parsing failure condition.
suberror ValidationError {
  DomainToAscii
  HostInvalidCodePoint
  IPv4EmptyPart
  IPv4TooManyParts
  IPv4NonNumericPart
  IPv4OutOfRangePart
  IPv6InvalidCompression
  IPv6MultipleCompression
  IPv6TooManyPieces
  IPv6InvalidCodePoint
  IPv6TooFewPieces
  IPv6Unclosed
  IPv4InIPv6InvalidCodePoint
  IPv4InIPv6TooManyPieces
  IPv4InIPv6OutOfRangePart
  IPv4InIPv6TooFewParts
  MissingSchemeNonRelativeUrl
  SpecialSchemeMissingFollowingSolidus
  InvalidReverseSolidus
  InvalidCredentials
  HostMissing
} derive(ToJson, Debug)

///|
pub impl Show for ValidationError with fn output(self, logger) {
  let message = match self {
    DomainToAscii => "DomainToAscii"
    HostInvalidCodePoint => "HostInvalidCodePoint"
    IPv4EmptyPart => "IPv4EmptyPart"
    IPv4TooManyParts => "IPv4TooManyParts"
    IPv4NonNumericPart => "IPv4NonNumericPart"
    IPv4OutOfRangePart => "IPv4OutOfRangePart"
    IPv6InvalidCompression => "IPv6InvalidCompression"
    IPv6MultipleCompression => "IPv6MultipleCompression"
    IPv6TooManyPieces => "IPv6TooManyPieces"
    IPv6InvalidCodePoint => "IPv6InvalidCodePoint"
    IPv6TooFewPieces => "IPv6TooFewPieces"
    IPv6Unclosed => "IPv6Unclosed"
    IPv4InIPv6InvalidCodePoint => "IPv4InIPv6InvalidCodePoint"
    IPv4InIPv6TooManyPieces => "IPv4InIPv6TooManyPieces"
    IPv4InIPv6OutOfRangePart => "IPv4InIPv6OutOfRangePart"
    IPv4InIPv6TooFewParts => "IPv4InIPv6TooFewParts"
    MissingSchemeNonRelativeUrl => "MissingSchemeNonRelativeUrl"
    SpecialSchemeMissingFollowingSolidus =>
      "SpecialSchemeMissingFollowingSolidus"
    InvalidReverseSolidus => "InvalidReverseSolidus"
    InvalidCredentials => "InvalidCredentials"
    HostMissing => "HostMissing"
  }
  logger.write_string(message)
}

///|
fn percent_decode_string(input : StringView) -> Bytes {
  let bytes = @encoding/utf8.encode(input, bom=false)
  return percent_decode_bytes(bytes)
}

///|
fn percent_decode_bytes(input : Bytes) -> Bytes {
  let output = Buffer()
  for view = input[:] {
    match view {
      [b'%', c0, c1, .. rest] => {
        let b0 = match c0 {
          b'0'..=b'9' => c0.to_int() - b'0'.to_int()
          b'a'..=b'f' => c0.to_int() - b'a'.to_int() + 10
          b'A'..=b'F' => c0.to_int() - b'A'.to_int() + 10
          _ => {
            output.write_byte(b'%')
            continue view[1:]
          }
        }
        let b1 = match c1 {
          b'0'..=b'9' => c1.to_int() - b'0'.to_int()
          b'a'..=b'f' => c1.to_int() - b'a'.to_int() + 10
          b'A'..=b'F' => c1.to_int() - b'A'.to_int() + 10
          _ => {
            output.write_byte(b'%')
            output.write_byte(c0)
            continue view[2:]
          }
        }
        output.write_byte(((b0 << 4) | b1).to_byte())
        continue rest
      }
      [byte, .. rest] => {
        output.write_byte(byte)
        continue rest
      }
      [] => break
    }
  }
  output.contents()
}

///|
/// Check if a string contains any forbidden domain code point per WHATWG URL spec
/// Forbidden domain code points = forbidden host code points + C0 controls + % + DEL
fn contains_forbidden_domain_codepoint(s : String) -> Bool {
  for c in s {
    // C0 controls (U+0000-U+001F) and DEL (U+007F)
    if c is ('\u{0000}'..='\u{001f}' | '\u{007f}') {
      return true
    }
    // Forbidden host code points plus %
    if c
      is (' '
      | '#'
      | '%'
      | '/'
      | ':'
      | '<'
      | '>'
      | '?'
      | '@'
      | '['
      | '\\'
      | ']'
      | '^'
      | '|') {
      return true
    }
  }
  false
}

///|
/// Parse a host string per WHATWG URL spec.
/// See: https://url.spec.whatwg.org/#host-parsing
/// Handles IPv4, IPv6 (in brackets), domain names with IDNA/Punycode, and opaque hosts.
///
/// The host parser takes a string input with an optional boolean isOpaque
/// (default false), and then runs these steps:
/// 1. If input starts with U+005B ([), then:
///    1.1. If input does not end with U+005D (]), IPv6-unclosed validation error,
///         return failure.
///    1.2. Return the result of IPv6 parsing input with its leading U+005B ([)
///         and trailing U+005D (]) removed.
/// 2. If isOpaque is true, then return the result of opaque-host parsing input.
/// 3. Assert: input is not the empty string.
/// 4. Let domain be the result of running UTF-8 decode without BOM on the
///    percent-decoding of input.
/// 5. Let asciiDomain be the result of running domain to ASCII with domain and false.
/// 6. If asciiDomain is failure, then return failure.
/// 7. If asciiDomain contains a forbidden domain code point, domain-invalid-code-point
///    validation error, return failure.
/// 8. If asciiDomain ends in a number, then return the result of IPv4 parsing asciiDomain.
/// 9. Return asciiDomain.
pub fn Host::parse(
  input : StringView,
  is_opaque? : Bool = false,
) -> Host raise ValidationError {
  match input {
    // 1. If input starts with U+005B ([)
    ['[', .. rest, ']'] => {
      // 1.2. Return the result of IPv6 parsing
      let ipv6 = IPv6::parse(rest)
      IPv6(ipv6)
    }
    ['[', ..] => raise IPv6Unclosed // 1.1. IPv6-unclosed validation error
    input => {
      // 2. If isOpaque is true, return the result of opaque-host parsing
      if is_opaque {
        let opaque_host = Host::parse_opaque(input)
        return Opaque(opaque_host)
      }
      // 4. Let domain be the result of UTF-8 decode without BOM on percent-decoding of input
      let domain = @encoding/utf8.decode_lossy(percent_decode_string(input))
      // 5. Let asciiDomain be the result of running domain to ASCII
      // Per WHATWG, empty labels are allowed (unlike strict DNS/IDNA)
      let ascii_domain = @idna.to_ascii(
        domain,
        use_std3_ascii_rules=false,
        verify_dns_length=false,
      ) catch {
        EmptyLabel => {
          // Allow empty labels per WHATWG only if domain consists of
          // valid ASCII domain characters (intentional empty labels like "." or "..")
          // Reject if empty label comes from IDNA mapping (e.g., soft hyphen -> "")
          let is_valid_ascii_domain = domain
            .iter()
            .all(fn(c) {
              c.is_ascii_lowercase() ||
              c.is_ascii_uppercase() ||
              c.is_ascii_digit() ||
              c is ('.' | '-')
            })
          if is_valid_ascii_domain && !domain.is_empty() {
            domain
          } else {
            raise DomainToAscii
          }
        }
        _ => {
          // Per WHATWG: If IDNA fails but the string is ASCII and doesn't
          // contain forbidden domain code points, use it as-is
          // This allows hosts like `!"$&'()*+,-.;=_`{}~` which are valid per spec
          // BUT: domains containing "xn--" labels must be valid punycode - reject if IDNA fails
          let is_ascii = domain.iter().all(fn(c) { c.to_int() < 128 })
          let has_punycode_label = domain.to_lower().contains("xn--")
          if is_ascii &&
            !contains_forbidden_domain_codepoint(domain) &&
            !has_punycode_label {
            domain
          } else {
            raise DomainToAscii
          }
        }
      }
      // 7. If asciiDomain contains a forbidden domain code point, return failure
      if contains_forbidden_domain_codepoint(ascii_domain) {
        raise HostInvalidCodePoint
      }
      // 8. If asciiDomain ends in a number, return the result of IPv4 parsing
      if ends_in_a_number(ascii_domain) {
        return IPv4(IPv4::parse(ascii_domain))
      }
      // 9. Return asciiDomain
      return Domain(ascii_domain)
    }
  }
}

///|
/// Check if a string ends in a number per WHATWG URL spec.
/// See: https://url.spec.whatwg.org/#ends-in-a-number-checker
/// A string input ends in a number if:
/// 1. Let parts be the result of strictly splitting input on U+002E (.).
/// 2. If the last item in parts is the empty string, then:
///    2.1. If parts's size is 1, then return false.
///    2.2. Remove the last item from parts.
/// 3. Let last be the last item in parts.
/// 4. If last is non-empty and contains only ASCII digits, then return true.
/// 5. If parsing last as an IPv4 number does not return failure, then return true.
/// 6. Return false.
fn ends_in_a_number(input : StringView) -> Bool {
  let parts = input.split(".").collect()
  match parts {
    [] | [""] => return false
    [.., last, ""] | [.., last] => {
      if last is "" {
        return false
      }
      match last {
        ['0', 'x' | 'X', .. last] =>
          for c in last {
            if c is ('0'..='9' | 'a'..='f' | 'A'..='F') {
              continue
            } else {
              return false
            }
          } nobreak {
            return true
          }
        ['0'..='9', .. last] =>
          for c in last {
            if c is ('0'..='9') {
              continue
            } else {
              return false
            }
          } nobreak {
            return true
          }
        _ => return false
      }
    }
  }
}

///|
/// Parse an opaque host per WHATWG URL spec
/// Only rejects forbidden host code points, accepts everything else
fn Host::parse_opaque(input : StringView) -> String raise ValidationError {
  // Check for forbidden host code points
  for c in input {
    if c
      is ('\u{0000}'
      | '\u{0009}'
      | '\u{000a}'
      | '\u{000d}'
      | ' '
      | '#'
      | '/'
      | ':'
      | '<'
      | '>'
      | '?'
      | '@'
      | '['
      | '\\'
      | ']'
      | '^'
      | '|') {
      raise HostInvalidCodePoint
    }
  }
  // Percent-encode C0 controls in the output
  utf8_percent_encode(input, c0_control_percent_encode_set)
}

///|
let hex_digits : ReadOnlyArray[Char] = [
  '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F',
]

///|
/// C0 control percent-encode set.
/// See: https://url.spec.whatwg.org/#c0-control-percent-encode-set
/// The C0 control percent-encode set are the C0 controls and all code points
/// greater than U+007E (~).
fn c0_control_percent_encode_set(char : Char) -> Bool {
  return char is ('\u{0000}'..='\u{001f}' | '\u{007f}'..<_)
}

///|
/// Query percent-encode set.
/// See: https://url.spec.whatwg.org/#query-percent-encode-set
/// The query percent-encode set is the C0 control percent-encode set and
/// U+0020 SPACE, U+0022 ("), U+0023 (#), U+003C (<), and U+003E (>).
fn query_percent_encode_set(char : Char) -> Bool {
  return char
    is ('\u{0000}'..='\u{001f}' | '\u{007f}'..<_ | ' ' | '"' | '#' | '<' | '>')
}

///|
/// Special-query percent-encode set.
/// See: https://url.spec.whatwg.org/#special-query-percent-encode-set
/// The special-query percent-encode set is the query percent-encode set and
/// U+0027 (').
fn special_query_percent_encode_set(char : Char) -> Bool {
  return char
    is ('\u{0000}'..='\u{001f}'
    | '\u{007f}'..<_
    | ' '
    | '"'
    | '#'
    | '<'
    | '>'
    | '\'')
}

///|
/// Fragment percent-encode set.
/// See: https://url.spec.whatwg.org/#fragment-percent-encode-set
/// The fragment percent-encode set is the C0 control percent-encode set and
/// U+0020 SPACE, U+0022 ("), U+003C (<), U+003E (>), and U+0060 (`).
fn fragment_percent_encode_set(char : Char) -> Bool {
  return char
    is ('\u{0000}'..='\u{001f}' | '\u{007f}'..<_ | ' ' | '"' | '<' | '>' | '`')
}

///|
/// Path percent-encode set.
/// See: https://url.spec.whatwg.org/#path-percent-encode-set
/// The path percent-encode set is the query percent-encode set and
/// U+003F (?), U+0060 (`), U+007B ({), and U+007D (}).
fn path_percent_encode_set(char : Char) -> Bool {
  return char
    is ('\u{0000}'..='\u{001f}'
    | '\u{007f}'..<_
    | ' '
    | '"'
    | '#'
    | '<'
    | '>'
    | '?'
    | '^'
    | '`'
    | '{'
    | '}')
}

///|
/// Userinfo percent-encode set.
/// See: https://url.spec.whatwg.org/#userinfo-percent-encode-set
/// The userinfo percent-encode set is the path percent-encode set and
/// U+002F (/), U+003A (:), U+003B (;), U+003D (=), U+0040 (@), U+005B ([)
/// to U+005E (^), and U+007C (|).
fn userinfo_percent_encode_set(char : Char) -> Bool {
  return char
    is ('\u{0000}'..='\u{001f}'
    | '\u{007f}'..<_
    | ' '
    | '"'
    | '#'
    | '<'
    | '>'
    | '?'
    | '^'
    | '`'
    | '{'
    | '}'
    | '/'
    | ':'
    | ';'
    | '='
    | '@'
    | '['..=']'
    | '|')
}

///|
#locals(percent_encode_set)
fn utf8_percent_encode(
  input : StringView,
  percent_encode_set : (Char) -> Bool,
  space_as_plus? : Bool = false,
) -> String {
  let output = StringBuilder::new()
  let encode_output = @encoding/utf8.encode(input)
  for byte in encode_output {
    if space_as_plus && byte is b' ' {
      output.write_char('+')
      continue
    }
    let isomorph = byte.to_char()
    if percent_encode_set(isomorph) {
      output.write_char('%')
      let byte = byte.to_int()
      output.write_char(hex_digits[(byte >> 4) & 0x0f])
      output.write_char(hex_digits[byte & 0x0f])
      continue
    } else {
      output.write_char(isomorph)
    }
  }
  output.to_string()
}