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

// The WHATWG URL parser (§4.4), the one a browser runs. It reads a string
// into the same [`Url`] the RFC 3986 reader fills in, so the two grammars
// share a type and a caller can keep one.

///|
/// A URL under construction: what the standard's algorithms hold while they
/// are reading, before it is a [`Url`].
priv struct Parts {
  mut scheme : String
  mut username : String
  mut password : String
  /// `None` is the standard's null host, which is not an empty one.
  mut host : String?
  mut port : Int32?
  /// The path's segments. An opaque path is one segment holding all of it,
  /// and `opaque_path` says which of the two this is.
  mut path : Array[String]
  mut opaque_path : Bool
  mut query : String?
  mut fragment : String?
}

///|
fn empty_parts() -> Parts {
  {
    scheme: "",
    username: "",
    password: "",
    host: None,
    port: None,
    path: [],
    opaque_path: false,
    query: None,
    fragment: None,
  }
}

///|
/// The states of the parser, named as the standard names them.
priv enum State {
  SchemeStart
  Scheme
  NoScheme
  SpecialRelativeOrAuthority
  PathOrAuthority
  Relative
  RelativeSlash
  SpecialAuthoritySlashes
  SpecialAuthorityIgnoreSlashes
  Authority
  Host
  Hostname
  Port
  File
  FileSlash
  FileHost
  PathStart
  Path
  OpaquePath
  Query
  Fragment
} derive(Eq)

// --------------------------------------------------------- percent encoding

///|
/// The percent-encode sets of §1.3: which code points a component escapes.
/// Each set contains the one before it, and the C0 control set is the floor.
priv enum Escaping {
  /// C0 controls and everything past `~`.
  C0Control
  /// A fragment, which may carry a `#`.
  Fragment
  /// A query, which may carry a backtick.
  Query
  /// A special scheme's query, where `'` is escaped too.
  SpecialQuery
  /// A path.
  Path
  /// Credentials, the strictest of them.
  Userinfo
}

///|
/// Whether `set` escapes a code point.
fn escapes(set : Escaping, point : Int32) -> Bool {
  if point < 0x20 || point > 0x7E {
    return true
  }
  match set {
    C0Control => false
    Fragment =>
      point == 0x20 ||
      point == 0x22 ||
      point == 0x3C ||
      point == 0x3E ||
      point == 0x60
    Query =>
      point == 0x20 ||
      point == 0x22 ||
      point == 0x23 ||
      point == 0x3C ||
      point == 0x3E
    SpecialQuery =>
      point == 0x20 ||
      point == 0x22 ||
      point == 0x23 ||
      point == 0x27 ||
      point == 0x3C ||
      point == 0x3E
    Path =>
      point == 0x20 ||
      point == 0x22 ||
      point == 0x23 ||
      point == 0x3C ||
      point == 0x3E ||
      point == 0x3F ||
      point == 0x5E ||
      point == 0x60 ||
      point == 0x7B ||
      point == 0x7D
    Userinfo =>
      point == 0x20 ||
      point == 0x22 ||
      point == 0x23 ||
      point == 0x2F ||
      point == 0x3A ||
      point == 0x3B ||
      point == 0x3C ||
      point == 0x3D ||
      point == 0x3E ||
      point == 0x3F ||
      point == 0x40 ||
      point == 0x5B ||
      point == 0x5C ||
      point == 0x5D ||
      point == 0x5E ||
      point == 0x60 ||
      point == 0x7B ||
      point == 0x7C ||
      point == 0x7D
  }
}

///|
/// Percent-encode one code point, as the UTF-8 it is, into `out`.
fn escape_point(out : StringBuilder, character : Char, set : Escaping) -> Unit {
  let point = character.to_int()
  if !escapes(set, point) {
    out.write_char(character)
    return
  }
  for byte in @utf8.encode(one(character)[:]) {
    let value = byte.to_int()
    out.write_char('%')
    out.write_char(hex_char(value >> 4))
    out.write_char(hex_char(value & 0xF))
  }
}

