/* 
 * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.
 */

package dev.plexapi.sdk.utils;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.net.http.HttpClient.Version;
import java.net.http.HttpHeaders;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.function.BiPredicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;

import javax.net.ssl.SSLSession;

import org.apache.commons.io.IOUtils;
import org.apache.http.NameValuePair;
import org.openapitools.jackson.nullable.JsonNullable;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.IntNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.TextNode;
import com.fasterxml.jackson.databind.type.TypeFactory;

public final class Utils {
    
    private Utils() {
         // prevent instantiation
    }

    // this method exists because primitive comparisons with objects can 
    // give compile errors. By calling this method we force autobox to object
    // version of primitive     
    public static boolean referenceEquals(Object a, Object b) {
        return a == b;
    }
    
    public static String generateURL(String baseURL, String path)
            throws IllegalArgumentException, IllegalAccessException {
        if (baseURL != null && baseURL.endsWith("/")) {
            baseURL = baseURL.substring(0, baseURL.length() - 1);
        }

        return baseURL + path;
    }
    
    public static <T> String generateURL(Class<T> type, String baseURL, String path, JsonNullable<? extends T> params,
            Map<String, Map<String, Map<String, Object>>> globals) throws JsonProcessingException, IllegalArgumentException, IllegalAccessException {
        if (params.isPresent() && params.get() != null) {
            return generateURL(type, baseURL, path, params.get(), globals);
        } else {
            return baseURL;
        }
    }
    
    public static <T> String generateURL(Class<T> type, String baseURL, String path, Optional<? extends T> params,
            Map<String, Map<String, Map<String, Object>>> globals) throws JsonProcessingException, IllegalArgumentException, IllegalAccessException {
        if (params.isPresent()) {
            return generateURL(type, baseURL, path, params.get(), globals);
        } else {
            return baseURL;
        }
    }

    public static <T> String generateURL(Class<T> type, String baseURL, String path, T params,
            Map<String, Map<String, Map<String, Object>>> globals)
            throws IllegalArgumentException, IllegalAccessException, JsonProcessingException {
        if (baseURL != null && baseURL.endsWith("/")) {
            baseURL = baseURL.substring(0, baseURL.length() - 1);
        }

        Map<String, String> pathParams = new HashMap<>();

        Field[] fields = type.getDeclaredFields();

        for (Field field : fields) {
            field.setAccessible(true);
            PathParamsMetadata pathParamsMetadata = PathParamsMetadata.parse(field);
            if (pathParamsMetadata == null) {
                continue;
            }

            Object value = params != null ? field.get(params) : null;
            value = resolveOptionals(value);
            value = populateGlobal(value, field.getName(), "pathParam", globals);
            if (value == null) {
                continue;
            }

            if (pathParamsMetadata.serialization != null && !pathParamsMetadata.serialization.isBlank()) {
                Map<String, String> serialized = parseSerializedParams(pathParamsMetadata, value);
                pathParams.putAll(serialized);
            } else {
                switch (pathParamsMetadata.style) {
                    case "simple":
                        switch (Types.getType(value.getClass())) {
                            case ARRAY:
                                final List<?> array = toList(value);
                                if (array.isEmpty()) {
                                    continue;
                                }

                                pathParams.put(pathParamsMetadata.name,
                                        String.join(",",
                                                array.stream().map(v -> valToString(v))
                                                        .collect(Collectors.toList())));
                                break;
                            case MAP:
                                Map<?, ?> map = (Map<?, ?>) value;
                                if (map.size() == 0) {
                                    continue;
                                }

                                pathParams.put(pathParamsMetadata.name,
                                        String.join(",", map.entrySet().stream().map(e -> {
                                            if (pathParamsMetadata.explode) {
                                                return String.format("%s=%s", valToString(e.getKey()),
                                                        valToString(e.getValue()));
                                            } else {
                                                return String.format("%s,%s", valToString(e.getKey()),
                                                        valToString(e.getValue()));
                                            }
                                        }).collect(Collectors.toList())));
                                break;
                            case OBJECT:
                                if (!allowIntrospection(value.getClass())) {
                                    pathParams.put(pathParamsMetadata.name, valToString(value));
                                    break;
                                }
                                List<String> values = new ArrayList<>();

                                Field[] valueFields = value.getClass().getDeclaredFields();
                                for (Field valueField : valueFields) {
                                    valueField.setAccessible(true);
                                    PathParamsMetadata valuePathParamsMetadata = PathParamsMetadata.parse(valueField);
                                    if (valuePathParamsMetadata == null) {
                                        continue;
                                    }

                                    Object val = valueField.get(value);
                                    val = resolveOptionals(val);
                                    if (val == null) {
                                        continue;
                                    }

                                    if (pathParamsMetadata.explode) {
                                        values.add(String.format("%s=%s", valuePathParamsMetadata.name,
                                                valToString(val)));
                                    } else {
                                        values.add(String.format("%s,%s", valuePathParamsMetadata.name,
                                                valToString(val)));
                                    }
                                }

                                pathParams.put(pathParamsMetadata.name, String.join(",", values));
                                break;
                            default:
                                pathParams.put(pathParamsMetadata.name, valToString(value));
                                break;
                        }
                }
            }
        }

        return baseURL + templateUrl(path, pathParams);
    }

