/*
 * Copyright 2020 LINE Corporation
 *
 * LINE Corporation 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:
 *
 *   https://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 com.linecorp.armeria.server.eureka;

import static com.linecorp.armeria.server.eureka.InstanceInfoBuilder.disabledPort;
import static java.util.Objects.requireNonNull;

import java.net.Inet4Address;
import java.net.URI;
import java.util.Optional;
import java.util.concurrent.TimeUnit;

import javax.annotation.Nullable;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.linecorp.armeria.client.ClientRequestContext;
import com.linecorp.armeria.client.ClientRequestContextCaptor;
import com.linecorp.armeria.client.Clients;
import com.linecorp.armeria.client.endpoint.EndpointGroup;
import com.linecorp.armeria.common.HttpResponse;
import com.linecorp.armeria.common.HttpStatus;
import com.linecorp.armeria.common.ResponseHeaders;
import com.linecorp.armeria.common.SessionProtocol;
import com.linecorp.armeria.common.util.SystemInfo;
import com.linecorp.armeria.internal.common.eureka.EurekaWebClient;
import com.linecorp.armeria.internal.common.eureka.InstanceInfo;
import com.linecorp.armeria.internal.common.eureka.InstanceInfo.InstanceStatus;
import com.linecorp.armeria.internal.common.eureka.InstanceInfo.PortWrapper;
import com.linecorp.armeria.server.Route;
import com.linecorp.armeria.server.RoutePathType;
import com.linecorp.armeria.server.Server;
import com.linecorp.armeria.server.ServerListener;
import com.linecorp.armeria.server.ServerListenerAdapter;
import com.linecorp.armeria.server.ServerPort;
import com.linecorp.armeria.server.ServiceConfig;
import com.linecorp.armeria.server.healthcheck.HealthCheckService;

import io.netty.channel.EventLoop;
import io.netty.util.NetUtil;
import io.netty.util.ReferenceCountUtil;
import io.netty.util.concurrent.ScheduledFuture;

/**
 * A {@link ServerListener} which registers the current {@link Server} to Eureka.
 * {@link EurekaUpdatingListener} sends renewal requests periodically so that the {@link Server} is not removed
 * from the registry. When the {@link Server} stops, {@link EurekaUpdatingListener} deregisters the
 * {@link Server} from Eureka by sending a cancellation request.
 */
public final class EurekaUpdatingListener extends ServerListenerAdapter {

    private static final Logger logger = LoggerFactory.getLogger(EurekaUpdatingListener.class);

    /**
     * Returns a new {@link EurekaUpdatingListener} which registers the current {@link Server} to
     * the specified {@code eurekaUri}.
     */
    public static EurekaUpdatingListener of(String eurekaUri) {
        return of(URI.create(requireNonNull(eurekaUri, "eurekaUri")));
    }

    /**
     * Returns a new {@link EurekaUpdatingListener} which registers the current {@link Server} to
     * the specified {@code eurekaUri}.
     */
    public static EurekaUpdatingListener of(URI eurekaUri) {
        return new EurekaUpdatingListenerBuilder(eurekaUri).build();
    }

    /**
     * Returns a new {@link EurekaUpdatingListener} which registers the current {@link Server} to
     * the specified {@link EndpointGroup}.
     */
    public static EurekaUpdatingListener of(
            SessionProtocol sessionProtocol, EndpointGroup endpointGroup) {
        return new EurekaUpdatingListenerBuilder(sessionProtocol, endpointGroup, null).build();
    }

    /**
     * Returns a new {@link EurekaUpdatingListener} which registers the current {@link Server} to
     * the specified {@link EndpointGroup} under the specified {@code path}.
     */
    public static EurekaUpdatingListener of(
            SessionProtocol sessionProtocol, EndpointGroup endpointGroup, String path) {
        return new EurekaUpdatingListenerBuilder(
                sessionProtocol, endpointGroup, requireNonNull(path, "path")).build();
    }

    /**
     * Returns a new {@link EurekaUpdatingListenerBuilder} created with the specified {@code eurekaUri}.
     */
    public static EurekaUpdatingListenerBuilder builder(String eurekaUri) {
        return builder(URI.create(requireNonNull(eurekaUri, "eurekaUri")));
    }

    /**
     * Returns a new {@link EurekaUpdatingListenerBuilder} created with the specified {@code eurekaUri}.
     */
    public static EurekaUpdatingListenerBuilder builder(URI eurekaUri) {
        return new EurekaUpdatingListenerBuilder(eurekaUri);
    }

    /**
     * Returns a new {@link EurekaUpdatingListenerBuilder} created with the specified {@link SessionProtocol}
     * and {@link EndpointGroup}.
     */
    public static EurekaUpdatingListenerBuilder builder(
            SessionProtocol sessionProtocol, EndpointGroup endpointGroup) {
        return new EurekaUpdatingListenerBuilder(sessionProtocol, endpointGroup, null);
    }

