/*
 * 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.
 */
package org.apache.rya.export.client.conf;

import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.UnknownHostException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.net.ntp.NTPUDPClient;
import org.apache.commons.net.ntp.TimeInfo;
import org.apache.log4j.Logger;
import org.mortbay.jetty.HttpMethods;

import com.google.common.net.HttpHeaders;

/**
 * Utility methods for time.
 */
public final class TimeUtils {
    private static final Logger log = Logger.getLogger(TimeUtils.class);

    /**
     * The default host name of the time server to use.
     * List of time servers: http://tf.nist.gov/tf-cgi/servers.cgi
     * Do not query time server more than once every 4 seconds.
     */
    public static final String DEFAULT_TIME_SERVER_HOST = "time.nist.gov";

    private static final int NTP_SERVER_TIMEOUT_MS = 15000;

    /**
     * Queries the default NTP Server for the time.
     * Do not query time server more than once every 4 seconds.
     * @return the NTP server {@link Date} or {@code null}.
     * @throws IOException
     */
    public static Date getDefaultNtpServerDate() throws IOException {
        return getNtpServerDate(DEFAULT_TIME_SERVER_HOST);
    }

    /**
     * Queries the specified NTP Server for the time.
     * Do not query time server more than once every 4 seconds.
     * @param timeServerHost the time server host name.
     * @return the NTP server {@link Date} or {@code null}.
     * @throws IOException
     */
    public static Date getNtpServerDate(final String timeServerHost) throws IOException {
        try {
            TimeInfo timeInfo = null;
            final NTPUDPClient timeClient = new NTPUDPClient();
            timeClient.setDefaultTimeout(NTP_SERVER_TIMEOUT_MS);
            final InetAddress inetAddress = InetAddress.getByName(timeServerHost);
            if (inetAddress != null) {
                timeInfo = timeClient.getTime(inetAddress);
                if (timeInfo != null) {
                    // TODO: which time to use?
                    final long serverTime = timeInfo.getMessage().getTransmitTimeStamp().getTime();
                    //long serverTime = timeInfo.getReturnTime();
                    final Date ntpDate = new Date(serverTime);
                    return ntpDate;
                }
            }
        } catch (final IOException e) {
            throw new IOException("Unable to get NTP server time.", e);
        }
        return null;
    }

    /**
     * Gets the remote machine's system time by checking the DATE field in the header
     * from a HTTP HEAD method response.
     * @param urlString the URL string of the remote machine's web server to connect to.
     * @return the remote machine's system {@link Date} or {@code null}.
     * @throws IOException
     * @throws ParseException
     */
    public static Date getRemoteMachineDate(final String urlString) throws IOException, ParseException {
        Date remoteDate = null;
        HttpURLConnection conn = null;
        try {
            final URL url = new URL(urlString);

            // Set up the initial connection
            conn = (HttpURLConnection)url.openConnection();
            // Use HEAD instead of GET so content isn't returned.
            conn.setRequestMethod(HttpMethods.HEAD);
            conn.setDoOutput(false);
            conn.setReadTimeout(10000);

            conn.connect();

            final Map<String, List<String>> header = conn.getHeaderFields();
            for (final String key : header.keySet()) {
                if (key != null && HttpHeaders.DATE.equals(key)) {
                    final List<String> data = header.get(key);
                    final String dateString = data.get(0);
                    final SimpleDateFormat sdf = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z");
                    remoteDate = sdf.parse(dateString);
                    break;
                }
            }
        } finally {
            // Close the connection
            if (conn != null) {
                conn.disconnect();
            }
        }

        return remoteDate;
    }