    public static boolean contentTypeMatches(String contentType, String pattern) {
        if (contentType == null || contentType.isBlank()) {
            return false;
        }

        if (contentType.equals(pattern) || pattern.equals("*") || pattern.equals("*/*")) {
            return true;
        }

        String[] contentTypeParts = contentType.split(";");
        if (contentTypeParts.length == 0) {
            return false;
        }
        String mediaType = contentTypeParts[0];

        if (mediaType.equals(pattern)) {
            return true;
        }

        String[] mediaTypeParts = mediaType.split("/");
        if (mediaTypeParts.length == 2) {
            if (String.format("%s/*", mediaTypeParts[0]).equals(pattern)
                    || String.format("*/%s", mediaTypeParts[1]).equals(pattern)) {
                return true;
            }
        }

        return false;
    }
    
    public static boolean allowIntrospection(Class<?> cls) {
        return !cls.equals(BigInteger.class) 
            && !cls.equals(BigDecimal.class)
            && !cls.equals(BigIntegerString.class)
            && !cls.equals(BigDecimalString.class)
            && !cls.equals(LocalDate.class)
            && !cls.equals(OffsetDateTime.class);
    }

    public enum JsonShape {
        STRING, DEFAULT;
    }
 
    public static SerializedBody serializeRequestBody(Object request, String requestField, String serializationMethod, boolean nullable)
            throws NoSuchFieldException,
            IllegalArgumentException, IllegalAccessException, UnsupportedOperationException, IOException {
        return RequestBody.serialize(request, requestField, serializationMethod, nullable);
    }
    
    public static <T extends Object> List<NameValuePair> getQueryParams(Class<T> type, Optional<? extends T> params,
            Map<String, Map<String, Map<String, Object>>> globals) throws Exception {
        if (params.isEmpty()) {
            return Collections.emptyList();
        } else {
            return getQueryParams(type, params.get(), globals);
        }
    }
    
    public static <T extends Object> List<NameValuePair> getQueryParams(Class<T> type, JsonNullable<? extends T> params,
            Map<String, Map<String, Map<String, Object>>> globals) throws Exception {
        if (!params.isPresent() || params.get() == null) {
            return Collections.emptyList();
        } else {
            return getQueryParams(type, params.get(), globals);
        }
    }

    public static <T extends Object> List<NameValuePair> getQueryParams(Class<T> type, T params,
            Map<String, Map<String, Map<String, Object>>> globals) throws Exception {
        return QueryParameters.parseQueryParams(type, params, globals);
    }

    public static HTTPRequest configureSecurity(HTTPRequest request, Object security) throws Exception {
        return Security.configureSecurity(request, security);
    }

    public static String templateUrl(String url, Map<String, String> params) {
        StringBuilder sb = new StringBuilder();

        Pattern p = Pattern.compile("(\\{.*?\\})");
        Matcher m = p.matcher(url);

        while (m.find()) {
            String match = m.group(1);
            String key = match.substring(1, match.length() - 1);
            String value = params.get(key);
            if (value != null) {
                m.appendReplacement(sb, URLEncoder.encode(value, StandardCharsets.UTF_8));
            }
        }
        m.appendTail(sb);

        return sb.toString();
    }