///|
fn escape_each(out : StringBuilder, text : StringView, set : Escaping) -> Unit {
  for character in text {
    escape_point(out, character, set)
  }
}

///|
/// Percent-encode a whole string for `set`.
fn escape_with(text : StringView, set : Escaping) -> String {
  let out = StringBuilder()
  escape_each(out, text, set)
  out.to_string()
}

///|
fn hex_char(value : Int32) -> Char {
  if value < 10 {
    (0x30 + value).unsafe_to_char()
  } else {
    (0x41 + value - 10).unsafe_to_char()
  }
}

///|
/// One character as a string.
fn one(character : Char) -> String {
  let out = StringBuilder()
  out.write_char(character)
  out.to_string()
}

///|
/// The code point at `pointer`, or `None` for the end of the input.
fn code(input : Array[Char], pointer : Int32) -> Char? {
  if pointer >= 0 && pointer < input.length() {
    Some(input[pointer])
  } else {
    None
  }
}

// ------------------------------------------------------------------ schemes

///|
fn letter(point : Int32) -> Bool {
  (point >= 0x41 && point <= 0x5A) || (point >= 0x61 && point <= 0x7A)
}

///|
fn plus_minus_dot(character : Char) -> Bool {
  character == '+' || character == '-' || character == '.'
}

///|
fn down(character : Char) -> Char {
  let point = character.to_int()
  if point >= 0x41 && point <= 0x5A {
    (point + 32).unsafe_to_char()
  } else {
    character
  }
}

///|
/// The six schemes the standard gives a port and a set of rules (its "special
/// schemes").
fn special(scheme : StringView) -> Bool {
  port_of(scheme) is Some(_) || scheme == "file"
}

///|
/// The port a scheme means when none is written.
fn port_of(scheme : StringView) -> Int32? {
  match scheme {
    "ftp" => Some(21)
    "http" => Some(80)
    "https" => Some(443)
    "ws" => Some(80)
    "wss" => Some(443)
    _ => None
  }
}

// ------------------------------------------------------------------ parsing

///|
/// Read `text` as the standard would, optionally against a base.
fn whatwg_decode(text : StringView, base : Url?) -> Url raise Refused {
  let base = match base {
    Some(base) => Some(parts_of(base))
    None => None
  }
  to_url(parse(text, base, None, None))
}

///|
/// The parts of an existing URL, read back the way the standard reads a base:
/// through its serialisation.
fn parts_of(url : Url) -> Parts raise Refused {
  parse(url.encode()[:], None, None, None)
}