    /**
     * Gets the time difference between the 2 specified times from the NTP server time and the machine system time.
     * @param ntpDate the {@link Date} from the NTP server host.
     * @param machineDate the {@link Date} from the machine (either local or remote).
     * @param isMachineLocal {@code true} if the {@code machineDate} from a local machine.  {@code false}
     * if it's from a remote machine.
     * @return the difference between the NTP server time and the machine's system time.  A positive value
     * indicates that the machine's system time is ahead of the time server.  A negative value indicates that
     * the machine's system time is behind of the time server.
     */
    public static Long getTimeDifference(final Date ntpDate, final Date machineDate, final boolean isMachineLocal) {
        Long diff = null;
        if (ntpDate != null && machineDate != null) {
            log.info("NTP Server Time: " + ntpDate);
            final String machineLabel = isMachineLocal ? "Local" : "Remote";
            log.info(machineLabel + " Machine Time: " + machineDate);
            diff = machineDate.getTime() - ntpDate.getTime();

            final boolean isAhead = diff > 0;
            final String durationBreakdown = TimeUtils.getDurationBreakdown(diff, false);
            log.info(machineLabel + " Machine time is " + (isAhead ? "ahead of" : "behind") + " NTP server time by " + durationBreakdown + ".");
        }

        return diff;
    }

    /**
     * Gets the time difference between the NTP server and the local machine system time.
     * @param timeServerHost the time server host name.
     * @return the difference between the NTP server time and the local machine's system time.  A positive value
     * indicates that the local machine's system time is ahead of the time server.  A negative value indicates that
     * the local machine's system time is behind of the time server.
     * @throws IOException
     */
    public static Long getNtpServerAndLocalMachineTimeDifference(final String timeServerHost) throws IOException {
        log.info("Getting NTP Server time from " + timeServerHost + "...");
        final Date ntpDate = getNtpServerDate(timeServerHost);
        Long diff = null;
        if (ntpDate != null) {
            log.info("Getting Local Machine time...");
            final Date machineDate = new Date();

            diff = getTimeDifference(ntpDate, machineDate, true);
        }

        return diff;
    }

    /**
     * Gets the time difference between the NTP server and the remote machine system time.
     * @param timeServerHost the time server host name.
     * @param remoteMachineUrlString the URL string of the remote machine's web server to connect to.
     * @return the difference between the NTP server time and the remote machine's system time.  A positive value
     * indicates that the remote machine's system time is ahead the time server.  A negative value indicates that
     * the remote machine's system time is behind the time server.
     * @throws ParseException
     * @throws IOException
     */
    public static Long getNtpServerAndRemoteMachineTimeDifference(final String timeServerHost, final String remoteMachineUrlString) throws IOException, ParseException {
        log.info("Getting NTP Server time from " + timeServerHost + "...");
        final Date ntpDate = getNtpServerDate(timeServerHost);
        Long diff = null;
        if (ntpDate != null) {
            log.info("Getting Remote Machine time from " + remoteMachineUrlString + "...");
            final Date machineDate = getRemoteMachineDate(remoteMachineUrlString);

            diff = getTimeDifference(ntpDate, machineDate, false);
        }

        return diff;
    }

    /**
     * Gets the time difference between the NTP server and the machine system time (either locally or remotely depending on the URL).
     * @param timeServerHost the time server host name.
     * @param machineUrlString the URL string of the machine's web server to connect to.  The machine might be
     * local or remote.
     * @return the difference between the NTP server time and the machine's system time.  A positive value
     * indicates that the machine's system time is ahead of the time server.  A negative value indicates that
     * the machine's system time is behind the time server.
     * @throws ParseException
     * @throws IOException
     */
    public static Long getNtpServerAndMachineTimeDifference(final String timeServerHost, final String machineUrlString) throws IOException, ParseException {
        final boolean isUrlLocalMachine = isUrlLocalMachine(machineUrlString);

        Long machineTimeOffset;
        if (isUrlLocalMachine) {
            machineTimeOffset = getNtpServerAndLocalMachineTimeDifference(timeServerHost);
        } else {
            machineTimeOffset = getNtpServerAndRemoteMachineTimeDifference(timeServerHost, machineUrlString);
        }

        return machineTimeOffset;
    }

    /**
     * Gets the machine system time (either locally or remotely depending on the URL).
     * @param urlString the URL string of the machine to check.
     * @return the machine's system time.
     * @throws IOException
     * @throws ParseException
     */
    public static Date getMachineDate(final String urlString) throws IOException, ParseException {
        final boolean isMachineLocal = isUrlLocalMachine(urlString);

        Date machineDate;
        if (isMachineLocal) {
            // Get local system machine time
            machineDate = new Date();
        } else {
            // Get remote machine time from HTTP HEAD response.  Check hosted server web page on machine for time.
            machineDate = getRemoteMachineDate(urlString);
        }

        return machineDate;
    }