    public static Map<String, List<String>> getHeadersFromMetadata(Object headers, Map<String, Map<String, Map<String, Object>>> globals) throws Exception {
        if (headers == null) {
            return Collections.emptyMap();
        }

        Map<String, List<String>> result = new HashMap<>();

        Field[] fields = headers.getClass().getDeclaredFields();

        for (Field field : fields) {
            field.setAccessible(true);
            HeaderMetadata headerMetadata = HeaderMetadata.parse(field);
            if (headerMetadata == null) {
                continue;
            }

            Object value = field.get(headers);
            value = resolveOptionals(value);
            value = populateGlobal(value, field.getName(), "header", globals);

            if (value == null) {
                continue;
            }

            switch (Types.getType(value.getClass())) {
                case OBJECT: {
                    if (!allowIntrospection(value.getClass())) {
                        break;
                    } 
                    List<String> items = new ArrayList<>();

                    Field[] valueFields = value.getClass().getDeclaredFields();
                    for (Field valueField : valueFields) {
                        valueField.setAccessible(true);
                        HeaderMetadata valueHeaderMetadata = HeaderMetadata.parse(valueField);
                        if (valueHeaderMetadata == null || valueHeaderMetadata.name == null
                                || valueHeaderMetadata.name.isBlank()) {
                            continue;
                        }

                        Object valueFieldValue = valueField.get(value);
                        valueFieldValue = resolveOptionals(valueFieldValue);
                        if (valueFieldValue == null) {
                            continue;
                        }

                        if (headerMetadata.explode) {
                            items.add(
                                    String.format("%s=%s", valueHeaderMetadata.name,
                                            valToString(valueFieldValue)));
                        } else {
                            items.add(valueHeaderMetadata.name);
                            items.add(valToString(valueFieldValue));
                        }
                    }

                    if (!result.containsKey(headerMetadata.name)) {
                        result.put(headerMetadata.name, new ArrayList<>());
                    }

                    List<String> values = result.get(headerMetadata.name);
                    values.add(String.join(",", items));

                    break;
                }
                case MAP: {
                    Map<?, ?> map = (Map<?, ?>) value;
                    if (map.size() == 0) {
                        continue;
                    }

                    List<String> items = new ArrayList<>();

                    for (Map.Entry<?, ?> entry : map.entrySet()) {
                        if (headerMetadata.explode) {
                            items.add(String.format("%s=%s", valToString(entry.getKey()),
                                    valToString(entry.getValue())));
                        } else {
                            items.add(valToString(entry.getKey()));
                            items.add(valToString(entry.getValue()));
                        }
                    }

                    if (!result.containsKey(headerMetadata.name)) {
                        result.put(headerMetadata.name, new ArrayList<>());
                    }

                    List<String> values = result.get(headerMetadata.name);
                    values.add(String.join(",", items));

                    break;
                }
                case ARRAY: {
                    final List<?> array = toList(value);

                    if (array.isEmpty()) {
                        continue;
                    }

                    List<String> items = new ArrayList<>();

                    for (Object item : array) {
                        items.add(valToString(item));
                    }

                    if (!result.containsKey(headerMetadata.name)) {
                        result.put(headerMetadata.name, new ArrayList<>());
                    }

                    List<String> values = result.get(headerMetadata.name);
                    values.add(String.join(",", items));

                    break;
                }
                default: {
                    if (!result.containsKey(headerMetadata.name)) {
                        result.put(headerMetadata.name, new ArrayList<>());
                    }

                    List<String> values = result.get(headerMetadata.name);
                    values.add(valToString(value));
                    break;
                }
            }
        }

        return result;
    }

    public static String valToString(Object value) {
        if (value.getClass().isEnum()) {
            try {
                Field field = value.getClass().getDeclaredField("value");
                field.setAccessible(true);
                return String.valueOf(field.get(value));
            } catch (IllegalArgumentException | IllegalAccessException | NoSuchFieldException | SecurityException e) {
                return "ERROR_UNKNOWN_VALUE";
            }
        } else {
            return String.valueOf(resolveOptionals(value));
        }
    }

    public static String prefixBearer(String authHeaderValue) {
        if (authHeaderValue.toLowerCase().startsWith("bearer ")) {
            return authHeaderValue;
        }
        return "Bearer " + authHeaderValue;
    }

    public static Object populateGlobal(Object value, String fieldName, String paramType,
            Map<String, Map<String, Map<String, Object>>> globals) {
        if (value == null &&
                globals != null &&
                globals.containsKey("parameters") &&
                globals.get("parameters").containsKey(paramType)) {
            Object globalVal = globals.get("parameters").get(paramType).get(fieldName);
            if (globalVal != null) {
                value = globalVal;
            }
        }

        return value;
    }

    private static Map<String, String> parseSerializedParams(PathParamsMetadata pathParamsMetadata, Object value)
            throws JsonProcessingException {
        Map<String, String> params = new HashMap<>();
        switch (pathParamsMetadata.serialization) {
            case "json":
                ObjectMapper mapper = JSON.getMapper();
                String json = mapper.writeValueAsString(value);
                params.put(pathParamsMetadata.name, json);
                break;
            default: 
                break;
        }
        return params;
    }
    
    public static <T> T checkNotNull(T object, String name) {
        if (object == null) {
            // IAE better than NPE in this use-case (NPE can suggest internal troubles)
            throw new IllegalArgumentException(name + " cannot be null");
        }
        return object;
    }    
    