///|
/// The basic URL parser (§4.4).
///
/// `seed` and `only` are the state-only arguments the constructors and
/// the setters use: with them, the parser modifies the URL it is given instead
/// of building one, and stops where the setter's value ends.
fn parse(
  text : StringView,
  base : Parts?,
  seed : Parts?,
  only : State?,
) -> Parts raise Refused {
  // Trimming belongs to reading a whole URL; a setter's value is taken as it
  // is, so that a leading space reaches the path as `%20`.
  let input = clean(text, trim=seed is None && only is None)
  let n = input.length()
  let url = match seed {
    Some(seed) => seed
    None => empty_parts()
  }
  let mut state = match only {
    Some(state) => state
    None => SchemeStart
  }
  let mut buffer : Array[Char] = []
  let mut at_sign = false
  let mut in_brackets = false
  let mut password_token = false
  let mut host_at = 0
  let mut pointer = 0
  while true {
    let c = code(input, pointer)
    match state {
      SchemeStart =>
        match c {
          Some(character) if letter(character.to_int()) => {
            buffer.push(down(character))
            state = Scheme
          }
          _ =>
            if only is None {
              state = NoScheme
              pointer -= 1
            } else {
              raise Scheme(at=pointer)
            }
        }
      Scheme =>
        match c {
          Some(character) if alpha(character.to_int()) ||
            digit(character.to_int()) ||
            plus_minus_dot(character) => buffer.push(down(character))
          Some(':') => {
            if only is Some(_) {
              let name = text_of(buffer)
              if special(url.scheme) != special(name[:]) {
                return url
              }
              if (credentials(url) || url.port is Some(_)) && name == "file" {
                return url
              }
              if url.scheme == "file" && url.host is Some("") {
                return url
              }
            }
            url.scheme = text_of(buffer)
            if only is Some(_) {
              if url.port == port_of(url.scheme[:]) {
                url.port = None
              }
              return url
            }
            buffer = []
            if url.scheme == "file" {
              state = File
            } else if special(url.scheme[:]) {
              match base {
                Some(base) if base.scheme == url.scheme =>
                  state = SpecialRelativeOrAuthority
                _ => state = SpecialAuthoritySlashes
              }
            } else if code(input, pointer + 1) is Some('/') {
              state = PathOrAuthority
              pointer += 1
            } else {
              // A scheme with no authority and no path: an opaque_path path follows.
              url.path = [""]
              url.opaque_path = true
              state = OpaquePath
            }
          }
          _ =>
            if only is None {
              buffer = []
              state = NoScheme
              pointer = -1
            } else {
              raise Scheme(at=pointer)
            }
        }
      NoScheme =>
        match base {
          None => raise Scheme(at=pointer)
          Some(base) =>
            if base.opaque_path && c != Some('#') {
              raise Scheme(at=pointer)
            } else if base.opaque_path {
              url.scheme = base.scheme
              url.path = base.path.copy()
              url.opaque_path = true
              url.query = base.query
              url.fragment = Some("")
              state = Fragment
            } else if base.scheme != "file" {
              state = Relative
              pointer -= 1
            } else {
              state = File
              pointer -= 1
            }
        }
      SpecialRelativeOrAuthority =>
        if c == Some('/') && code(input, pointer + 1) == Some('/') {
          state = SpecialAuthorityIgnoreSlashes
          pointer += 1
        } else {
          state = Relative
          pointer -= 1
        }
      PathOrAuthority =>
        if c == Some('/') {
          state = Authority
        } else {
          state = Path
          pointer -= 1
        }
      Relative =>
        match base {
          None => raise Scheme(at=pointer)
          Some(base) => {
            url.scheme = base.scheme
            if c == Some('/') || (special(url.scheme[:]) && c == Some('\\')) {
              state = RelativeSlash
            } else {
              url.username = base.username
              url.password = base.password
              url.host = base.host
              url.port = base.port
              url.path = base.path.copy()
              url.query = base.query
              if c == Some('?') {
                url.query = Some("")
                state = Query
              } else if c == Some('#') {
                url.fragment = Some("")
                state = Fragment
              } else if c is Some(_) {
                url.query = None
                shorten(url)
                state = Path
                pointer -= 1
              }
            }
          }
        }
      RelativeSlash =>
        if special(url.scheme[:]) && (c == Some('/') || c == Some('\\')) {
          state = SpecialAuthorityIgnoreSlashes
        } else if c == Some('/') {
          state = Authority
        } else {
          match base {
            None => raise Scheme(at=pointer)
            Some(base) => {
              url.username = base.username
              url.password = base.password
              url.host = base.host
              url.port = base.port
            }
          }
          state = Path
          pointer -= 1
        }
      SpecialAuthoritySlashes =>
        if c == Some('/') && code(input, pointer + 1) == Some('/') {
          state = SpecialAuthorityIgnoreSlashes
          pointer += 1
        } else {
          state = SpecialAuthorityIgnoreSlashes
          pointer -= 1
        }
      SpecialAuthorityIgnoreSlashes =>
        if c != Some('/') && c != Some('\\') {
          state = Authority
          pointer -= 1
        }
      Authority =>
        if c == Some('@') {
          if at_sign {
            buffer = string_chars("%40") + buffer
          }
          at_sign = true
          for character in buffer {
            if character == ':' && !password_token {
              password_token = true
              continue
            }
            let piece = escape_with(one(character)[:], Userinfo)
            if password_token {
              url.password = url.password + piece
            } else {
              url.username = url.username + piece
            }
          }
          buffer = []
        } else if c is None ||
          c == Some('/') ||
          c == Some('?') ||
          c == Some('#') ||
          (special(url.scheme[:]) && c == Some('\\')) {
          if at_sign && buffer.length() == 0 {
            raise Host(at=pointer)
          }
          pointer -= buffer.length() + 1
          host_at = pointer + 1
          buffer = []
          state = Host
        } else if c is Some(character) {
          buffer.push(character)
        }
      Host | Hostname => {
        let is_name = state == Hostname
        if only is Some(_) && url.scheme == "file" {
          pointer -= 1
          state = FileHost
        } else if c == Some(':') && !in_brackets {
          if buffer.length() == 0 {
            raise Host(at=pointer)
          }
          if is_name {
            raise Host(at=pointer)
          }
          url.host = Some(
            host(text_of(buffer)[:], special=special(url.scheme[:]), at=host_at),
          )
          buffer = []
          state = Port
        } else if c is None ||
          c == Some('/') ||
          c == Some('?') ||
          c == Some('#') ||
          (special(url.scheme[:]) && c == Some('\\')) {
          pointer -= 1
          if special(url.scheme[:]) && buffer.length() == 0 {
            raise Host(at=pointer)
          }
          if only is Some(_) &&
            buffer.length() == 0 &&
            (credentials(url) || url.port is Some(_)) {
            raise Host(at=pointer)
          }
          url.host = Some(
            host(text_of(buffer)[:], special=special(url.scheme[:]), at=host_at),
          )
          buffer = []
          state = PathStart
          if only is Some(_) {
            return url
          }
        } else {
          match c {
            Some('[') => in_brackets = true
            Some(']') => in_brackets = false
            _ => ()
          }
          if c is Some(character) {
            buffer.push(character)
          }
        }
      }
      Port =>
        match c {
          Some(character) if digit(character.to_int()) => buffer.push(character)
          _ if c is None ||
            c == Some('/') ||
            c == Some('?') ||
            c == Some('#') ||
            (special(url.scheme[:]) && c == Some('\\')) ||
            only is Some(_) => {
            if buffer.length() > 0 {
              let mut value = 0
              for character in buffer {
                value = value * 10 + (character.to_int() - 0x30)
                guard value <= 0xFFFF else { raise Port(at=pointer) }
              }
              url.port = if Some(value) == port_of(url.scheme[:]) {
                None
              } else {
                Some(value)
              }
              buffer = []
              if only is Some(_) {
                return url
              }
            } else if only is Some(_) {
              raise Port(at=pointer)
            }
            state = PathStart
            pointer -= 1
          }
          _ => raise Port(at=pointer)
        }
      File => {
        url.scheme = "file"
        url.host = Some("")
        if c == Some('/') || c == Some('\\') {
          state = FileSlash
        } else {
          match base {
            Some(base) if base.scheme == "file" => {
              url.host = base.host
              url.path = base.path.copy()
              url.query = base.query
              if c == Some('?') {
                url.query = Some("")
                state = Query
              } else if c == Some('#') {
                url.fragment = Some("")
                state = Fragment
              } else if c is Some(_) {
                url.query = None
                if drive_at(input, pointer) is Some(_) {
                  url.path = []
                } else {
                  shorten(url)
                }
                state = Path
                pointer -= 1
              }
            }
            _ => {
              state = Path
              pointer -= 1
            }
          }
        }
      }
      FileSlash =>
        if c == Some('/') || c == Some('\\') {
          state = FileHost
        } else {
          match base {
            Some(base) if base.scheme == "file" => {
              url.host = base.host
              if drive_at(input, pointer) is None &&
                base.path.length() > 0 &&
                drive(base.path[0][:]) == Some(true) {
                url.path.push(base.path[0])
              }
            }
            _ => ()
          }
          state = Path
          pointer -= 1
        }
      FileHost =>
        if c is None ||
          c == Some('/') ||
          c == Some('\\') ||
          c == Some('?') ||
          c == Some('#') {
          pointer -= 1
          if only is None && drive(text_of(buffer)[:]) is Some(_) {
            // `C:` in the host position is a drive letter, not a host. The
            // buffer is not reset: the path state reads it next.
            state = Path
          } else if buffer.length() == 0 {
            url.host = Some("")
            if only is Some(_) {
              return url
            }
            state = PathStart
          } else {
            let name = host(text_of(buffer)[:], special=true, at=host_at)
            url.host = Some(if name == "localhost" { "" } else { name })
            if only is Some(_) {
              return url
            }
            buffer = []
            state = PathStart
          }
        } else if c is Some(character) {
          buffer.push(character)
        }
      PathStart =>
        if special(url.scheme[:]) {
          state = Path
          if c != Some('/') && c != Some('\\') {
            pointer -= 1
          }
        } else if only is None && c == Some('?') {
          url.query = Some("")
          state = Query
        } else if only is None && c == Some('#') {
          url.fragment = Some("")
          state = Fragment
        } else if c is Some(_) {
          state = Path
          if c != Some('/') {
            pointer -= 1
          }
        } else if only is Some(_) && url.host is None {
          url.path.push("")
        }
      Path =>
        if c is None ||
          c == Some('/') ||
          (special(url.scheme[:]) && c == Some('\\')) ||
          (only is None && (c == Some('?') || c == Some('#'))) {
          let piece = text_of(buffer)
          let slash = c == Some('/') ||
            (special(url.scheme[:]) && c == Some('\\'))
          if dots(piece[:]) == 2 {
            shorten(url)
            if !slash {
              url.path.push("")
            }
          } else if dots(piece[:]) == 1 {
            if !slash {
              url.path.push("")
            }
          } else {
            if url.scheme == "file" &&
              url.path.length() == 0 &&
              drive(piece[:]) is Some(_) {
              // A drive letter is written with a colon, whatever the input
              // said: `file://C|/` and `file://C:/` name the same place.
              buffer[1] = ':'
            }
            url.path.push(text_of(buffer))
          }
          buffer = []
          if c == Some('?') {
            url.query = Some("")
            state = Query
          } else if c == Some('#') {
            url.fragment = Some("")
            state = Fragment
          }
        } else if c is Some(character) {
          push_escaped(buffer, character, Path)
        }
      OpaquePath =>
        if c == Some('?') {
          url.query = Some("")
          state = Query
        } else if c == Some('#') {
          url.fragment = Some("")
          state = Fragment
        } else if c == Some(' ') {
          let next = code(input, pointer + 1)
          if next == Some('?') || next == Some('#') {
            url.path[0] = url.path[0] + "%20"
          } else {
            url.path[0] = url.path[0] + " "
          }
        } else if c is Some(character) {
          let out = StringBuilder()
          out.write_string(url.path[0])
          escape_point(out, character, C0Control)
          url.path[0] = out.to_string()
        }
      Query =>
        if (only is None && c == Some('#')) || c is None {
          let set = if special(url.scheme[:]) { SpecialQuery } else { Query }
          url.query = Some(
            url.query.unwrap_or("") + escape_with(text_of(buffer)[:], set),
          )
          buffer = []
          if c == Some('#') {
            url.fragment = Some("")
            state = Fragment
          }
        } else if c is Some(character) {
          buffer.push(character)
        }
      Fragment =>
        if c is Some(character) {
          push_escaped(buffer, character, Fragment)
        }
    }
    if pointer >= n {
      break
    }
    pointer += 1
  }
  if buffer.length() > 0 && state == Fragment {
    url.fragment = Some(
      url.fragment.unwrap_or("") + escape_with(text_of(buffer)[:], Fragment),
    )
  }
  url
}

