/*
 * Copyright (c) 2020 Oracle and/or its affiliates.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package io.helidon.build.cli.plugin;

import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.net.URLConnection;
import java.net.UnknownHostException;
import java.util.HashMap;
import java.util.Map;

import static java.util.Objects.requireNonNull;


/**
 * A builder for accessing a network stream. Supports retries.
 */
public class NetworkConnection {

    /**
     * Connects to a URL.
     */
    public interface Connector {
        /**
         * Returns the connection after connecting to the given url.
         *
         * @param url The url.
         * @param headers The headers.
         * @param connectTimeout The connect timeout, in milliseconds.
         * @param readTimeout The read timeout, in milliseconds.
         * @return The connection.
         * @throws IOException If an error occurs.
         */
        URLConnection connect(URL url, Map<String, String> headers, int connectTimeout, int readTimeout) throws IOException;
    }

    /**
     * Performs retry delays.
     */
    public interface RetryDelay {
        /**
         * Performs a delay.
         *
         * @param attempt The attempt number. Always > 0.
         * @param maxAttempts The maximum number of attempts.
         */
        void execute(int attempt, int maxAttempts);
    }

    /**
     * The default connector.
     */
    public static final Connector DEFAULT_CONNECTOR = (url, headers, connectTimeout, readTimeout) -> {
        final URLConnection connection = url.openConnection();
        connection.setConnectTimeout(connectTimeout);
        connection.setReadTimeout(readTimeout);
        headers.forEach(connection::addRequestProperty);
        if (connection instanceof HttpURLConnection) {
            ((HttpURLConnection) connection).setInstanceFollowRedirects(true);
        }
        return connection;
    };

    /**
     * A {@link RetryDelay} that supports a linearly increasing delay.
     */
    public static final class LinearRetryDelay implements RetryDelay {
        private final long initialDelay;
        private final long increment;

        /**
         * Constructor.
         *
         * @param initialDelay The initial delay, in milliseconds.
         * @param increment The number of milliseconds to add to the initial delay for each retry.
         */
        public LinearRetryDelay(long initialDelay, long increment) {
            this.initialDelay = initialDelay;
            this.increment = increment;
        }

        @Override
        public void execute(int attempt, int maxAttempts) {
            try {
                final long delay = initialDelay + (attempt * increment);
                if (Log.isVerbose()) {
                    final float seconds = delay / 1000F;
                    Log.info("  $(italic retry %d of %d, sleeping for %.1f seconds)", attempt, maxAttempts, seconds);
                } else {
                    Log.info("  $(italic retry %d of %d)", attempt, maxAttempts);
                }
                Thread.sleep(delay);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }

    /**
     * The default maximum number of attempts.
     */
    public static final int DEFAULT_MAXIMUM_ATTEMPTS = 5;

    /**
     * The default connect timeout, in milliseconds.
     */
    public static final int DEFAULT_CONNECT_TIMEOUT = 500;

    /**
     * The default read timeout, in milliseconds.
     */
    public static final int DEFAULT_READ_TIMEOUT = 500;

    /**
     * The default retry delay. Linear, with an initial delay of 500 milliseconds, incrementing by 500 on each retry.
     */
    public static final RetryDelay DEFAULT_RETRY_DELAY = new LinearRetryDelay(500, 500);

    /**
     * Returns a new builder.
     *
     * @return The builder.
     */
    public static Builder builder() {
        return new Builder();
    }

    /**
     * Builder.
     */
    public static class Builder {
        private URL url;
        private int maxAttempts;
        private int connectTimeout;
        private int readTimeout;
        private Connector connector;
        private RetryDelay delay;
        private Map<String, String> headers;

        private Builder() {
            this.maxAttempts = DEFAULT_MAXIMUM_ATTEMPTS;
            this.connectTimeout = DEFAULT_CONNECT_TIMEOUT;
            this.readTimeout = DEFAULT_READ_TIMEOUT;
            this.connector = DEFAULT_CONNECTOR;
            this.delay = DEFAULT_RETRY_DELAY;
            this.headers = new HashMap<>();
        }

        /**
         * Sets the url to open.
         *
         * @param url The url to open.
         * @return This instance, for chaining.
         */
        public Builder url(String url) {
            try {
                return url(new URL(requireNonNull(url)));
            } catch (MalformedURLException e) {
                throw new RuntimeException(e);
            }
        }

        /**
         * Sets the url to open.
         *
         * @param url The url to open.
         * @return This instance, for chaining.
         */
        public Builder url(URL url) {
            this.url = requireNonNull(url);
            return this;
        }

        /**
         * Add a header.
         *
         * @param name The header name.
         * @param value The header value.
         * @return This instance, for chaining.
         */
        public Builder header(String name, String value) {
            headers.put(requireNonNull(name), requireNonNull(value));
            return this;
        }

        /**
         * Add headers.
         *
         * @param headers The headers.
         * @return This instance, for chaining.
         */
        public Builder headers(Map<String, String> headers) {
            this.headers.putAll(requireNonNull(headers));
            return this;
        }

        /**
         * Sets the maximum number of attempts.
         *
         * @param maxAttempts The maximum number of attempts.
         * @return This instance, for chaining.
         */
        public Builder maxAttempts(int maxAttempts) {
            if (maxAttempts <= 0) {
                throw new IllegalArgumentException("maxAttempts must be > 0");
            }
            this.maxAttempts = maxAttempts;
            return this;
        }

        /**
         * Sets the connect timeout.
         *
         * @param connectTimeout The timeout.
         * @return This instance, for chaining.
         */
        public Builder connectTimeout(int connectTimeout) {
            if (connectTimeout <= 0) {
                throw new IllegalArgumentException("connect timeout must be > 0");
            }
            this.connectTimeout = connectTimeout;
            return this;
        }

        /**
         * Sets the read timeout.
         *
         * @param readTimeout The timeout.
         * @return This instance, for chaining.
         */
        public Builder readTimeout(int readTimeout) {
            if (readTimeout <= 0) {
                throw new IllegalArgumentException("read timeout must be > 0");
            }
            this.readTimeout = readTimeout;
            return this;
        }

        /**
         * Sets the connector .
         *
         * @param connector The connector.
         * @return This instance, for chaining.
         */
        public Builder connector(Connector connector) {
            this.connector = requireNonNull(connector);
            return this;
        }

        /**
         * Sets the retry delay.
         *
         * @param retryDelay The delay.
         * @return This instance, for chaining.
         */
        public Builder retryDelay(RetryDelay retryDelay) {
            this.delay = requireNonNull(retryDelay);
            return this;
        }

        /**
         * Connect, retrying if needed.
         *
         * @return The connection.
         */
        public URLConnection connect() throws IOException {
            if (url == null) {
                throw new IllegalStateException("url is required");
            }
            Log.debug("connecting to %s, headers=%s", url, headers);
            IOException lastCaught = null;
            for (int attempt = 1; attempt <= maxAttempts; attempt++) {
                try {
                    URLConnection result = connector.connect(url, headers, connectTimeout, readTimeout);
                    Log.debug("connected to %s, headers=%s", url, result.getHeaderFields());
                    return result;
                } catch (UnknownHostException | SocketException | SocketTimeoutException e) {
                    lastCaught = e;
                    delay.execute(attempt, maxAttempts);
                }
            }
            throw requireNonNull(lastCaught);
        }

        /**
         * Open the stream, retrying if needed.
         *
         * @return The stream.
         */
        public InputStream open() throws IOException {
            return connect().getInputStream();
        }
    }

    private NetworkConnection() {
    }
}