    public static void checkArgument(boolean expression, String message) {
        if (!expression) {
            throw new IllegalArgumentException(message);
        }
    } 
    
    public static <K, V> Map<K, V> emptyMapIfNull(Map<K, V> map) {
        return map == null ? java.util.Collections.emptyMap() : map; 
    }
    
    public static String toString(Class<?> cls, Object... items) {
        if (items.length % 2 != 0) {
            throw new IllegalArgumentException("items must have an even length");
        }
        StringBuilder s = new StringBuilder();
        int i = 0;
        while (i < items.length) {
            if (i > 0) {
                s.append(", ");
            }
            s.append(items[i]);
            s.append("=");
            s.append(items[i + 1]);
            i += 2;
        }
        return cls.getSimpleName() + "[" + s + "]";
    }    

    public static Object resolveOptionals(Object o) {
        if (o instanceof Optional) {
            return ((Optional<?>) o).orElse(null);
        } else if (o instanceof JsonNullable) {
            // TODO if JsonNullable.of(null) then we probably want an explicit null
            // to be used by the caller of this so should probably return an EXPLICIT_NULL 
            // (a singleton constant object that represents this scenario without us being
            // coupled to JsonNullable).
            return ((JsonNullable<?>) o).orElse(null);
        } else {
            return o;
        }
    }
    
    public static List<?> toList(Object o) {
        if (o == null) {
            return null;
        } else if (o instanceof List) {
            return (List<?>) o;
        } else if (o.getClass().isArray()) {
            return Arrays.asList((Object[]) o);
        } else {
            throw new IllegalArgumentException("argument must be List or array");
        }
    }
    
    public static <T> T readDefaultOrConstValue(String name, String json, TypeReference<T> typeReference) {
        try {
            return readValue(json, typeReference);
        } catch (JsonProcessingException e) {
            throw new IllegalArgumentException("default/const value did not match the expected type, name=" + name + ",json=\n" + json, e); 
        }
    }
    
    private static <T> T readValue(String json, TypeReference<T> typeReference) throws JsonProcessingException {
        return JSON.getMapper().readValue(json, typeReference);
    }
    
    public static byte[] extractByteArrayFromBody(HttpResponse<InputStream> response) throws IOException {
        return toByteArrayAndClose(response.body());   
    }
    
    public static byte[] toByteArrayAndClose(InputStream in) throws IOException {
        try {
            return IOUtils.toByteArray(in);
        } finally {
            in.close();
        }
    }
    
    public static String toUtf8AndClose(InputStream in) throws IOException {
        return new String(toByteArrayAndClose(in), StandardCharsets.UTF_8);
    }

    public static Object convertToShape(Object o, JsonShape shape, TypeReference<?> typeReference) {
        if (shape == JsonShape.STRING) {
            return convertToStringShape(o, typeReference);
        } else {
            return o; 
        } 
    }
    
    private static final Map<Class<?>, java.util.function.Function<Object, Object>> STRING_CONVERSIONS = Map.of(//
            BigInteger.class, o -> new BigIntegerString((BigInteger) o), //
            BigDecimal.class, o -> new BigDecimalString((BigDecimal) o));
            
    private static final Map<Class<?>, java.util.function.Function<Object, Object>> STRING_INVERSE_CONVERSIONS = Map.of(//
            BigIntegerString.class, o -> ((BigIntegerString) o).value(), //
            BigDecimalString.class, o -> ((BigDecimalString) o).value());


    private static Object convertToStringShape(Object o, TypeReference<?> typeReference) {
        JavaType jt = JSON
            .getMapper()
            .getTypeFactory()
            .resolveMemberType(typeReference.getType(), null);
        return convertToStringShape(o, jt);
    }
    
    private static Object convertToStringShape(Object o, JavaType jt) {
        if (jt.getRawClass().equals(List.class)) {
            List<?> list = (List<?>) o;
            return list.stream() //
                    .map(x -> convertToStringShape(x, jt.getContentType())) //
                    .collect(Collectors.toList());
        } else if (jt.getRawClass().equals(Map.class)) {
            Map<?, ?> map = (Map<?, ?>) o;
            Map<Object, Object> result = new HashMap<>();
            for (Entry<?, ?> entry : map.entrySet()) {
                result.put(entry.getKey(), convertToStringShape(entry.getValue(), jt.getContentType()));
            }
            return result;
        } else if (jt.getRawClass().equals(Optional.class)) {
            Optional<?> optional = (Optional<?>) o;
            if (optional.isPresent()) {
                return Optional.of(convertToStringShape(optional.get(), jt.getContentType()));
            } else {
                return o;
            }
        } else if (jt.getRawClass().equals(JsonNullable.class)) {
            JsonNullable<?> n = (JsonNullable<?>) o;
            if (n.isPresent()) {
                if (n.get() == null) {
                    return o;
                } else {
                    return JsonNullable.of(convertToStringShape(n.get(), jt.getContentType()));
                }
            } else {
                return o;
            }
        } else if (STRING_CONVERSIONS.containsKey(jt.getRawClass())) {
            return STRING_CONVERSIONS.get(jt.getRawClass()).apply(o);
        } else {
            return o;
        }
    }
    
