"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.MemorySessionStorage = exports.enhanceStorage = exports.lazySession = exports.session = void 0;
const platform_node_js_1 = require("../platform.node.js");
const debug = (0, platform_node_js_1.debug)("grammy:session");
/**
 * Session middleware provides a persistent data storage for your bot. You can
 * use it to let your bot remember any data you want, for example the messages
 * it sent or received in the past. This is done by attaching _session data_ to
 * every chat. The stored data is then provided on the context object under
 * `ctx.session`.
 *
 * > **What is a session?** Simply put, the session of a chat is a little
 * > persistent storage that is attached to it. As an example, your bot can send
 * > a message to a chat and store the ID of that message in the corresponding
 * > session. The next time your bot receives an update from that chat, the
 * > session will still contain that ID.
 * >
 * > Session data can be stored in a database, in a file, or simply in memory.
 * > grammY only supports memory sessions out of the box, but you can use
 * > third-party session middleware to connect to other storage solutions. Note
 * > that memory sessions will be lost when you stop your bot and the process
 * > exits, so they are usually not useful in production.
 *
 * Whenever your bot receives an update, the first thing the session middleware
 * will do is to load the correct session from your storage solution. This
 * object is then provided on `ctx.session` while your other middleware is
 * running. As soon as your bot is done handling the update, the middleware
 * takes over again and writes back the session object to your storage. This
 * allows you to modify the session object arbitrarily in your middleware, and
 * to stop worrying about the database.
 *
 * ```ts
 * bot.use(session())
 *
 * bot.on('message', ctx => {
 *   // The session object is persisted across updates!
 *   const session = ctx.session
 * })
 * ```
 *
 * It is recommended to make use of the `initial` option in the configuration
 * object, which correctly initializes session objects for new chats.
 *
 * You can delete the session data by setting `ctx.session` to `null` or
 * `undefined`.
 *
 * Check out the [documentation](https://grammy.dev/plugins/session.html) on the
 * website to know more about how sessions work in grammY.
 *
 * @param options Optional configuration to pass to the session middleware
 */
function session(options = {}) {
    return options.type === "multi"
        ? strictMultiSession(options)
        : strictSingleSession(options);
}
exports.session = session;
function strictSingleSession(options) {
    const { initial, storage, getSessionKey, custom } = fillDefaults(options);
    return async (ctx, next) => {
        const propSession = new PropertySession(storage, ctx, "session", initial);
        const key = await getSessionKey(ctx);
        await propSession.init(key, { custom, lazy: false });
        await next(); // no catch: do not write back if middleware throws
        await propSession.finish();
    };
}
function strictMultiSession(options) {
    const props = Object.keys(options).filter((k) => k !== "type");
    const defaults = Object.fromEntries(props.map((prop) => [prop, fillDefaults(options[prop])]));
    return async (ctx, next) => {
        ctx.session = {};
        const propSessions = await Promise.all(props.map(async (prop) => {
            const { initial, storage, getSessionKey, custom } = defaults[prop];
            const s = new PropertySession(
            // @ts-ignore cannot express that the storage works for a concrete prop
            storage, ctx.session, prop, initial);
            const key = await getSessionKey(ctx);
            await s.init(key, { custom, lazy: false });
            return s;
        }));
        await next(); // no catch: do not write back if middleware throws
        if (ctx.session == null)
            propSessions.forEach((s) => s.delete());
        await Promise.all(propSessions.map((s) => s.finish()));
    };
}
/**
 * > This is an advanced function of grammY.
 *
 * Generally speaking, lazy sessions work just like normal sessions—just they
 * are loaded on demand. Except for a few `async`s and `await`s here and there,
 * their usage looks 100 % identical.
 *
 * Instead of directly querying the storage every time an update arrives, lazy
 * sessions quickly do this _once you access_ `ctx.session`. This can
 * significantly reduce the database traffic (especially when your bot is added
 * to group chats), because it skips a read and a wrote operation for all
 * updates that the bot does not react to.
 *
 * ```ts
 * // The options are identical
 * bot.use(lazySession({ storage: ... }))
 *
 * bot.on('message', async ctx => {
 *   // The session object is persisted across updates!
 *   const session = await ctx.session
 *   //                        ^
 *   //                        |
 *   //                       This plain property access (no function call) will trigger the database query!
 * })
 * ```
 *
 * Check out the
 * [documentation](https://grammy.dev/plugins/session.html#lazy-sessions) on the
 * website to know more about how lazy sessions work in grammY.
 *
 * @param options Optional configuration to pass to the session middleware
 */
