'use strict';

const Boom = require('@hapi/boom');
const Hoek = require('@hapi/hoek');
const Podium = require('@hapi/podium');
const Validate = require('@hapi/validate');

const Pending = require('./pending');


const internals = {
    day: 24 * 60 * 60 * 1000,
    events: Podium.validate([
        { name: 'error', channels: ['generate', 'persist'] }
    ])
};


internals.schema = Validate.object({
    expiresIn: Validate.number().integer().min(1),
    expiresAt: Validate.string().regex(/^\d\d?\:\d\d$/),
    staleIn: [
        Validate.number().integer().min(1).when('expiresAt', { is: Validate.required(), then: Validate.number().max(86400000 - 1) }),       // One day - 1 (max is inclusive)
        Validate.func()
    ],
    staleTimeout: Validate.number().integer().min(1),
    generateFunc: Validate.func(),
    generateTimeout: Validate.number().integer().min(1).allow(false),
    generateOnReadError: Validate.boolean(),
    generateIgnoreWriteError: Validate.boolean(),
    dropOnError: Validate.boolean(),
    pendingGenerateTimeout: Validate.number().integer().min(1),
    getDecoratedValue: Validate.boolean().default(false),

    // Ignored external keys (hapi)

    privacy: Validate.any(),
    cache: Validate.any(),
    segment: Validate.any(),
    shared: Validate.any()
})
    .without('expiresIn', 'expiresAt')
    .with('staleIn', 'generateFunc')
    .with('generateOnReadError', 'generateFunc')
    .with('generateIgnoreWriteError', 'generateFunc')
    .with('dropOnError', 'generateFunc')
    .and('generateFunc', 'generateTimeout')
    .and('staleIn', 'staleTimeout');