    private static Object convertToStringShapeInverse(Object o, JavaType jt) {
        if (jt.getRawClass().equals(List.class)) {
            List<?> list = (List<?>) o;
            return list.stream() //
                    .map(x -> convertToStringShapeInverse(x, jt.getContentType())) //
                    .collect(Collectors.toList());
        } else if (jt.getRawClass().equals(Map.class)) {
            Map<?, ?> map = (Map<?, ?>) o;
            Map<Object, Object> result = new HashMap<>();
            for (Entry<?, ?> entry : map.entrySet()) {
                result.put(entry.getKey(), convertToStringShapeInverse(entry.getValue(), jt.getContentType()));
            }
            return result;
        } else if (jt.getRawClass().equals(Optional.class)) {
            Optional<?> optional = (Optional<?>) o;
            if (optional.isPresent()) {
                return Optional.of(convertToStringShapeInverse(optional.get(), jt.getContentType()));
            } else {
                return o;
            }
        } else if (jt.getRawClass().equals(JsonNullable.class)) {
            JsonNullable<?> n = (JsonNullable<?>) o;
            if (n.isPresent()) {
                if (n.get() == null) {
                    return o;
                } else {
                    return JsonNullable.of(convertToStringShapeInverse(n.get(), jt.getContentType()));
                }
            } else {
                return o;
            }
        } else if (STRING_INVERSE_CONVERSIONS.containsKey(jt.getRawClass())) {
            return STRING_INVERSE_CONVERSIONS.get(jt.getRawClass()).apply(o);
        } else {
            return o;
        }
    }
    
    // used for deserialization
    static JavaType convertToShape(TypeFactory f, TypeReference<?> typeReference, JsonShape shape) {
        JavaType jt = f.resolveMemberType(typeReference.getType(), null);
        if (shape == JsonShape.STRING) {
            return convertToStringShape(f, jt);
        } else {
            return jt;
        }
    }
    
    static Object convertToShapeInverse(Object o, JsonShape shape, JavaType jt) {
        if (shape == JsonShape.STRING) {
            return convertToStringShapeInverse(o, jt);
        } else {
            return o;
        }
    }
    
    // VisibleForTesting
    public static JavaType convertToStringShape(TypeFactory f, JavaType a) {
        if (a.getRawClass().equals(List.class)) {
            JavaType b = convertToStringShape(f, a.getContentType());
            return f.constructCollectionType(List.class, b);
        } else if (a.getRawClass().equals(Map.class)) {
            JavaType key = f.constructType(String.class);
            JavaType value = convertToStringShape(f, a.getContentType());
            return f.constructMapType(Map.class, key, value);
        } else if (a.getRawClass().equals(Optional.class)) {
            JavaType b = convertToStringShape(f, a.getContentType());
            return f.constructParametricType(Optional.class, b);
        } else if (a.getRawClass().equals(JsonNullable.class)) {
            JavaType b = convertToStringShape(f, a.getContentType());
            return f.constructParametricType(JsonNullable.class, b);
        } else if (a.getRawClass().equals(BigInteger.class)) {
            return f.constructType(BigIntegerString.class);
        } else if (a.getRawClass().equals(BigDecimal.class)) {
            return f.constructType(BigDecimalString.class);
        } else {
            return a;
        }
    }
    
    public static final class TypeReferenceWithShape {
        private final TypeReference<?> typeReference;
        private final JsonShape shape;
        
        private TypeReferenceWithShape(TypeReference<?> typeReference, JsonShape shape) {
            this.typeReference = typeReference;
            this.shape = shape;
        }
        
        public static TypeReferenceWithShape of(TypeReference<?> typeReference, JsonShape shape) {
            return new TypeReferenceWithShape(typeReference, shape); 
        }
        
        public TypeReference<?> typeReference() {
            return typeReference;
        }
        
        public JsonShape shape() {
            return shape;
        }
    }
    