    /**
     * Returns a new {@link EurekaUpdatingListenerBuilder} created with the specified {@link SessionProtocol},
     * {@link EndpointGroup} and path.
     */
    public static EurekaUpdatingListenerBuilder builder(
            SessionProtocol sessionProtocol, EndpointGroup endpointGroup, String path) {
        return new EurekaUpdatingListenerBuilder(sessionProtocol, endpointGroup, requireNonNull(path, "path"));
    }

    private final EurekaWebClient client;
    private final InstanceInfo instanceInfo;
    @Nullable
    private volatile ScheduledFuture<?> heartBeatFuture;

    @Nullable
    private volatile String appName; // Set when serverStarted is called.
    private volatile boolean closed;

    /**
     * Creates a new instance.
     */
    EurekaUpdatingListener(EurekaWebClient client, InstanceInfo instanceInfo) {
        this.client = client;
        this.instanceInfo = instanceInfo;
    }

    @Override
    public void serverStarted(Server server) throws Exception {
        final InstanceInfo newInfo = fillAndCreateNewInfo(instanceInfo, server);

        try (ClientRequestContextCaptor contextCaptor = Clients.newContextCaptor()) {
            final HttpResponse response = client.register(newInfo);
            final ClientRequestContext ctx = contextCaptor.get();
            response.aggregate().handle((res, cause) -> {
                if (closed) {
                    return null;
                }
                if (cause != null) {
                    logger.warn("Failed to register {} to Eureka: {}",
                                newInfo.getHostName(), client.uri(), cause);
                    return null;
                }
                final ResponseHeaders headers = res.headers();
                if (headers.status() != HttpStatus.NO_CONTENT) {
                    logger.warn("Failed to register {} to Eureka: {}. (status: {}, content: {})",
                                newInfo.getHostName(), client.uri(), headers.status(), res.contentUtf8());
                } else {
                    logger.info("Registered {} to Eureka: {}", newInfo.getHostName(), client.uri());
                    scheduleHeartBeat(ctx.eventLoop(), newInfo);
                }
                return null;
            });
        }
    }

    private void scheduleHeartBeat(EventLoop eventLoop, InstanceInfo newInfo) {
        heartBeatFuture = eventLoop.schedule(new HeartBeatTask(eventLoop, newInfo),
                                             newInfo.getLeaseInfo().getRenewalIntervalInSecs(),
                                             TimeUnit.SECONDS);
    }

    private InstanceInfo fillAndCreateNewInfo(InstanceInfo oldInfo, Server server) {
        final String defaultHostname = server.defaultHostname();
        final String hostName = oldInfo.getHostName() != null ? oldInfo.getHostName() : defaultHostname;
        appName = oldInfo.getAppName() != null ? oldInfo.getAppName() : hostName;
        final String instanceId = oldInfo.getInstanceId() != null ? oldInfo.getInstanceId() : hostName;

        final Inet4Address defaultInet4Address = SystemInfo.defaultNonLoopbackIpV4Address();
        final String defaultIpAddr = defaultInet4Address != null ? defaultInet4Address.getHostAddress()
                                                                 : null;
        final String ipAddr = oldInfo.getIpAddr() != null ? oldInfo.getIpAddr() : defaultIpAddr;
        final PortWrapper oldPortWrapper = oldInfo.getPort();
        final PortWrapper portWrapper = portWrapper(server, oldPortWrapper, SessionProtocol.HTTP);
        final PortWrapper oldSecurePortWrapper = oldInfo.getSecurePort();
        final PortWrapper securePortWrapper = portWrapper(server, oldSecurePortWrapper, SessionProtocol.HTTPS);

        final String vipAddress = vipAddress(oldInfo.getVipAddress(), hostName, portWrapper);
        final String secureVipAddress = vipAddress(oldInfo.getSecureVipAddress(), hostName, securePortWrapper);

        final Optional<ServiceConfig> healthCheckService =
                server.serviceConfigs()
                      .stream()
                      .filter(cfg -> cfg.service().as(HealthCheckService.class) != null)
                      .findFirst();

        final String hostnameOrIpAddr;
        if (oldInfo.getHostName() != null) {
            hostnameOrIpAddr = oldInfo.getHostName();
        } else if (ipAddr != null) {
            hostnameOrIpAddr = ipAddr;
        } else {
            hostnameOrIpAddr = hostName;
        }
        final String healthCheckUrl = healthCheckUrl(hostnameOrIpAddr, oldInfo.getHealthCheckUrl(), portWrapper,
                                                     healthCheckService, SessionProtocol.HTTP);
        final String secureHealthCheckUrl =
                healthCheckUrl(hostnameOrIpAddr, oldInfo.getSecureHealthCheckUrl(), securePortWrapper,
                               healthCheckService, SessionProtocol.HTTPS);

        return new InstanceInfo(instanceId, appName, oldInfo.getAppGroupName(), hostName, ipAddr,
                                vipAddress, secureVipAddress, portWrapper, securePortWrapper, InstanceStatus.UP,
                                oldInfo.getHomePageUrl(), oldInfo.getStatusPageUrl(), healthCheckUrl,
                                secureHealthCheckUrl, oldInfo.getDataCenterInfo(),
                                oldInfo.getLeaseInfo(), oldInfo.getMetadata());
    }