function lazySession(options = {}) {
    if (options.type !== undefined && options.type !== "single") {
        throw new Error("Cannot use lazy multi sessions!");
    }
    const { initial, storage, getSessionKey, custom } = fillDefaults(options);
    return async (ctx, next) => {
        const propSession = new PropertySession(
        // @ts-ignore suppress promise nature of values
        storage, ctx, "session", initial);
        const key = await getSessionKey(ctx);
        await propSession.init(key, { custom, lazy: true });
        await next(); // no catch: do not write back if middleware throws
        await propSession.finish();
    };
}
exports.lazySession = lazySession;
/**
 * Internal class that manages a single property on the session. Can be used
 * both in a strict and a lazy way. Works by using `Object.defineProperty` to
 * install `O[P]`.
 */
// deno-lint-ignore ban-types
class PropertySession {
    constructor(storage, obj, prop, initial) {
        this.storage = storage;
        this.obj = obj;
        this.prop = prop;
        this.initial = initial;
        this.fetching = false;
        this.read = false;
        this.wrote = false;
    }
    /** Performs a read op and stores the result in `this.value` */
    load() {
        if (this.key === undefined) {
            // No session key provided, cannot load
            return;
        }
        if (this.wrote) {
            // Value was set, no need to load
            return;
        }
        // Perform read op if not cached
        if (this.promise === undefined) {
            this.fetching = true;
            this.promise = Promise.resolve(this.storage.read(this.key))
                .then((val) => {
                var _a;
                this.fetching = false;
                // Check for write op in the meantime
                if (this.wrote) {
                    // Discard read op
                    return this.value;
                }
                // Store received value in `this.value`
                if (val !== undefined) {
                    this.value = val;
                    return val;
                }
                // No value, need to initialize
                val = (_a = this.initial) === null || _a === void 0 ? void 0 : _a.call(this);
                if (val !== undefined) {
                    // Wrote initial value
                    this.wrote = true;
                    this.value = val;
                }
                return val;
            });
        }
        return this.promise;
    }
    async init(key, opts) {
        this.key = key;
        if (!opts.lazy)
            await this.load();
        Object.defineProperty(this.obj, this.prop, {
            enumerable: true,
            get: () => {
                if (key === undefined) {
                    const msg = undef("access", opts);
                    throw new Error(msg);
                }
                this.read = true;
                if (!opts.lazy || this.wrote)
                    return this.value;
                this.load();
                return this.fetching ? this.promise : this.value;
            },
            set: (v) => {
                if (key === undefined) {
                    const msg = undef("assign", opts);
                    throw new Error(msg);
                }
                this.wrote = true;
                this.fetching = false;
                this.value = v;
            },
        });
    }
    delete() {
        Object.assign(this.obj, { [this.prop]: undefined });
    }
    async finish() {
        if (this.key !== undefined) {
            if (this.read)
                await this.load();
            if (this.read || this.wrote) {
                const value = await this.value;
                if (value == null)
                    await this.storage.delete(this.key);
                else
                    await this.storage.write(this.key, value);
            }
        }
    }
}
function fillDefaults(opts = {}) {
    let { getSessionKey = defaultGetSessionKey, initial, storage } = opts;
    if (storage == null) {
        debug("Storing session data in memory, all data will be lost when the bot restarts.");
        storage = new MemorySessionStorage();
    }
    const custom = getSessionKey !== defaultGetSessionKey;
    return { initial, storage, getSessionKey, custom };
}
/** Stores session data per chat by default */
function defaultGetSessionKey(ctx) {
    var _a;
    return (_a = ctx.chat) === null || _a === void 0 ? void 0 : _a.id.toString();
}
/** Returns a useful error message for when the session key is undefined */
function undef(op, opts) {
    const { lazy = false, custom } = opts;
    const reason = custom
        ? "the custom `getSessionKey` function returned undefined for this update"
        : "this update does not belong to a chat, so the session key is undefined";
    return `Cannot ${op} ${lazy ? "lazy " : ""}session data because ${reason}!`;
}
function isEnhance(value) {
    return value === undefined ||
        typeof value === "object" && value !== null && "__d" in value;
}
/**
 * You can use this function to transform an existing storage adapter, and add
 * more features to it. Currently, you can add session migrations and expiry
 * dates.
 *
 * You can use this function like so:
 * ```ts
 * const storage = ... // define your storage adapter
 * const enhanced = enhanceStorage({ storage, millisecondsToLive: 500 })
 * bot.use(session({ storage: enhanced }))
 * ```
 *
 * @param options Session enhancing options
 * @returns The enhanced storage adapter
 */
