class S3URI {
    private static final String INVALID_URI_MSG = "Invalid URI. Please make sure that given URI is encoded properly.";

    private final URI uri;
    private final String accessKey;
    private final String secretKey;
    private final String bucket;
    private final String key;
    private final String endpoint;

    private S3URI(URI uri, String accessKey, String secretKey, String bucket, String key, String endpoint) {
        this.uri = uri;
        this.accessKey = accessKey;
        this.secretKey = secretKey;
        this.bucket = bucket;
        this.key = key;
        this.endpoint = endpoint;
    }

    public static S3URI toS3URI(URI uri) {
        URI normalizedURI = normalize(uri);
        String userInfo = getUserInfo(normalizedURI);
        String accessKey = null;
        String secretKey = null;
        if (userInfo != null) {
            String[] userInfoParts = userInfo.split(":");
            try {
                accessKey = userInfoParts[0];
                secretKey = userInfoParts[1];
            } catch (ArrayIndexOutOfBoundsException e) {
                // ignore
            }
            // if the URI contains '@' and ':', a UserInfo is in fact given, but could not
            // be parsed properly because the URI is not valid (e.g. not properly encoded).
        } else if (normalizedURI.toString().contains("@") && normalizedURI.toString().contains(":")) {
            throw new IllegalArgumentException(INVALID_URI_MSG);
        }

        String bucket;
        String key;
        String path = normalizedURI.getPath().substring(1);
        int splitIndex = path.indexOf('/');
        if (splitIndex == -1) {
            bucket = path;
            key = "";
        } else {
            bucket = path.substring(0, splitIndex);
            key = path.substring(splitIndex + 1);
        }

        String endpoint = null;
        if (normalizedURI.getHost() != null) {
            endpoint = normalizedURI.getHost() + ":" + normalizedURI.getPort();
        }

        return new S3URI(normalizedURI, accessKey, secretKey, bucket, key, endpoint);
    }

    @VisibleForTesting
    static String getUserInfo(URI uri) {
        // userInfo is provided but is contained in authority, NOT in userInfo. happens when host is not provided
        String userInfo = null;
        if (uri.getHost() == null && uri.getPort() == -1 && uri.getUserInfo() == null) {
            var authority = uri.getAuthority();
            if (authority != null) {
                int idx = authority.indexOf('@');
                if (idx != authority.length() - 1) {
                    throw new IllegalArgumentException(INVALID_URI_MSG);
                }
                userInfo = uri.getAuthority().substring(0, idx);
            }
        } else {
            userInfo = uri.getUserInfo();
        }
        return userInfo;
    }

    /**
     * As shown in <a href="https://crate.io/docs/crate/reference/en/latest/sql/statements/copy-from.html#sql-copy-from-s3">CrateDB Reference</a>,
     * the accepted s3 uri format is:
     * <pre>{@code
     * s3://[<accesskey>:<secretkey>@][<host>:<port>/]<bucketname>/<path>}
     * </pre>
     * which is inconsistent with {@link URI}.
     * <p>
     * For example, s3://bucket is an acceptable s3 uri to CrateDB but {@link URI} parses "bucket" as the host instead of the path.
     * <p>
     * Another example is, s3://bucket/key1/key2, another valid CrateDB s3 uri. Again, URI parses "bucket" as the host and "/key1/key2" as the path.
     * <p>
     * This method resolved this inconsistency such that URI can be utilized.
     *
     * @param uri a valid s3 uri
     * @return a normalized uri such that the path component contains the bucket and the key
     */
    private static URI normalize(URI uri) {<FILL_FUNCTION_BODY>}

    public S3URI replacePath(String bucket, String key) {
        StringBuilder sb = new StringBuilder();
        sb.append("s3://");
        if (uri.getRawAuthority() != null) {
            sb.append(uri.getRawAuthority());
        }
        sb.append("/").append(bucket).append("/").append(key);
        return new S3URI(URI.create(sb.toString()), accessKey, secretKey, bucket, key, endpoint);
    }

    @Override
    public String toString() {
        return uri.toString();
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (!(obj instanceof S3URI that)) {
            return false;
        }
        return uri.equals(that.uri) &&
               Objects.equals(accessKey, that.accessKey) &&
               Objects.equals(secretKey, that.secretKey) &&
               Objects.equals(bucket, that.bucket) &&
               Objects.equals(key, that.key) &&
               Objects.equals(endpoint, that.endpoint);
    }

    @Override
    public int hashCode() {
        return Objects.hash(uri, accessKey, secretKey, bucket, key, endpoint);
    }

    public URI uri() {
        return uri;
    }

    public String bucket() {
        return bucket;
    }

    public String key() {
        return key;
    }

    public String accessKey() {
        return accessKey;
    }

    public String secretKey() {
        return secretKey;
    }

    public String endpoint() {
        return endpoint;
    }
}

        assert "s3".equals(uri.getScheme());
        if (uri.getHost() != null) {
            if (uri.getPath() == null || uri.getPort() == -1) {
                return URI.create("s3://"
                                  + (uri.getRawUserInfo() == null ? "" : uri.getRawUserInfo() + "@")
                                  + "/"
                                  + uri.getHost()
                                  + uri.getPath());
            }
        }
        return uri;
    
<no_super_class>