uri_builder.pony

class ref URIBuilder
  """
  Fluent builder for constructing URIs from raw (unencoded) components.

  Each setter accepts raw text and automatically percent-encodes it for the
  target component. The `build()` method validates the assembled URI and
  returns a `URI val`.

  ```pony
  match URIBuilder
    .set_scheme("https")
    .set_host("example.com")
    .set_path("/api/users")
    .add_query_param("name", "Jane Doe")
    .set_fragment("top")
    .build()
  | let u: URI val =>
    // u.string() == "https://example.com/api/users?name=Jane%20Doe#top"
  | let e: URIBuildError val =>
    // handle error
  end
  ```

  Use `from()` to copy an existing URI's pre-encoded components into the
  builder for modification. Since `from()` copies pre-encoded values, do not
  pass an existing URI's encoded component (e.g., `uri.query`) to a setter
  like `set_query()` — that would double-encode it. Instead, use `from()`
  and then modify only the components you want to change.
  """
  var _scheme: (String | None) = None
  var _userinfo: (String | None) = None
  var _host: (String | None) = None
  var _port: (U16 | None) = None
  var _path: String = ""
  var _query: (String | None) = None
  var _fragment: (String | None) = None

  new create() =>
    """
    Create an empty builder with all components unset.
    """
    None

  new from(uri: URI val) =>
    """
    Copy pre-encoded components from an existing URI.

    Authority components (userinfo, host, port) are decomposed from the URI's
    authority. All values are copied as-is without re-encoding.
    """
    _scheme = uri.scheme
    match uri.authority
    | let a: URIAuthority =>
      _userinfo = a.userinfo
      _host = a.host
      _port = a.port
    end
    _path = uri.path
    _query = uri.query
    _fragment = uri.fragment

  fun ref set_scheme(scheme: String): URIBuilder ref =>
    """
    Set the URI scheme from raw text.

    The scheme is validated on `build()` — it must match
    `[A-Za-z][A-Za-z0-9+-.]*` per RFC 3986 section 3.1. No encoding is
    needed for schemes (they are ASCII-only).
    """
    _scheme = scheme
    this

  fun ref clear_scheme(): URIBuilder ref =>
    """
    Remove the scheme component.
    """
    _scheme = None
    this

  fun ref set_userinfo(userinfo: String): URIBuilder ref =>
    """
    Set the userinfo component from raw (unencoded) text.

    The input is percent-encoded for the userinfo component. If no host is
    set, an empty host is created automatically (userinfo requires an
    authority).
    """
    _userinfo = PercentEncode(userinfo, URIPartUserinfo)
    if _host is None then _host = "" end
    this

  fun ref clear_userinfo(): URIBuilder ref =>
    """
    Remove the userinfo component.
    """
    _userinfo = None
    this

  fun ref set_host(host: String): URIBuilder ref =>
    """
    Set the host component from raw (unencoded) text.

    If the input starts with `[`, it is treated as an IP-literal and stored
    as-is (validated on `build()`). Otherwise, it is percent-encoded as a
    reg-name. IPv6 addresses must include brackets (e.g., `[::1]`).
    """
    if (host.size() > 0) and try host(0)? == '[' else false end then
      _host = host
    else
      _host = PercentEncode(host, URIPartHost)
    end
    this

  fun ref clear_host(): URIBuilder ref =>
    """
    Remove the host component.

    Also clears userinfo and port, since they require an authority.
    """
    _host = None
    _userinfo = None
    _port = None
    this

  fun ref set_port(port: U16): URIBuilder ref =>
    """
    Set the port number.

    If no host is set, an empty host is created automatically (port requires
    an authority).
    """
    _port = port
    if _host is None then _host = "" end
    this

  fun ref clear_port(): URIBuilder ref =>
    """
    Remove the port component.
    """
    _port = None
    this

  fun ref set_path(path: String): URIBuilder ref =>
    """
    Set the path component from raw (unencoded) text.

    The input is percent-encoded for the path component. Path structure
    (slashes) is preserved — only characters that need encoding are encoded.
    """
    _path = PercentEncode(path, URIPartPath)
    this

  fun ref append_path_segment(segment: String): URIBuilder ref =>
    """
    Append a single path segment from raw (unencoded) text.

    The segment is encoded with slash (`/`) also encoded (as `%2F`) so
    it remains a single segment. A `/` separator is prepended automatically.
    """
    let encoded = _PathSegmentEncode(segment)
    _path =
      recover val
        String
          .> append(_path)
          .> push('/')
          .> append(encoded)
      end
    this

  fun ref set_query(query: String): URIBuilder ref =>
    """
    Set the query component from raw (unencoded) text.

    The input is percent-encoded for the query component. Structure characters
    `&` and `=` are preserved since they are sub-delimiters allowed in query.
    For individual key-value pairs with full encoding, use `add_query_param`.
    """
    _query = PercentEncode(query, URIPartQuery)
    this

  fun ref clear_query(): URIBuilder ref =>
    """
    Remove the query component.

    After clearing, no `?` delimiter appears in the output. To produce a
    trailing `?` with no value, use `set_query("")` instead.
    """
    _query = None
    this

  fun ref add_query_param(key: String, value: String): URIBuilder ref =>
    """
    Add a query parameter from raw (unencoded) key and value.

    Both the key and value are encoded with `=`, `&`, and `+` also encoded
    (in addition to standard query encoding) so they are safe as individual
    parameter components. Parameters are appended with `&` as separator.
    """
    let encoded_key = _FormFieldEncode(key)
    let encoded_val = _FormFieldEncode(value)
    let param =
      recover val
        String
          .> append(encoded_key)
          .> push('=')
          .> append(encoded_val)
      end
    _query =
      match \exhaustive\ _query
      | None => param
      | let q: String if q.size() == 0 => param
      | let q: String =>
        recover val
          String
            .> append(q)
            .> push('&')
            .> append(param)
        end
      end
    this

  fun ref set_fragment(fragment: String): URIBuilder ref =>
    """
    Set the fragment component from raw (unencoded) text.

    The input is percent-encoded for the fragment component.
    """
    _fragment = PercentEncode(fragment, URIPartFragment)
    this

  fun ref clear_fragment(): URIBuilder ref =>
    """
    Remove the fragment component.

    After clearing, no `#` delimiter appears in the output. To produce a
    trailing `#` with no value, use `set_fragment("")` instead.
    """
    _fragment = None
    this

  fun box build(): (URI val | URIBuildError val) =>
    """
    Validate and assemble the URI from the current components.

    Returns `InvalidScheme` if the scheme is set but does not match
    `[A-Za-z][A-Za-z0-9+-.]*`. Returns `InvalidHost` if an IP-literal
    host is malformed. Returns the assembled `URI val` on success.
    """
    // Validate scheme if present
    match _scheme
    | let s: String =>
      if not _valid_scheme(s) then
        return InvalidScheme
      end
    end

    // Build authority if host is present
    let authority: (URIAuthority | None) =
      match \exhaustive\ _host
      | let h: String =>
        // Assemble authority string for validation via ParseURIAuthority
        let auth_str =
          recover val
            let out = String
            match _userinfo
            | let u: String => out.append(u); out.push('@')
            end
            out.append(h)
            match _port
            | let p: U16 => out.push(':'); out.append(p.string())
            end
            out
          end
        match \exhaustive\ ParseURIAuthority(auth_str)
        | let a: URIAuthority val => a
        | let e: URIParseError val => return e
        end
      | None => None
      end

    // Auto-prepend "/" if authority present and path is non-empty but relative
    let path: String val =
      match authority
      | let _: URIAuthority =>
        if (_path.size() > 0) and
          (try _path(0)? != '/' else false end)
        then
          recover val
            String
              .> push('/')
              .> append(_path)
          end
        else
          _path
        end
      else
        _path
      end

    URI(_scheme, authority, path, _query, _fragment)

  fun _valid_scheme(s: String): Bool =>
    if s.size() == 0 then return false end
    try
      let first = s(0)?
      if not (((first >= 'A') and (first <= 'Z')) or
        ((first >= 'a') and (first <= 'z')))
      then
        return false
      end
    else
      _Unreachable()
    end
    try
      var i: USize = 1
      while i < s.size() do
        let c = s(i)?
        if not (((c >= 'A') and (c <= 'Z')) or
          ((c >= 'a') and (c <= 'z')) or
          ((c >= '0') and (c <= '9')) or
          (c == '+') or (c == '-') or (c == '.'))
        then
          return false
        end
        i = i + 1
      end
    else
      _Unreachable()
    end
    true