/*
 * ====================================================================
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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.
 * ====================================================================
 *
 * This software consists of voluntary contributions made by many
 * individuals on behalf of the Apache Software Foundation.  For more
 * information on the Apache Software Foundation, please see
 * <http://www.apache.org/>.
 *
 */

package org.apache.hc.client5.http;

import java.net.InetAddress;
import java.nio.file.Path;
import java.util.Objects;

import org.apache.hc.core5.http.HttpHost;
import org.apache.hc.core5.util.Args;
import org.apache.hc.core5.util.Asserts;
import org.apache.hc.core5.util.LangUtils;

/**
 * Helps tracking the steps in establishing a route.
 *
 * @since 4.0
 */
public final class RouteTracker implements RouteInfo, Cloneable {

    /** The target host to connect to. */
    private final HttpHost targetHost;

    /**
     * The local address to connect from.
     * {@code null} indicates that the default should be used.
     */
    private final InetAddress localAddress;

    /** The Unix domain socket to connect through, if any. */
    private final Path unixDomainSocket;

    // the attributes above are fixed at construction time
    // now follow attributes that indicate the established route

    /** Whether the first hop of the route is established. */
    private boolean connected;

    /** The proxy chain, if any. */
    private HttpHost[] proxyChain;

    /** Whether the the route is tunnelled end-to-end through proxies. */
    private TunnelType tunnelled;

    /** Whether the route is layered over a tunnel. */
    private LayerType layered;

    /** Whether the route is secure. */
    private boolean secure;

    /**
     * Creates a new route tracker.
     * The target and origin need to be specified at creation time.
     *
     * @param target    the host to which to route
     * @param local     the local address to route from, or
     *                  {@code null} for the default
     */
    public RouteTracker(final HttpHost target, final InetAddress local) {
        this(target, local, null);
    }

    /**
     * Creates a new route tracker.
     * The target and origin need to be specified at creation time.
     *
     * @param target              the host to which to route
     * @param local               the local address to route from, or
     *                            {@code null} for the default
     * @param unixDomainSocket    the path to the Unix domain socket
     *                            through which to connect, or {@code null}
     */
    public RouteTracker(final HttpHost target, final InetAddress local, final Path unixDomainSocket) {
        Args.notNull(target, "Target host");
        this.targetHost = target;
        this.localAddress = local;
        this.unixDomainSocket = unixDomainSocket;
        this.tunnelled = TunnelType.PLAIN;
        this.layered = LayerType.PLAIN;
    }

    /**
     * @since 4.2
     */
    public void reset() {
        this.connected = false;
        this.proxyChain = null;
        this.tunnelled = TunnelType.PLAIN;
        this.layered = LayerType.PLAIN;
        this.secure = false;
    }

    /**
     * Creates a new tracker for the given route.
     * Only target and origin are taken from the route,
     * everything else remains to be tracked.
     *
     * @param route     the route to track
     */
    public RouteTracker(final HttpRoute route) {
        this(route.getTargetHost(), route.getLocalAddress(), route.getUnixDomainSocket());
    }

    /**
     * Tracks connecting to the target.
     *
     * @param secure    {@code true} if the route is secure,
     *                  {@code false} otherwise
     */
    public void connectTarget(final boolean secure) {
        Asserts.check(!this.connected, "Already connected");
        this.connected = true;
        this.secure = secure;
    }

    /**
     * Tracks connecting to the first proxy.
     *
     * @param proxy     the proxy connected to
     * @param secure    {@code true} if the route is secure,
     *                  {@code false} otherwise
     */
    public void connectProxy(final HttpHost proxy, final boolean secure) {
        Args.notNull(proxy, "Proxy host");
        Asserts.check(!this.connected, "Already connected");
        this.connected = true;
        this.proxyChain = new HttpHost[]{proxy};
        this.secure = secure;
    }

    /**
     * Tracks tunnelling to the target.
     *
     * @param secure    {@code true} if the route is secure,
     *                  {@code false} otherwise
     */
    public void tunnelTarget(final boolean secure) {
        Asserts.check(this.connected, "No tunnel unless connected");
        Asserts.notNull(this.proxyChain, "No tunnel without proxy");
        this.tunnelled = TunnelType.TUNNELLED;
        this.secure = secure;
    }

    /**
     * Tracks tunnelling to a proxy in a proxy chain.
     * This will extend the tracked proxy chain, but it does not mark
     * the route as tunnelled. Only end-to-end tunnels are considered there.
     *
     * @param proxy     the proxy tunnelled to
     * @param secure    {@code true} if the route is secure,
     *                  {@code false} otherwise
     */
    public void tunnelProxy(final HttpHost proxy, final boolean secure) {
        Args.notNull(proxy, "Proxy host");
        Asserts.check(this.connected, "No tunnel unless connected");
        Asserts.notNull(this.proxyChain, "No tunnel without proxy");
        // prepare an extended proxy chain
        final HttpHost[] proxies = new HttpHost[this.proxyChain.length + 1];
        System.arraycopy(this.proxyChain, 0,
                proxies, 0, this.proxyChain.length);
        proxies[proxies.length - 1] = proxy;

        this.proxyChain = proxies;
        this.secure = secure;
    }

    /**
     * Tracks layering a protocol.
     *
     * @param secure    {@code true} if the route is secure,
     *                  {@code false} otherwise
     */
    public void layerProtocol(final boolean secure) {
        // it is possible to layer a protocol over a direct connection,
        // although this case is probably not considered elsewhere
        Asserts.check(this.connected, "No layered protocol unless connected");
        this.layered = LayerType.LAYERED;
        this.secure = secure;
    }