exports = module.exports = internals.Policy = class {

    rule = null;
    stats = {
        sets: 0,
        gets: 0,
        hits: 0,
        stales: 0,
        generates: 0,
        errors: 0
    };

    _events = null;
    _cache = null;
    _segment = null;
    _pendings = new Map();                       // id -> Pending
    _pendingGenerateCall = new Map();            // id -> timer

    constructor(options, cache, segment) {

        this._cache = cache;
        this.rules(options);

        if (cache) {
            const nameErr = cache.validateSegmentName(segment);
            Hoek.assert(nameErr === null, 'Invalid segment name: ' + segment + (nameErr ? ' (' + nameErr.message + ')' : ''));

            this._segment = segment;
        }
    }

    get client() {

        return this._cache;
    }

    get events() {

        if (!this._events) {
            this._events = new Podium.Podium(internals.events, { validate: false });
        }

        return this._events;
    }

    _error(source, error) {

        if (!this._events) {
            return;
        }

        this._events.emit({ name: 'error', channel: source }, { source, error });
    }

    rules(options) {

        this.rule = internals.Policy.compile(options, !!this._cache);
    }

    async get(key) {     // key: string or { id: 'id' }

        ++this.stats.gets;

        // Check if request is already pending

        if (!key ||
            typeof key === 'string') {

            key = { id: key, string: true };
        }

        let pending = this._pendings.get(key.id);
        if (pending !== undefined) {
            return pending.join();
        }

        pending = new Pending(key.id, this.rule);
        this._pendings.set(key.id, pending);

        try {
            await this._get(pending, key);
        }
        catch (err) {
            this._send(key, err);                    // Safeguard to ensure that the pending rejects on any processing errors
        }

        return pending.promise;
    }

    async _get(pending, key) {

        // Prepare report

        const report = {};

        // Lookup in cache

        const timer = new Hoek.Bench();

        if (this._cache) {
            try {
                var cached = await this._cache.get({ segment: this._segment, id: key.id });
            }
            catch (err) {
                report.error = err;
                ++this.stats.errors;
                this._error('persist', err);
            }
        }

        report.msec = timer.elapsed();

        if (cached) {
            report.stored = cached.stored;
            report.ttl = cached.ttl;
            const staleIn = typeof this.rule.staleIn === 'function' ? this.rule.staleIn(cached.stored, cached.ttl) : this.rule.staleIn;
            cached.isStale = staleIn ? Date.now() - cached.stored >= staleIn : false;
            report.isStale = cached.isStale;

            if (cached.isStale) {
                ++this.stats.stales;
            }
        }

        // No generate method

        if (!this.rule.generateFunc ||
            report.error && !this.rule.generateOnReadError) {

            this._send(key, report.error, cached ? cached.item : null, cached, report);
            return;
        }

        // Check if found and fresh

        if (cached &&
            !cached.isStale) {

            this._send(key, null, cached.item, cached, report);
            return;
        }

        // Wait until generated or otherwise resolved

        return Promise.race([
            pending.promise,
            this._generate(pending, key, cached, report)
        ]);
    }

    _generate(pending, key, cached, report) {

        if (cached) {                                               // Must be stale

            // Set stale timeout

            cached.ttl = cached.ttl - this.rule.staleTimeout;       // Adjust TTL for when the timeout is invoked (staleTimeout must be valid if isStale is true)
        }

        if (cached &&
            cached.ttl > 0) {

            pending.setTimeout(() => this._send(key, null, cached.item, cached, report), this.rule.staleTimeout);
        }
        else if (this.rule.generateTimeout) {

            // Set item generation timeout (when not in cache)

            pending.setTimeout(() => this._send(key, Boom.serverUnavailable(), null, null, report), this.rule.generateTimeout);
        }

        // Check if a generate call is already in progress

        if (this._pendingGenerateCall.has(key.id)) {
            return;
        }

        // Generate new value

        ++this.stats.generates;                                     // Record generation before call in case it times out

        if (this.rule.pendingGenerateTimeout) {
            const timeout = setTimeout(() => this._pendingGenerateCall.delete(key.id), this.rule.pendingGenerateTimeout);
            this._pendingGenerateCall.set(key.id, timeout);
        }

        return this._callGenerateFunc(key, cached, report);
    }

    async _callGenerateFunc(key, cached, report) {

        const flags = {};

        try {
            var value = await this.rule.generateFunc(key.string ? key.id : key, flags);
        }
        catch (err) {
            var generateError = err;
            this._error('generate', err);
        }

        const pendingTimeout = this._pendingGenerateCall.get(key.id);
        if (pendingTimeout) {
            clearTimeout(pendingTimeout);
            this._pendingGenerateCall.delete(key.id);
        }

        // Error (if dropOnError is not set to false) or not cached

        try {
            if (flags.ttl === 0 ||                                                  // null or undefined means use policy
                generateError && this.rule.dropOnError) {

                await this.drop(key.id);                                            // Invalidate cache
            }
            else if (!generateError) {
                await this.set(key.id, value, flags.ttl);                           // Replace stale cache copy with late-coming fresh copy
            }
        }
        catch (err) {
            var persistError = err;
            this._error('persist', err);
        }

        const error = generateError || (this.rule.generateIgnoreWriteError ? null : persistError);
        if (cached &&
            error &&
            !this.rule.dropOnError) {

            this._send(key, error, cached.item, cached, report);
            return;
        }

        this._send(key, error, value, null, report);                             // Ignored if stale value already returned
    }

    _send(key, err, value, cached, report) {

        const pending = this._pendings.get(key.id);
        if (!pending) {
            return;
        }

        this._pendings.delete(key.id);
        pending.send(err, value, cached, report);

        if (report?.isStale !== undefined) {
            this.stats.hits = this.stats.hits + pending.count;
        }
    }

    async set(key, value, ttl) {

        ++this.stats.sets;

        if (!this._cache) {
            return;
        }

        try {
            await this._cache.set({ segment: this._segment, id: internals.id(key) }, value, ttl || internals.Policy.ttl(this.rule));
        }
        catch (err) {
            ++this.stats.errors;
            throw err;
        }
    }

    async drop(key) {

        if (!this._cache) {
            return;
        }

        try {
            await this._cache.drop({ segment: this._segment, id: internals.id(key) });
            return;
        }
        catch (err) {
            ++this.stats.errors;
            throw err;
        }
    }

    ttl(created) {

        return internals.Policy.ttl(this.rule, created);
    }

    isReady() {

        if (!this._cache) {
            return false;
        }

        return this._cache.connection.isReady();
    }

    static compile(options, serverSide) {

        /*
            {
                expiresIn: 30000,
                expiresAt: '13:00',
                generateFunc: (id, flags) => { throw err; } / { return result; } / { flags.ttl = ttl; return result; }
                generateTimeout: 500,
                generateOnReadError: true,
                generateIgnoreWriteError: true,
                staleIn: 20000,
                staleTimeout: 500,
                dropOnError: true,
                getDecoratedValue: false
            }
         */

        const rule = {};

        if (!options ||
            !Object.keys(options).length) {

            return rule;
        }

        // Validate rule

        options = Validate.attempt(options, internals.schema, 'Invalid cache policy configuration');

        const hasExpiresIn = options.expiresIn !== undefined && options.expiresIn !== null;
        const hasExpiresAt = options.expiresAt !== undefined && options.expiresAt !== null;

        Hoek.assert(!hasExpiresIn || !options.staleIn || typeof options.staleIn === 'function' || options.staleIn < options.expiresIn, 'staleIn must be less than expiresIn');
        Hoek.assert(!options.staleIn || serverSide, 'Cannot use stale options without server-side caching');
        Hoek.assert(!options.staleTimeout || !hasExpiresIn || options.staleTimeout < options.expiresIn, 'staleTimeout must be less than expiresIn');
        Hoek.assert(!options.staleTimeout || !hasExpiresIn || typeof options.staleIn === 'function' || options.staleTimeout < options.expiresIn - options.staleIn, 'staleTimeout must be less than the delta between expiresIn and staleIn');
        Hoek.assert(!options.staleTimeout || !options.pendingGenerateTimeout || options.staleTimeout < options.pendingGenerateTimeout, 'pendingGenerateTimeout must be greater than staleTimeout if specified');

        // Expiration

        if (hasExpiresAt) {

            // expiresAt

            const time = /^(\d\d?):(\d\d)$/.exec(options.expiresAt);
            rule.expiresAt = {
                hours: parseInt(time[1], 10),
                minutes: parseInt(time[2], 10)
            };
        }
        else {

            // expiresIn

            rule.expiresIn = options.expiresIn ?? 0;
        }

        // generateTimeout

        if (options.generateFunc) {
            rule.generateFunc = options.generateFunc;
            rule.generateTimeout = options.generateTimeout;

            // Stale

            if (options.staleIn) {
                rule.staleIn = options.staleIn;
                rule.staleTimeout = options.staleTimeout;
            }

            rule.dropOnError = options.dropOnError !== undefined ? options.dropOnError : true;                                          // Defaults to true
            rule.pendingGenerateTimeout = options.pendingGenerateTimeout !== undefined ? options.pendingGenerateTimeout : 0;            // Defaults to zero
        }

        rule.generateOnReadError = options.generateOnReadError !== undefined ? options.generateOnReadError : true;                      // Defaults to true
        rule.generateIgnoreWriteError = options.generateIgnoreWriteError !== undefined ? options.generateIgnoreWriteError : true;       // Defaults to true

        // Decorations

        rule.getDecoratedValue = options.getDecoratedValue;

        return rule;
    }

    static ttl(rule, created, now) {

        now = now ?? Date.now();
        created = created ?? now;
        const age = now - created;

        if (age < 0) {
            return 0;                                                                   // Created in the future, assume expired/bad
        }

        if (rule.expiresIn) {
            return Math.max(rule.expiresIn - age, 0);
        }

        if (rule.expiresAt) {
            if (age > internals.day) {                                                  // If the item was created more than a 24 hours ago
                return 0;
            }

            const expiresAt = new Date(created);                                        // Compare expiration time on the same day
            expiresAt.setHours(rule.expiresAt.hours);
            expiresAt.setMinutes(rule.expiresAt.minutes);
            expiresAt.setSeconds(0);
            expiresAt.setMilliseconds(0);
            let expires = expiresAt.getTime();

            if (expires <= created) {
                expires = expires + internals.day;                                     // Move to tomorrow
            }

            if (now >= expires) {                                                      // Expired
                return 0;
            }

            return expires - now;
        }

        return 0;                                                                       // No rule
    }
};


internals.id = function (key) {

    return key && typeof key === 'object' ? key.id : key;
};