    static <T> Object resolveStringShape(Class<T> type, String fieldName, Object value) throws IllegalAccessException {
        try {
            // the presence of this TypeReference field indicates that the parameter
            // has a JsonShape of String and that we should convert BigInteger to 
            // BigIntegerString and BigDecimal to BigDecimalString
            // where explicitly mentioned in the TypeReference
            Field tr = type.getDeclaredField(fieldName + "_typeReference");
            tr.setAccessible(true);
            TypeReference<?> typeReference = (TypeReference<?>) tr.get(null);
            // adjust the value so BigInteger and BigDecimal serialize to string
            return convertToShape(value, JsonShape.STRING, typeReference);
        } catch (NoSuchFieldException e) {
            return value;
        }
    }
    
    public static <T> Stream<T> stream(Callable<Optional<T>> first, Function<T, Optional<T>> next) {
        return StreamSupport.stream(iterable(first, next).spliterator(), false);
    }
    
    // need a Function method that throws
    public interface Function<S, T> {
        T apply(S value) throws Exception;
    }
    
    private static <T> Iterable<T> iterable(Callable<Optional<T>> first, Function<T, Optional<T>> next) {
        return new Iterable<T>() {

            @Override
            public Iterator<T> iterator() {
                return new Iterator<T>() {

                    private boolean pending = true;

                    private Optional<T> nxt;

                    @Override
                    public boolean hasNext() {
                        load();
                        return nxt.isPresent();
                    }

                    @Override
                    public T next() {
                        load();
                        if (!nxt.isPresent()) {
                            throw new NoSuchElementException();
                        } else {
                            pending = true;
                            return nxt.get();
                        }
                    }

                    private void load() {
                        try {
                            if (pending) {
                                if (nxt == null) {
                                    nxt = first.call();
                                } else if (nxt.isPresent()) {
                                    nxt = next.apply(nxt.get());
                                } 
                                pending = false;
                            }
                        } catch (Exception e) {
                            rethrow(e);
                        }
                    }
                };
            }
        };
    }
    
    private static <T> T rethrow(Throwable e) {
        if (e instanceof RuntimeException) {
            throw (RuntimeException) e;
        } else if (e instanceof Error) {
            throw (Error) e;
        } else {
           throw new RuntimeException(e);
        }
    }
    
    public static boolean statusCodeMatches(int statusCode, String... expectedStatusCodes) {
        return Arrays.stream(expectedStatusCodes)
            .anyMatch(expected -> statusCodeMatchesOne(statusCode, expected));
    }
    
    // VisibleForTesting
    public static boolean statusCodeMatchesOne(int statusCode, String expectedStatusCode) {
        checkNotNull(expectedStatusCode, "expectedStatusCode");
        if (expectedStatusCode.toLowerCase(Locale.ENGLISH).equals("default")) {
            return true;
        }
        if (statusCode < 100 || statusCode >= 600) {
            throw new IllegalArgumentException("unexpected http status code: " + statusCode);
        }
        if (expectedStatusCode.length() != 3) {
            return false;
        }
        String firstDigit = String.valueOf(statusCode / 100);
        String firstDigitExpected = expectedStatusCode.substring(0, 1);
        if (!firstDigit.equals(firstDigitExpected)) {
            return false;
        } else if (expectedStatusCode.toUpperCase(Locale.ENGLISH).endsWith("XX")) {
            return true;
        } else {
            return expectedStatusCode.equals(String.valueOf(statusCode));
        }
    }
    
    /**
     * Returns an {@link HttpRequest.Builder} which is initialized with the 
     * state of the given {@link HttpRequest}.
     * 
     * @param request request to copy
     * @return a builder initialized with values from {@code request}
     */
    public static HttpRequest.Builder copy(HttpRequest request) {
        return copy(request, (k, v) -> true);
    }
    
    /**
     * Returns an {@link HttpRequest.Builder} which is initialized with the 
     * state of the given {@link HttpRequest}.
     * 
     * @param request request to copy
     * @param filter selects which header key-values to include in the copied request
     * @return a builder initialized with values from {@code request}
     */
    public static HttpRequest.Builder copy(HttpRequest request, BiPredicate<String, String> filter) {
        // in JDK 16+ we can use this
        // return HttpRequest.newBuilder(request, (k, v) -> true);
        checkNotNull(request, "request");

        final HttpRequest.Builder builder = HttpRequest.newBuilder();
        builder.uri(request.uri());
        builder.expectContinue(request.expectContinue());

        request.headers() 
            .map() 
            .forEach((name, values) ->
                values.stream()
                    .filter(v -> filter.test(name, v))
                    .forEach(value -> builder.header(name, value)));

        request.version().ifPresent(builder::version);
        request.timeout().ifPresent(builder::timeout);
        var method = request.method();
        request.bodyPublisher().ifPresentOrElse(
                // if body is present, set it
                bodyPublisher -> builder.method(method, bodyPublisher),
                // otherwise, the body is absent, special case for GET/DELETE,
                // or else use empty body
                () -> {
                    switch (method) {
                        case "GET": builder.GET();break;
                        case "DELETE" : builder.DELETE();break;
                        default : builder.method(method, HttpRequest.BodyPublishers.noBody());
                    }
                }
        );
        return builder;
    }
    
