// @flow

/**
 * A `Namespace` refers to a space of nameable things like macros or lengths,
 * which can be `set` either globally or local to a nested group, using an
 * undo stack similar to how TeX implements this functionality.
 * Performance-wise, `get` and local `set` take constant time, while global
 * `set` takes time proportional to the depth of group nesting.
 */

import ParseError from "./ParseError";

export type Mapping<Value> = {[string]: Value};

export default class Namespace<Value> {
    current: Mapping<Value>;
    builtins: Mapping<Value>;
    undefStack: Mapping<?Value>[];

    /**
     * Both arguments are optional.  The first argument is an object of
     * built-in mappings which never change.  The second argument is an object
     * of initial (global-level) mappings, which will constantly change
     * according to any global/top-level `set`s done.
     */
    constructor(builtins: Mapping<Value> = {},
                globalMacros: Mapping<Value> = {}) {
        this.current = globalMacros;
        this.builtins = builtins;
        this.undefStack = [];
    }

    /**
     * Start a new nested group, affecting future local `set`s.
     */
    beginGroup() {
        this.undefStack.push({});
    }

    /**
     * End current nested group, restoring values before the group began.
     */
    endGroup() {
        if (this.undefStack.length === 0) {
            throw new ParseError("Unbalanced namespace destruction: attempt " +
                "to pop global namespace; please report this as a bug");
        }
        const undefs = this.undefStack.pop();
        for (const undef in undefs) {
            if (undefs.hasOwnProperty(undef)) {
                if (undefs[undef] == null) {
                    delete this.current[undef];
                } else {
                    this.current[undef] = undefs[undef];
                }
            }
        }
    }

    /**
     * Ends all currently nested groups (if any), restoring values before the
     * groups began.  Useful in case of an error in the middle of parsing.
     */
    endGroups() {
        while (this.undefStack.length > 0) {
            this.endGroup();
        }
    }

    /**
     * Detect whether `name` has a definition.  Equivalent to
     * `get(name) != null`.
     */
    has(name: string): boolean {
        return this.current.hasOwnProperty(name) ||
               this.builtins.hasOwnProperty(name);
    }

    /**
     * Get the current value of a name, or `undefined` if there is no value.
     *
     * Note: Do not use `if (namespace.get(...))` to detect whether a macro
     * is defined, as the definition may be the empty string which evaluates
     * to `false` in JavaScript.  Use `if (namespace.get(...) != null)` or
     * `if (namespace.has(...))`.
     */
    get(name: string): ?Value {
        if (this.current.hasOwnProperty(name)) {
            return this.current[name];
        } else {
            return this.builtins[name];
        }
    }

    /**
     * Set the current value of a name, and optionally set it globally too.
     * Local set() sets the current value and (when appropriate) adds an undo
     * operation to the undo stack.  Global set() may change the undo
     * operation at every level, so takes time linear in their number.
     * A value of undefined means to delete existing definitions.
     */
    set(name: string, value: ?Value, global: boolean = false) {
        if (global) {
            // Global set is equivalent to setting in all groups.  Simulate this
            // by destroying any undos currently scheduled for this name,
            // and adding an undo with the *new* value (in case it later gets
            // locally reset within this environment).
            for (let i = 0; i < this.undefStack.length; i++) {
                delete this.undefStack[i][name];
            }
            if (this.undefStack.length > 0) {
                this.undefStack[this.undefStack.length - 1][name] = value;
            }
        } else {
            // Undo this set at end of this group (possibly to `undefined`),
            // unless an undo is already in place, in which case that older
            // value is the correct one.
            const top = this.undefStack[this.undefStack.length - 1];
            if (top && !top.hasOwnProperty(name)) {
                top[name] = this.current[name];
            }
        }
        if (value == null) {
            delete this.current[name];
        } else {
            this.current[name] = value;
        }
    }
}
