'use strict';

// Load modules

const Dgram = require('dgram');
const Dns = require('dns');

const Hoek = require('hoek');


// Declare internals

const internals = {};


exports.time = function (options, callback) {

    if (arguments.length !== 2) {
        callback = arguments[0];
        options = {};
    }

    const settings = Hoek.clone(options);
    settings.host = settings.host || 'time.google.com';
    settings.port = settings.port || 123;
    settings.resolveReference = settings.resolveReference || false;

    // Declare variables used by callback

    let timeoutId = null;
    let sent = 0;

    // Ensure callback is only called once

    const finish = Hoek.once((err, result) => {

        clearTimeout(timeoutId);

        socket.removeAllListeners();
        socket.once('error', Hoek.ignore);

        try {
            socket.close();
        }
        catch (ignoreErr) { }       // Ignore errors if the socket is already closed

        return callback(err, result);
    });

    // Set timeout

    if (settings.timeout) {
        timeoutId = setTimeout(() => {

            return finish(new Error('Timeout'));
        }, settings.timeout);
    }

    // Create UDP socket

    const socket = Dgram.createSocket('udp4');

    socket.once('error', (err) => finish(err));

    // Listen to incoming messages

    socket.on('message', (buffer, rinfo) => {

        const received = Date.now();

        const message = new internals.NtpMessage(buffer);
        if (!message.isValid) {
            return finish(new Error('Invalid server response'), message);
        }

        if (message.originateTimestamp !== sent) {
            return finish(new Error('Wrong originate timestamp'), message);
        }

        // Timestamp Name          ID   When Generated
        // ------------------------------------------------------------
        // Originate Timestamp     T1   time request sent by client
        // Receive Timestamp       T2   time request received by server
        // Transmit Timestamp      T3   time reply sent by server
        // Destination Timestamp   T4   time reply received by client
        //
        // The roundtrip delay d and system clock offset t are defined as:
        //
        // d = (T4 - T1) - (T3 - T2)     t = ((T2 - T1) + (T3 - T4)) / 2

        const T1 = message.originateTimestamp;
        const T2 = message.receiveTimestamp;
        const T3 = message.transmitTimestamp;
        const T4 = received;

        message.d = (T4 - T1) - (T3 - T2);
        message.t = ((T2 - T1) + (T3 - T4)) / 2;
        message.receivedLocally = received;

        if (!settings.resolveReference ||
            message.stratum !== 'secondary') {

            return finish(null, message);
        }

        // Resolve reference IP address

        Dns.reverse(message.referenceId, (err, domains) => {

            if (/* $lab:coverage:off$ */ !err /* $lab:coverage:on$ */) {
                message.referenceHost = domains[0];
            }

            return finish(null, message);
        });
    });

    // Construct NTP message

    const message = new Buffer(48);
    for (let i = 0; i < 48; ++i) {                      // Zero message
        message[i] = 0;
    }

    message[0] = (0 << 6) + (4 << 3) + (3 << 0);        // Set version number to 4 and Mode to 3 (client)
    sent = Date.now();
    internals.fromMsecs(sent, message, 40);             // Set transmit timestamp (returns as originate)

    // Send NTP request

    socket.send(message, 0, message.length, settings.port, settings.host, (err, bytes) => {

        if (err ||
            bytes !== 48) {

            return finish(err || new Error('Could not send entire message'));
        }
    });
};