function enhanceStorage(options) {
    let { storage, millisecondsToLive, migrations } = options;
    storage = compatStorage(storage);
    if (millisecondsToLive !== undefined) {
        storage = timeoutStorage(storage, millisecondsToLive);
    }
    if (migrations !== undefined) {
        storage = migrationStorage(storage, migrations);
    }
    return wrapStorage(storage);
}
exports.enhanceStorage = enhanceStorage;
function compatStorage(storage) {
    return {
        read: async (k) => {
            const v = await storage.read(k);
            return isEnhance(v) ? v : { __d: v };
        },
        write: (k, v) => storage.write(k, v),
        delete: (k) => storage.delete(k),
    };
}
function timeoutStorage(storage, millisecondsToLive) {
    const ttlStorage = {
        read: async (k) => {
            const value = await storage.read(k);
            if (value === undefined)
                return undefined;
            if (value.e === undefined) {
                await ttlStorage.write(k, value);
                return value;
            }
            if (value.e < Date.now()) {
                await ttlStorage.delete(k);
                return undefined;
            }
            return value;
        },
        write: async (k, v) => {
            v.e = addExpiryDate(v, millisecondsToLive).expires;
            await storage.write(k, v);
        },
        delete: (k) => storage.delete(k),
    };
    return ttlStorage;
}
function migrationStorage(storage, migrations) {
    const versions = Object.keys(migrations)
        .map((v) => parseInt(v))
        .sort((a, b) => a - b);
    const count = versions.length;
    if (count === 0)
        throw new Error("No migrations given!");
    const earliest = versions[0];
    const last = count - 1;
    const latest = versions[last];
    const index = new Map();
    versions.forEach((v, i) => index.set(v, i)); // inverse array lookup
    function nextAfter(current) {
        // TODO: use `findLastIndex` with Node 18
        let i = last;
        while (current <= versions[i])
            i--;
        return i;
        // return versions.findLastIndex((v) => v < current)
    }
    return {
        read: async (k) => {
            var _a;
            const val = await storage.read(k);
            if (val === undefined)
                return val;
            let { __d: value, v: current = earliest - 1 } = val;
            let i = 1 + ((_a = index.get(current)) !== null && _a !== void 0 ? _a : nextAfter(current));
            for (; i < count; i++)
                value = migrations[versions[i]](value);
            return { ...val, v: latest, __d: value };
        },
        write: (k, v) => storage.write(k, { v: latest, ...v }),
        delete: (k) => storage.delete(k),
    };
}
function wrapStorage(storage) {
    return {
        read: (k) => Promise.resolve(storage.read(k)).then((v) => v === null || v === void 0 ? void 0 : v.__d),
        write: (k, v) => storage.write(k, { __d: v }),
        delete: (k) => storage.delete(k),
    };
}
// === Memory storage adapter
/**
 * The memory session storage is a built-in storage adapter that saves your
 * session data in RAM using a regular JavaScript `Map` object. If you use this
 * storage adapter, all sessions will be lost when your process terminates or
 * restarts. Hence, you should only use it for short-lived data that is not
 * important to persist.
 *
 * This class is used as default if you do not provide a storage adapter, e.g.
 * to your database.
 *
 * This storage adapter features expiring sessions. When instantiating this class
 * yourself, you can pass a time to live in milliseconds that will be used for
 * each session object. If a session for a user expired, the session data will
 * be discarded on its first read, and a fresh session object as returned by the
 * `initial` option (or undefined) will be put into place.
 */
class MemorySessionStorage {
    /**
     * Constructs a new memory session storage with the given time to live. Note
     * that this storage adapter will not store your data permanently.
     *
     * @param timeToLive TTL in milliseconds, default is `Infinity`
     */
    constructor(timeToLive) {
        this.timeToLive = timeToLive;
        /**
         * Internally used `Map` instance that stores the session data
         */
        this.storage = new Map();
    }
    read(key) {
        const value = this.storage.get(key);
        if (value === undefined)
            return undefined;
        if (value.expires !== undefined && value.expires < Date.now()) {
            this.delete(key);
            return undefined;
        }
        return value.session;
    }
    /**
     * @deprecated Use {@link readAllValues} instead
     */
    readAll() {
        return this.readAllValues();
    }
    readAllKeys() {
        return Array.from(this.storage.keys());
    }
    readAllValues() {
        return Array
            .from(this.storage.keys())
            .map((key) => this.read(key))
            .filter((value) => value !== undefined);
    }
    readAllEntries() {
        return Array.from(this.storage.keys())
            .map((key) => [key, this.read(key)])
            .filter((pair) => pair[1] !== undefined);
    }
    has(key) {
        return this.storage.has(key);
    }
    write(key, value) {
        this.storage.set(key, addExpiryDate(value, this.timeToLive));
    }
    delete(key) {
        this.storage.delete(key);
    }
}
exports.MemorySessionStorage = MemorySessionStorage;
function addExpiryDate(value, ttl) {
    if (ttl !== undefined && ttl < Infinity) {
        const now = Date.now();
        return { session: value, expires: now + ttl };
    }
    else {
        return { session: value };
    }
}