    private static PortWrapper portWrapper(Server server, PortWrapper oldPortWrapper,
                                           SessionProtocol protocol) {
        if (oldPortWrapper.isEnabled()) {
            for (ServerPort serverPort : server.activePorts().values()) {
                if (serverPort.hasProtocol(protocol) &&
                    serverPort.localAddress().getPort() == oldPortWrapper.getPort()) {
                    return oldPortWrapper;
                }
            }
            logger.warn("The specified port number {} does not exist. (expected one of activePorts: {})",
                        oldPortWrapper.getPort(), server.activePorts());
            return oldPortWrapper;
        }

        final ServerPort serverPort = server.activePort(protocol);
        if (serverPort == null) {
            return disabledPort;
        }
        return new PortWrapper(true, serverPort.localAddress().getPort());
    }

    @Nullable
    private static String vipAddress(@Nullable String vipAddress, String hostName, PortWrapper portWrapper) {
        if (!portWrapper.isEnabled()) {
            return null;
        }
        return vipAddress != null ? vipAddress : hostName + ':' + portWrapper.getPort();
    }

    @Nullable
    private static String healthCheckUrl(String hostnameOrIpAddr, @Nullable String oldHealthCheckUrl,
                                         PortWrapper portWrapper,
                                         Optional<ServiceConfig> healthCheckService,
                                         SessionProtocol sessionProtocol) {
        if (oldHealthCheckUrl != null) {
            return oldHealthCheckUrl;
        }
        if (!portWrapper.isEnabled() || !healthCheckService.isPresent()) {
            return null;
        }
        final ServiceConfig healthCheckServiceConfig = healthCheckService.get();
        final Route route = healthCheckServiceConfig.route();
        if (route.pathType() != RoutePathType.EXACT && route.pathType() != RoutePathType.PREFIX) {
            return null;
        }

        return sessionProtocol.uriText() + "://" + hostnameOrIpAddr(hostnameOrIpAddr) +
               ':' + portWrapper.getPort() + route.paths().get(0);
    }

    private static String hostnameOrIpAddr(String hostnameOrIpAddr) {
        if (NetUtil.isValidIpV6Address(hostnameOrIpAddr) && hostnameOrIpAddr.charAt(0) != '[') {
            return '[' + hostnameOrIpAddr + ']';
        }
        return hostnameOrIpAddr;
    }

    @Override
    public void serverStopping(Server server) throws Exception {
        closed = true;
        final ScheduledFuture<?> heartBeatFuture = this.heartBeatFuture;
        if (heartBeatFuture != null) {
            heartBeatFuture.cancel(false);
        }
        final String appName = this.appName;
        if (appName != null) {
            final String instanceId = instanceInfo.getInstanceId();
            assert instanceId != null;
            client.cancel(appName, instanceId).aggregate().handle((res, cause) -> {
                if (cause != null) {
                    logger.warn("Failed to deregister from Eureka: {}", client.uri(), cause);
                } else if (!res.status().isSuccess()) {
                    logger.warn("Failed to deregister from Eureka: {} (status: {}, content: {})",
                                client.uri(), res.status(), res.contentUtf8());
                }
                return null;
            });
        }
    }

    private class HeartBeatTask implements Runnable {

        private final EventLoop eventLoop;
        private final InstanceInfo instanceInfo;

        HeartBeatTask(EventLoop eventLoop, InstanceInfo instanceInfo) {
            this.eventLoop = eventLoop;
            this.instanceInfo = instanceInfo;
        }

        @Override
        public void run() {
            final String appName = instanceInfo.getAppName();
            final String instanceId = instanceInfo.getInstanceId();
            assert appName != null;
            assert instanceId != null;
            client.sendHeartBeat(appName, instanceId, instanceInfo, null)
                  .aggregate()
                  .handle((res, cause) -> {
                      try {
                          if (closed) {
                              return null;
                          }

                          // The information of this instance is removed from the registry when the heart beats
                          // fail consecutive three times, so we don't retry.
                          // See https://github.com/Netflix/eureka/wiki/Understanding-eureka-client-server-communication#renew
                          if (cause != null) {
                              logger.warn("Failed to send a heart beat to Eureka: {}", client.uri(), cause);
                          } else if (res.headers().status() != HttpStatus.OK) {
                              logger.warn("Failed to send a heart beat to Eureka: {}, " +
                                          "(status: {}, content: {})",
                                          client.uri(), res.headers().status(), res.contentUtf8());
                          }
                          heartBeatFuture = eventLoop.schedule(
                                  this, instanceInfo.getLeaseInfo().getRenewalIntervalInSecs(),
                                  TimeUnit.SECONDS);
                          return null;
                      } finally {
                          ReferenceCountUtil.release(res.content());
                      }
                  });
        }
    }
}