    @Override
    public HttpHost getTargetHost() {
        return this.targetHost;
    }

    @Override
    public InetAddress getLocalAddress() {
        return this.localAddress;
    }

    @Override
    public Path getUnixDomainSocket() {
        return this.unixDomainSocket;
    }

    @Override
    public int getHopCount() {
        int hops = 0;
        if (this.connected) {
            if (proxyChain == null) {
                hops = 1;
            } else {
                hops = proxyChain.length + 1;
            }
        }
        return hops;
    }

    @Override
    public HttpHost getHopTarget(final int hop) {
        Args.notNegative(hop, "Hop index");
        final int hopcount = getHopCount();
        Args.check(hop < hopcount, "Hop index exceeds tracked route length");
        HttpHost result = null;
        if (hop < hopcount - 1) {
            result = this.proxyChain[hop];
        } else {
            result = this.targetHost;
        }

        return result;
    }

    @Override
    public HttpHost getProxyHost() {
        return this.proxyChain == null ? null : this.proxyChain[0];
    }

    public boolean isConnected() {
        return this.connected;
    }

    @Override
    public TunnelType getTunnelType() {
        return this.tunnelled;
    }

    @Override
    public boolean isTunnelled() {
        return this.tunnelled == TunnelType.TUNNELLED;
    }

    @Override
    public LayerType getLayerType() {
        return this.layered;
    }

    @Override
    public boolean isLayered() {
        return this.layered == LayerType.LAYERED;
    }

    @Override
    public boolean isSecure() {
        return this.secure;
    }

    /**
     * Obtains the tracked route.
     * If a route has been tracked, it is {@link #isConnected connected}.
     * If not connected, nothing has been tracked so far.
     *
     * @return  the tracked route, or
     *          {@code null} if nothing has been tracked so far
     */
    public HttpRoute toRoute() {
        if (!this.connected) {
            return null;
        } else if (this.unixDomainSocket != null) {
            return new HttpRoute(this.targetHost, this.secure, this.unixDomainSocket);
        } else {
            return new HttpRoute(this.targetHost, this.localAddress,
                this.proxyChain, this.secure,
                this.tunnelled, this.layered);
        }
    }

    /**
     * Compares this tracked route to another.
     *
     * @param o         the object to compare with
     *
     * @return  {@code true} if the argument is the same tracked route,
     *          {@code false}
     */
    @Override
    public boolean equals(final Object o) {
        if (o == this) {
            return true;
        }
        if (!(o instanceof RouteTracker)) {
            return false;
        }

        final RouteTracker that = (RouteTracker) o;
        return
                // Do the cheapest checks first
                this.connected == that.connected &&
                        this.secure == that.secure &&
                        this.tunnelled == that.tunnelled &&
                        this.layered == that.layered &&
                        Objects.equals(this.targetHost, that.targetHost) &&
                        Objects.equals(this.localAddress, that.localAddress) &&
                        Objects.equals(this.unixDomainSocket, that.unixDomainSocket) &&
                        Objects.equals(this.proxyChain, that.proxyChain);
    }

    /**
     * Generates a hash code for this tracked route.
     * Route trackers are modifiable and should therefore not be used
     * as lookup keys. Use {@link #toRoute toRoute} to obtain an
     * unmodifiable representation of the tracked route.
     *
     * @return  the hash code
     */
    @Override
    public int hashCode() {
        int hash = LangUtils.HASH_SEED;
        hash = LangUtils.hashCode(hash, this.targetHost);
        hash = LangUtils.hashCode(hash, this.localAddress);
        hash = LangUtils.hashCode(hash, this.unixDomainSocket);
        if (this.proxyChain != null) {
            for (final HttpHost element : this.proxyChain) {
                hash = LangUtils.hashCode(hash, element);
            }
        }
        hash = LangUtils.hashCode(hash, this.connected);
        hash = LangUtils.hashCode(hash, this.secure);
        hash = LangUtils.hashCode(hash, this.tunnelled);
        hash = LangUtils.hashCode(hash, this.layered);
        return hash;
    }

    /**
     * Obtains a description of the tracked route.
     *
     * @return  a human-readable representation of the tracked route
     */
    @Override
    public String toString() {
        final StringBuilder cab = new StringBuilder(50 + getHopCount() * 30);

        cab.append("RouteTracker[");
        if (this.localAddress != null) {
            cab.append(this.localAddress);
            cab.append("->");
        } else if (this.unixDomainSocket != null) {
            cab.append(this.unixDomainSocket).append("->");
        }
        cab.append('{');
        if (this.connected) {
            cab.append('c');
        }
        if (this.tunnelled == TunnelType.TUNNELLED) {
            cab.append('t');
        }
        if (this.layered == LayerType.LAYERED) {
            cab.append('l');
        }
        if (this.secure) {
            cab.append('s');
        }
        cab.append("}->");
        if (this.proxyChain != null) {
            for (final HttpHost element : this.proxyChain) {
                cab.append(element);
                cab.append("->");
            }
        }
        cab.append(this.targetHost);
        cab.append(']');

        return cab.toString();
    }


    // default implementation of clone() is sufficient
    @Override
    public Object clone() throws CloneNotSupportedException {
        return super.clone();
    }

}