    // convenience method so that classes don't need to import a possibly colliding name of JSON 
    // (Utils is a very common import)
    public static ObjectMapper mapper() {
        return JSON.getMapper();
    }
    
    public static <T> T asType(EventStreamMessage x, ObjectMapper mapper, TypeReference<T> typeReference) {
        try {
            try {
                String json = json(x, mapper, false);
                return mapper.readValue(json, typeReference);
            } catch (JsonProcessingException e) {
                // retry with the assumption that data field is plain text
                String json = json(x, mapper, true);
                return mapper.readValue(json, typeReference);
            }
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }

    public static String json(EventStreamMessage m, ObjectMapper mapper, boolean dataIsPlainText)
            throws JsonProcessingException {
        ObjectNode node = mapper.createObjectNode();
        m.event().ifPresent(value -> node.set("event", new TextNode(value)));
        m.id().ifPresent(value -> node.set("id", new TextNode(value)));
        m.retryMs().ifPresent(value -> node.set("retry", new IntNode(value)));
        // data is always present (but may be an empty string)
        if (dataIsPlainText || m.data().trim().isEmpty()) {
            node.set("data", new TextNode(m.data()));
        } else {
            JsonNode tree = mapper.readTree(m.data());
            node.set("data", tree);
        }
        return mapper.writeValueAsString(node);
    }
    
    /**
     * Fully reads the body of the given response and caches it in memory. The
     * returned response has utility methods to view the body
     * ({@code bodyAsUtf8(), bodyAsBytes()} and the {@code body()} method can be
     * called multiple times, each returning a fresh {@link InputStream} that will
     * read from the cached byte array.
     * 
     * <p>
     * This method is most likely to be used in a diagnostic/logging situtation so
     * that the contents of a response can be viewed without affecting processing.
     * Using this method with a very large body may be problematic in
     * terms of memory use.
     * 
     * @param response response to cache
     * @return response with a cached body
     * @throws IOException
     */
    public static HttpResponseCached cache(HttpResponse<InputStream> response) throws IOException {
        return new HttpResponseCached(response);
    }
    
    public static final class HttpResponseCached implements HttpResponse<InputStream> {

        private final HttpResponse<InputStream> response;
        private final byte[] bytes;
        
        public HttpResponseCached(HttpResponse<InputStream> response) throws IOException {
            this.response = response;
            this.bytes = toByteArrayAndClose(response.body());
        }

        public String bodyAsUtf8() {
            return new String(bytes, StandardCharsets.UTF_8);
        }
        
        public byte[] bodyAsBytes() {
            return bytes;
        }

        @Override
        public int statusCode() {
            return response.statusCode();
        }

        @Override
        public HttpRequest request() {
            return response.request();
        }

        @Override
        public Optional<HttpResponse<InputStream>> previousResponse() {
            return response.previousResponse();
        }
        
        @Override
        public HttpHeaders headers() {
            return response.headers();
        }

        @Override
        public InputStream body() {
            return new ByteArrayInputStream(bytes);
        }

        @Override
        public Optional<SSLSession> sslSession() {
            return response.sslSession();
        }

        @Override
        public URI uri() {
            return response.uri();
        }

        @Override
        public Version version() {
            return response.version();
        }
        
        @Override
        public String toString() {
            return response.toString();
        }
    }
    
    private static final char[] HEX_ARRAY = "0123456789abcdef".toCharArray();

    public static String bytesToLowerCaseHex(byte[] bytes) {
        char[] hexChars = new char[bytes.length * 2];
        for (int j = 0; j < bytes.length; j++) {
            int v = bytes[j] & 0xFF;
            hexChars[j * 2] = HEX_ARRAY[v >>> 4];
            hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F];
        }
        return new String(hexChars);
    }
    
    @SuppressWarnings("unchecked")
    public static String discriminatorToString(Object o) {
        // expects o to be either an Optional<String>, Enum (with a String value() method)
        // or a String value
        Class<?> cls = o.getClass();
        if (cls.equals(Optional.class)) {
            Optional<String> a = (Optional<String>) o;
            return a.map(x -> discriminatorToString(x)).orElse(null);
        } else if (cls.isEnum()) {
            try {
                Method m = cls.getMethod("value");
                return (String) m.invoke(o);
            } catch (NoSuchMethodException | SecurityException | IllegalAccessException | IllegalArgumentException
                    | InvocationTargetException e) {
                throw new RuntimeException(e);
            }
        } else {
            return (String) o;
        }
    }
    