///|
/// Percent-encode one code point onto the end of a buffer.
fn push_escaped(buffer : Array[Char], character : Char, set : Escaping) -> Unit {
  let out = StringBuilder()
  escape_point(out, character, set)
  for character in out.to_string() {
    buffer.push(character)
  }
}

// ------------------------------------------------------------- the result

///|
/// A URL, as the [`Url`] type holds it.
///
/// The standard keeps the username and the password apart and the path as
/// segments; this writes them back together, since a URL is text.
fn to_url(parts : Parts) -> Url {
  let userinfo = if parts.username == "" && parts.password == "" {
    None
  } else if parts.password == "" {
    Some(parts.username)
  } else {
    Some(parts.username + ":" + parts.password)
  }
  let path = if parts.opaque_path {
    if parts.path.length() > 0 {
      parts.path[0]
    } else {
      ""
    }
  } else {
    let out = StringBuilder()
    for segment in parts.path {
      out.write_char('/')
      out.write_string(segment)
    }
    out.to_string()
  }
  {
    scheme: Some(parts.scheme),
    userinfo,
    host: parts.host,
    port: parts.port,
    path,
    query: parts.query,
    fragment: parts.fragment,
  }
}

// ------------------------------------------------------------------ plumbing

///|
/// The input with what the standard takes off before reading it: C0 controls
/// and spaces at either end, and every tab and newline anywhere.
fn clean(text : StringView, trim~ : Bool) -> Array[Char] {
  let all : Array[Char] = []
  for character in text {
    all.push(character)
  }
  let mut begin = 0
  let mut end = all.length()
  if trim {
    while begin < end && all[begin].to_int() <= 0x20 {
      begin += 1
    }
    while end > begin && all[end - 1].to_int() <= 0x20 {
      end -= 1
    }
  }
  let out : Array[Char] = []
  for i = begin; i < end; i = i + 1 {
    let point = all[i].to_int()
    if point != 0x09 && point != 0x0A && point != 0x0D {
      out.push(all[i])
    }
  }
  out
}