internals.NtpMessage = function (buffer) {

    this.isValid = false;

    // Validate

    if (buffer.length !== 48) {
        return;
    }

    // Leap indicator

    const li = (buffer[0] >> 6);
    switch (li) {
        case 0: this.leapIndicator = 'no-warning'; break;
        case 1: this.leapIndicator = 'last-minute-61'; break;
        case 2: this.leapIndicator = 'last-minute-59'; break;
        case 3: this.leapIndicator = 'alarm'; break;
    }

    // Version

    const vn = ((buffer[0] & 0x38) >> 3);
    this.version = vn;

    // Mode

    const mode = (buffer[0] & 0x7);
    switch (mode) {
        case 1: this.mode = 'symmetric-active'; break;
        case 2: this.mode = 'symmetric-passive'; break;
        case 3: this.mode = 'client'; break;
        case 4: this.mode = 'server'; break;
        case 5: this.mode = 'broadcast'; break;
        case 0:
        case 6:
        case 7: this.mode = 'reserved'; break;
    }

    // Stratum

    const stratum = buffer[1];
    if (stratum === 0) {
        this.stratum = 'death';
    }
    else if (stratum === 1) {
        this.stratum = 'primary';
    }
    else if (stratum <= 15) {
        this.stratum = 'secondary';
    }
    else {
        this.stratum = 'reserved';
    }

    // Poll interval (msec)

    this.pollInterval = Math.round(Math.pow(2, buffer[2])) * 1000;

    // Precision (msecs)

    this.precision = Math.pow(2, buffer[3]) * 1000;

    // Root delay (msecs)

    const rootDelay = 256 * (256 * (256 * buffer[4] + buffer[5]) + buffer[6]) + buffer[7];
    this.rootDelay = 1000 * (rootDelay / 0x10000);

    // Root dispersion (msecs)

    this.rootDispersion = ((buffer[8] << 8) + buffer[9] + ((buffer[10] << 8) + buffer[11]) / Math.pow(2, 16)) * 1000;

    // Reference identifier

    this.referenceId = '';
    switch (this.stratum) {
        case 'death':
        case 'primary':
            this.referenceId = String.fromCharCode(buffer[12]) + String.fromCharCode(buffer[13]) + String.fromCharCode(buffer[14]) + String.fromCharCode(buffer[15]);
            break;
        case 'secondary':
            this.referenceId = '' + buffer[12] + '.' + buffer[13] + '.' + buffer[14] + '.' + buffer[15];
            break;
    }

    // Reference timestamp

    this.referenceTimestamp = internals.toMsecs(buffer, 16);

    // Originate timestamp

    this.originateTimestamp = internals.toMsecs(buffer, 24);

    // Receive timestamp

    this.receiveTimestamp = internals.toMsecs(buffer, 32);

    // Transmit timestamp

    this.transmitTimestamp = internals.toMsecs(buffer, 40);

    // Validate

    if (this.version === 4 &&
        this.stratum !== 'reserved' &&
        this.mode === 'server' &&
        this.originateTimestamp &&
        this.receiveTimestamp &&
        this.transmitTimestamp) {

        this.isValid = true;
    }

    return this;
};


internals.toMsecs = function (buffer, offset) {

    let seconds = 0;
    let fraction = 0;

    for (let i = 0; i < 4; ++i) {
        seconds = (seconds * 256) + buffer[offset + i];
    }

    for (let i = 4; i < 8; ++i) {
        fraction = (fraction * 256) + buffer[offset + i];
    }

    return ((seconds - 2208988800 + (fraction / Math.pow(2, 32))) * 1000);
};


internals.fromMsecs = function (ts, buffer, offset) {

    const seconds = Math.floor(ts / 1000) + 2208988800;
    const fraction = Math.round((ts % 1000) / 1000 * Math.pow(2, 32));

    buffer[offset + 0] = (seconds & 0xFF000000) >> 24;
    buffer[offset + 1] = (seconds & 0x00FF0000) >> 16;
    buffer[offset + 2] = (seconds & 0x0000FF00) >> 8;
    buffer[offset + 3] = (seconds & 0x000000FF);

    buffer[offset + 4] = (fraction & 0xFF000000) >> 24;
    buffer[offset + 5] = (fraction & 0x00FF0000) >> 16;
    buffer[offset + 6] = (fraction & 0x0000FF00) >> 8;
    buffer[offset + 7] = (fraction & 0x000000FF);
};


// Offset singleton

internals.last = {
    offset: 0,
    expires: 0,
    host: '',
    port: 0
};


exports.offset = function (options, callback) {

    if (arguments.length !== 2) {
        callback = arguments[0];
        options = {};
    }

    const now = Date.now();
    const clockSyncRefresh = options.clockSyncRefresh || 24 * 60 * 60 * 1000;                    // Daily

    if (internals.last.offset &&
        internals.last.host === options.host &&
        internals.last.port === options.port &&
        now < internals.last.expires) {

        process.nextTick(() => callback(null, internals.last.offset));
        return;
    }

    exports.time(options, (err, time) => {

        if (err) {
            return callback(err, 0);
        }

        internals.last = {
            offset: Math.round(time.t),
            expires: now + clockSyncRefresh,
            host: options.host,
            port: options.port
        };

        return callback(null, internals.last.offset);
    });
};


// Now singleton

internals.now = {
    started: false,
    intervalId: null
};


exports.start = function (options, callback) {

    if (arguments.length !== 2) {
        callback = arguments[0];
        options = {};
    }

    if (internals.now.started) {
        process.nextTick(() => callback());
        return;
    }

    const report = (err) => {

        if (err &&
            options.onError) {

            options.onError(err);
        }
    };

    internals.now.started = true;
    exports.offset(options, (err, offset) => {

        report(err);

        internals.now.intervalId = setInterval(() => {

            exports.offset(options, report);
        }, options.clockSyncRefresh || 24 * 60 * 60 * 1000);                                // Daily

        return callback();
    });
};


exports.stop = function () {

    if (!internals.now.started) {
        return;
    }

    clearInterval(internals.now.intervalId);
    internals.now.started = false;
    internals.now.intervalId = null;
};


exports.isLive = function () {

    return internals.now.started;
};


exports.now = function () {

    const now = Date.now();
    if (!exports.isLive() ||
        now >= internals.last.expires) {

        return now;
    }

    return now + internals.last.offset;
};