    public static void recordTest(String id) {
        try {
            new File("build").mkdir();
            Files.writeString(Paths.get("build/test-javav2-record.txt"), id + "\n", StandardOpenOption.CREATE,
                    StandardOpenOption.APPEND);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }
    
    /**
     * Returns an equivalent url with query parameters sorted by name. Sort is
     * stable in that parameters with the same name will not be reordered. 
     * 
     * @param url input
     * @return url with query parameters sorted by name
     */
    public static String sortQueryParameters(String url) {
        if (url == null || url.isBlank()) {
            return "";
        }
        String[] parts = url.split("\\?");
        if (parts.length == 1) {
            return url;
        }
        String query = parts[1];
        String[] params = query.split("&");
        sortByDelimitedKey(params, "=");
        return parts[0] + "?" + Arrays.stream(params).collect(Collectors.joining("&"));
    }

    public static Object sortSerializedMaps(Object input, String regex, String delim) {
        if (input == null) {
            return input;
        } else if (input instanceof String) {
            return sortMapString((String) input, regex, delim);
        } else if (input.getClass().isArray()) {
            Object[] a = (Object[]) input;
            String[] b = new String[a.length];
            for (int i = 0; i < a.length; i++) {
                if (!(a[i] instanceof String)) {
                    throw new IllegalArgumentException("expected array item type of String, found " + a[i]);
                }
                b[i] = sortMapString((String) a[i], regex, delim);
            }
            return b;
        } else if (input instanceof Map) {
            @SuppressWarnings("unchecked")
            Map<Object, Object> a = (Map<Object, Object>) input;
            Map<String, String> b = new LinkedHashMap<>();
            for (Entry<Object, Object> entry: a.entrySet()) {
                if (!(entry.getKey() instanceof String)) {
                    throw new IllegalArgumentException("expected map key type of String, found " + entry.getKey());
                }
                if (!(entry.getValue() instanceof String)) {
                    throw new IllegalArgumentException("expected map value type of String, found " + entry.getValue());
                }
                b.put((String) entry.getKey(), sortMapString((String) entry.getValue(), regex, delim));
            }
            return b;
        } else {
            throw new IllegalArgumentException("unexpected type: " + input.getClass());
        }
    }
    
    private static String sortMapString(String input, String regex, String delim) {
        return Pattern.compile(regex).matcher(input).replaceAll(m -> {
            String escapedDelim = Pattern.quote(delim);
            String result = m.group();
            for (int i = 1; i <= m.groupCount(); i++) {
                final String match = m.group(i);
                String[] pairs;
                if (match.contains("=")) {
                    pairs = match.split(escapedDelim);
                    sortByDelimitedKey(pairs, "=");
                } else {
                    String[] values = match.split(escapedDelim);
                    if (values.length == 1) {
                        pairs = values;
                    } else {
                        pairs = new String[values.length / 2];
                        for (int j = 0; j < values.length; j += 2) {
                            pairs[j / 2] = values[j] + delim + values[j + 1];
                        }
                    }
                    sortByDelimitedKey(pairs, delim);
                }
                String joined = Arrays.stream(pairs).collect(Collectors.joining(delim));
                result = result.replace(m.group(i), joined);
            }
            return result;
        });
    }
    
    private static void sortByDelimitedKey(String[] array, String delim) {
        Arrays.sort(array, (a, b) -> {
            String escapedDelim = Pattern.quote(delim);
            String aKey = a.split(escapedDelim)[0];
            String bKey = b.split(escapedDelim)[0];
            return aKey.compareTo(bKey);
        });
    }

    public static boolean isPresentAndNotNull(Optional<?> x) {
        return x.isPresent();    
    }
    
    public static boolean isPresentAndNotNull(JsonNullable<?> x) {
        return x.isPresent() && x.get() != null;
    }

    private static final String OPEN_BRACKET_MARKER = UUID.randomUUID().toString().replace("-", "");
    private static final String CLOSE_BRACKET_MARKER = UUID.randomUUID().toString().replace("-", "");
    
    public static String urlEncode(String s) {
        // Ensure that complies with RFC 2732 (URLEncoder does not and we don't want to
        // encode [, ] chars)
        return URLEncoder.encode( //
                s.replace("[", OPEN_BRACKET_MARKER) //
                        .replace("]", CLOSE_BRACKET_MARKER), //
                StandardCharsets.UTF_8) //
                .replace(OPEN_BRACKET_MARKER, "[") //
                .replace(CLOSE_BRACKET_MARKER, "]");
    }
    
}