///|
/// A buffer as text.
fn text_of(buffer : Array[Char]) -> String {
  let out = StringBuilder()
  for character in buffer {
    out.write_char(character)
  }
  out.to_string()
}

///|
/// A string as characters, for the authority state, which has to put `%40` in
/// front of what it has read.
fn string_chars(text : StringView) -> Array[Char] {
  let out : Array[Char] = []
  for character in text {
    out.push(character)
  }
  out
}

///|
/// Whether the URL has a username or a password, which is not the same as
/// having a userinfo somebody wrote: `@` alone makes one.
fn credentials(url : Parts) -> Bool {
  url.username != "" || url.password != ""
}

///|
/// Take the last segment of the path off (§4.4, "shorten").
fn shorten(url : Parts) -> Unit {
  if url.scheme == "file" &&
    url.path.length() == 1 &&
    drive(url.path[0][:]) is Some(_) {
    return
  }
  if url.path.length() > 0 {
    let _ = url.path.pop()
  }
}

///|
/// Whether `text` is a Windows drive letter: a letter and a colon, which is
/// what `file:` has to tell apart from a one-letter host.
///
/// `Some(true)` when it is written with a colon, `Some(false)` when it is
/// written with anything else in the colon's place — `C|` and `C:` name the
/// same drive, and only the second is the normal form.
fn drive(text : StringView) -> Bool? {
  if text.length() != 2 || !alpha(text[0].to_int()) {
    return None
  }
  if text[1].to_int() == 0x3A {
    Some(true)
  } else if text[1].to_int() == 0x7C {
    Some(false)
  } else {
    None
  }
}