    /**
     * Checks to see if the URL provided is hosted on the local machine or not.
     * @param urlString the URL string to check.
     * @return {@code true} if the URL is hosted on the local machine.  {@code false}
     * if it's on a remote machine.
     * @throws UnknownHostException
     * @throws MalformedURLException
     */
    public static boolean isUrlLocalMachine(final String urlString) throws UnknownHostException, MalformedURLException {
        final String localAddress = InetAddress.getLocalHost().getHostAddress();
        final String requestAddress = InetAddress.getByName(new URL(urlString).getHost()).getHostAddress();
        return localAddress != null && requestAddress != null && localAddress.equals(requestAddress);
    }

    /**
     * Convert a millisecond duration to a string format.
     * @param durationMs A duration to convert to a string form.
     * @return A string of the form "X Days Y Hours Z Minutes A Seconds B Milliseconds".
     */
    public static String getDurationBreakdown(final long durationMs) {
        return getDurationBreakdown(durationMs, true);
    }

    /**
     * Convert a millisecond duration to a string format.
     * @param durationMs A duration to convert to a string form.
     * @param showSign {@code true} to show if the duration is positive or negative. {@code false}
     * to not display the sign.
     * @return A string of the form "X Days Y Hours Z Minutes A Seconds B Milliseconds".
     */
    public static String getDurationBreakdown(final long durationMs, final boolean showSign) {
        long tempDurationMs = Math.abs(durationMs);

        final long days = TimeUnit.MILLISECONDS.toDays(tempDurationMs);
        tempDurationMs -= TimeUnit.DAYS.toMillis(days);
        final long hours = TimeUnit.MILLISECONDS.toHours(tempDurationMs);
        tempDurationMs -= TimeUnit.HOURS.toMillis(hours);
        final long minutes = TimeUnit.MILLISECONDS.toMinutes(tempDurationMs);
        tempDurationMs -= TimeUnit.MINUTES.toMillis(minutes);
        final long seconds = TimeUnit.MILLISECONDS.toSeconds(tempDurationMs);
        tempDurationMs -= TimeUnit.SECONDS.toMillis(seconds);
        final long milliseconds = TimeUnit.MILLISECONDS.toMillis(tempDurationMs);

        final StringBuilder sb = new StringBuilder();
        if (tempDurationMs != 0 && showSign) {
            sb.append(tempDurationMs > 0 ? "+" : "-");
        }
        if (days > 0) {
            sb.append(days);
            sb.append(days == 1 ? " Day " : " Days ");
        }
        if (hours > 0) {
            sb.append(hours);
            sb.append(hours == 1 ? " Hour " : " Hours ");
        }
        if (minutes > 0) {
            sb.append(minutes);
            sb.append(minutes == 1 ? " Minute " : " Minutes ");
        }
        if (seconds > 0) {
            sb.append(seconds);
            sb.append(seconds == 1 ? " Second " : " Seconds " );
        }
        if (milliseconds > 0 || (!showSign && sb.length() == 0) || (showSign && sb.length() == 1)) {
            // At least show the milliseconds if nothing else has been shown so far
            sb.append(milliseconds);
            sb.append(milliseconds == 1 ? " Millisecond" : " Milliseconds");
        }

        return StringUtils.trim(sb.toString());
    }

    /**
     * Checks if a date is before another date or if they are equal.
     * @param date1 the first {@link Date}.
     * @param date2 the second {@link Date}.
     * @return {@code true} if {@code date1} is before or equal to {@code date2}.  {@code false} otherwise.
     */
    public static boolean dateBeforeInclusive(final Date date1, final Date date2) {
        return !date1.after(date2);
    }

    /**
     * Checks if a date is after another date or if they are equal.
     * @param date1 the first {@link Date}.
     * @param date2 the second {@link Date}.
     * @return {@code true} if {@code date1} is after or equal to {@code date2}.  {@code false} otherwise.
     */
    public static boolean dateAfterInclusive(final Date date1, final Date date2) {
        return !date1.before(date2);
    }
}