///|
/// Whether the input from `pointer` on starts with a Windows drive letter and
/// then the end, a slash or a query: `file:C:/x`, but not `file:CD/x`.
fn drive_at(input : Array[Char], pointer : Int32) -> Bool? {
  guard pointer + 1 < input.length() && alpha(input[pointer].to_int()) else {
    return None
  }
  let kind = match input[pointer + 1] {
    ':' => Some(true)
    '|' => Some(false)
    _ => return None
  }
  if pointer + 2 == input.length() {
    return kind
  }
  match input[pointer + 2] {
    '/' | '\\' | '?' | '#' => kind
    _ => None
  }
}

///|
/// How many dots a path segment is: 2 for `..`, `.%2e`, `%2e.` and `%2e%2e`,
/// 1 for `.` and `%2e`, and 0 for anything else, the empty segment included.
fn dots(text : StringView) -> Int32 {
  let mut rest = text
  let mut count = 0
  while true {
    if starts(rest, "%2e") || starts(rest, "%2E") {
      rest = rest[3:]
    } else if rest.length() > 0 && rest[0].to_int() == 0x2E {
      rest = rest[1:]
    } else {
      break
    }
    count += 1
  }
  if rest.length() > 0 || count > 2 {
    0
  } else {
    count
  }
}

// ------------------------------------------------------------------ origin

///|
/// The origin of a URL (§4.7): what two URLs have to share to be the same
/// site, written `scheme://host:port`.
///
/// A `blob:` URL carries the origin of the URL inside it. Everything else that
/// is not a scheme with an authority of its own — `file:`, `data:`, `mailto:`
/// — has an origin no other URL shares, which is written `"null"`.
///
/// ```mbt check
/// test {
///   let url = @moonurl.Url::decode(
///     "https://example.test/a",
///     syntax=@moonurl.Whatwg,
///   )
///   assert_eq(url.origin(), "https://example.test")
///   let file = @moonurl.Url::decode("file:///tmp/x", syntax=@moonurl.Whatwg)
///   assert_eq(file.origin(), "null")
/// }
/// ```
pub fn Url::origin(self : Url) -> String {
  let scheme = self.scheme.unwrap_or("")
  if scheme == "blob" {
    let inner = Some(Url::decode(self.path[:], syntax=Whatwg)) catch {
      _ => None
    }
    guard inner is Some(inner) else { return "null" }
    let scheme = inner.scheme.unwrap_or("")
    return if scheme == "http" || scheme == "https" || scheme == "file" {
      inner.origin()
    } else {
      "null"
    }
  }
  match scheme {
    "ftp" | "http" | "https" | "ws" | "wss" => {
      let host = self.host.unwrap_or("")
      let out = StringBuilder()
      out.write_string(scheme)
      out.write_string("://")
      if literal(host) {
        out.write_char('[')
        out.write_string(host)
        out.write_char(']')
      } else {
        out.write_string(host)
      }
      match self.port {
        Some(port) => {
          out.write_char(':')
          out.write_string(port.to_string())
        }
        None => ()
      }
      out.to_string()
    }
    _ => "null"
  }
}

// ------------------------------------------------------------------- setters

///|
/// Whether the URL may carry a username, a password or a port: a special
/// scheme other than `file` does, and a URL with no host does not.
fn with_credentials(parts : Parts) -> Bool {
  parts.host is Some(host) && host != "" && parts.scheme != "file"
}

///|
/// The parts of this URL, if it is one the standard can read.
fn read_parts(url : Url) -> Parts? {
  Some(parts_of(url)) catch {
    _ => None
  }
}

///|
/// This URL with its scheme replaced, or this URL if the value is not a scheme.
///
/// A URL may change between special and non-special schemes only through
/// another special one, and only a URL with no credentials, port or `file`
/// host can become `file`; the setter leaves anything else as it was, as the
/// standard's own setter does.
///
/// ```mbt check
/// test {
///   let url = @moonurl.Url::decode(
///     "http://example.test/a",
///     syntax=@moonurl.Whatwg,
///   )
///   assert_eq(url.set_scheme("https").encode(), "https://example.test/a")
///   assert_eq(url.set_scheme("1bad").encode(), "http://example.test/a")
/// }
/// ```
pub fn Url::set_scheme(self : Url, value : StringView) -> Url {
  set_by(self, value.to_owned() + ":", SchemeStart)
}

///|
/// This URL with its username replaced.
pub fn Url::set_username(self : Url, value : StringView) -> Url {
  let parts = match read_parts(self) {
    Some(parts) => parts
    None => return self
  }
  guard with_credentials(parts) else { return self }
  parts.username = escape_with(value, Userinfo)
  to_url(parts)
}

///|
/// This URL with its password replaced.
pub fn Url::set_password(self : Url, value : StringView) -> Url {
  let parts = match read_parts(self) {
    Some(parts) => parts
    None => return self
  }
  guard with_credentials(parts) else { return self }
  parts.password = escape_with(value, Userinfo)
  to_url(parts)
}

///|
/// This URL with its host replaced, and its port too if the value carries one.
///
/// An empty value is a host with nothing in it, not a missing host: the URL
/// keeps its shape and loses its authority's middle.
pub fn Url::set_host(self : Url, value : StringView) -> Url {
  let parts = match read_parts(self) {
    Some(parts) => parts
    None => return self
  }
  guard !parts.opaque_path else { return self }
  set_parts(parts, value, Host)
}

///|
/// This URL with its host replaced and its port left alone.
pub fn Url::set_hostname(self : Url, value : StringView) -> Url {
  let parts = match read_parts(self) {
    Some(parts) => parts
    None => return self
  }
  guard !parts.opaque_path else { return self }
  set_parts(parts, value, Hostname)
}

///|
/// This URL with its port replaced. An empty value takes the port away, which
/// is not the same as setting it to zero.
pub fn Url::set_port(self : Url, value : StringView) -> Url {
  let parts = match read_parts(self) {
    Some(parts) => parts
    None => return self
  }
  guard with_credentials(parts) else { return self }
  if value.length() == 0 {
    parts.port = None
    return to_url(parts)
  }
  set_parts(parts, value, Port)
}

///|
/// This URL with its path replaced. A URL whose path is opaque — `mailto:`,
/// `data:` — has no path to replace, and keeps the one it has.
pub fn Url::set_path(self : Url, value : StringView) -> Url {
  let parts = match read_parts(self) {
    Some(parts) => parts
    None => return self
  }
  guard !parts.opaque_path else { return self }
  parts.path = []
  set_parts(parts, value, PathStart)
}

///|
/// This URL with its query replaced, the leading `?` of the value optional.
/// An empty value takes the query away.
pub fn Url::set_query(self : Url, value : StringView) -> Url {
  let parts = match read_parts(self) {
    Some(parts) => parts
    None => return self
  }
  if value.length() == 0 {
    parts.query = None
    return to_url(parts)
  }
  let text = if value[0].to_int() == 0x3F { value[1:] } else { value }
  parts.query = Some("")
  set_parts(parts, text, Query)
}

///|
/// This URL with its fragment replaced, the leading `#` of the value optional.
/// An empty value takes the fragment away.
pub fn Url::set_fragment(self : Url, value : StringView) -> Url {
  let parts = match read_parts(self) {
    Some(parts) => parts
    None => return self
  }
  if value.length() == 0 {
    parts.fragment = None
    return to_url(parts)
  }
  let text = if value[0].to_int() == 0x23 { value[1:] } else { value }
  parts.fragment = Some("")
  set_parts(parts, text, Fragment)
}

///|
/// Read `value` into `parts` starting at `state`, and return what came of it —
/// or the URL untouched, since a setter that fails changes nothing.
fn set_parts(parts : Parts, value : StringView, state : State) -> Url {
  // The parser is given the parts themselves, so that a setter that fails
  // halfway — a host that reads and a port that does not — keeps what it read,
  // as the standard's own setters do.
  let parsed = Some(parse(value, None, Some(parts), Some(state))) catch {
    _ => None
  }
  match parsed {
    Some(parsed) => to_url(parsed)
    None => to_url(parts)
  }
}

///|
fn set_by(url : Url, value : String, state : State) -> Url {
  let parts = match read_parts(url) {
    Some(parts) => parts
    None => return url
  }
  set_parts(parts, value[:], state)
}

///|
/// The query as pairs, read the way `URLSearchParams` reads it (WHATWG URL
/// Standard §5.1).
///
/// ```mbt check
/// test {
///   let url = @moonurl.Url::decode(
///     "https://h/?a=1&a=2&b=x+y",
///     syntax=@moonurl.Whatwg,
///   )
///   assert_eq(url.params().values("a").length(), 2)
///   assert_eq(url.params().get("b").unwrap(), "x y")
/// }
/// ```
pub fn Url::params(self : Url) -> Query raise Refused {
  match self.query {
    Some(query) => Query::decode(@utf8.encode(query[:]))
    None => Query::new()
  }
}