const adapter = require('./adapter');
const mutationAdapter = require('./mutation-adapter');
const xmlEscape = require('../util/xml-escape');
const MonitorRecord = require('./monitor-record');
const Clone = require('../util/clone');
const {Map} = require('immutable');
const BlocksExecuteCache = require('./blocks-execute-cache');
const BlocksRuntimeCache = require('./blocks-runtime-cache');
const log = require('../util/log');
const Variable = require('./variable');
const getMonitorIdForBlockWithArgs = require('../util/get-monitor-id');

/**
 * @fileoverview
 * Store and mutate the VM block representation,
 * and handle updates from Scratch Blocks events.
 */

/**
 * Create a block container.
 * @param {Runtime} runtime The runtime this block container operates within
 * @param {boolean} optNoGlow Optional flag to indicate that blocks in this container
 * should not request glows. This does not affect glows when clicking on a block to execute it.
 */
class Blocks {
    constructor (runtime, optNoGlow) {
        this.runtime = runtime;

        /**
         * All blocks in the workspace.
         * Keys are block IDs, values are metadata about the block.
         * @type {Object.<string, Object>}
         */
        this._blocks = {};

        /**
         * All top-level scripts in the workspace.
         * A list of block IDs that represent scripts (i.e., first block in script).
         * @type {Array.<String>}
         */
        this._scripts = [];

        /**
         * Runtime Cache
         * @type {{inputs: {}, procedureParamNames: {}, procedureDefinitions: {}}}
         * @private
         */
        Object.defineProperty(this, '_cache', {
            writable: true,
            enumerable: false
        });
        this._cache = {
            /**
             * Cache block inputs by block id
             * @type {object.<string, !Array.<object>>}
             */
            inputs: {},
            /**
             * Cache procedure Param Names by block id
             * @type {object.<string, ?Array.<string>>}
             */
            procedureParamNames: {},
            /**
             * Cache procedure definitions by block id
             * @type {object.<string, ?string>}
             */
            procedureDefinitions: {},

            /**
             * A cache for execute to use and store on. Only available to
             * execute.
             * @type {object.<string, object>}
             */
            _executeCached: {},

            /**
             * A cache of block IDs and targets to start threads on as they are
             * actively monitored.
             * @type {Array<{blockId: string, target: Target}>}
             */
            _monitored: null,

            /**
             * A cache of hat opcodes to collection of theads to execute.
             * @type {object.<string, object>}
             */
            scripts: {}
        };

        /**
         * Flag which indicates that blocks in this container should not glow.
         * Blocks will still glow when clicked on, but this flag is used to control
         * whether the blocks in this container can request a glow as part of
         * a running stack. E.g. the flyout block container and the monitor block container
         * should not be able to request a glow, but blocks containers belonging to
         * sprites should.
         * @type {boolean}
         */
        this.forceNoGlow = optNoGlow || false;
    }

    /**
     * Blockly inputs that represent statements/branch.
     * are prefixed with this string.
     * @const{string}
     */
    static get BRANCH_INPUT_PREFIX () {
        return 'SUBSTACK';
    }

    /**
     * Provide an object with metadata for the requested block ID.
     * @param {!string} blockId ID of block we have stored.
     * @return {?object} Metadata about the block, if it exists.
     */
    getBlock (blockId) {
        return this._blocks[blockId];
    }

    /**
     * Get all known top-level blocks that start scripts.
     * @return {Array.<string>} List of block IDs.
     */
    getScripts () {
        return this._scripts;
    }

    /**
     * Get the next block for a particular block
     * @param {?string} id ID of block to get the next block for
     * @return {?string} ID of next block in the sequence
     */
    getNextBlock (id) {
        const block = this._blocks[id];
        return typeof block === 'undefined' ? null : block.next;
    }

    /**
     * Get the branch for a particular C-shaped block.
     * @param {?string} id ID for block to get the branch for.
     * @param {?number} branchNum Which branch to select (e.g. for if-else).
     * @return {?string} ID of block in the branch.
     */
    getBranch (id, branchNum) {
        const block = this._blocks[id];
        if (typeof block === 'undefined') return null;
        if (!branchNum) branchNum = 1;

        let inputName = Blocks.BRANCH_INPUT_PREFIX;
        if (branchNum > 1) {
            inputName += branchNum;
        }

        // Empty C-block?
        const input = block.inputs[inputName];
        return typeof input === 'undefined' ? null : input.block;
    }

    /**
     * Get the opcode for a particular block
     * @param {?object} block The block to query
     * @return {?string} the opcode corresponding to that block
     */
    getOpcode (block) {
        return typeof block === 'undefined' ? null : block.opcode;
    }

    /**
     * Get all fields and their values for a block.
     * @param {?object} block The block to query.
     * @return {?object} All fields and their values.
     */
    getFields (block) {
        return typeof block === 'undefined' ? null : block.fields;
    }

    /**
     * Get all non-branch inputs for a block.
     * @param {?object} block the block to query.
     * @return {?Array.<object>} All non-branch inputs and their associated blocks.
     */
    getInputs (block) {
        if (typeof block === 'undefined') return null;
        let inputs = this._cache.inputs[block.id];
        if (typeof inputs !== 'undefined') {
            return inputs;
        }

        inputs = {};
        for (const input in block.inputs) {
            // Ignore blocks prefixed with branch prefix.
            if (
                input.substring(0, Blocks.BRANCH_INPUT_PREFIX.length) !==
                Blocks.BRANCH_INPUT_PREFIX
            ) {
                inputs[input] = block.inputs[input];
            }
        }

        this._cache.inputs[block.id] = inputs;
        return inputs;
    }

    /**
     * Get mutation data for a block.
     * @param {?object} block The block to query.
     * @return {?object} Mutation for the block.
     */
    getMutation (block) {
        return typeof block === 'undefined' ? null : block.mutation;
    }

    /**
     * Get the top-level script for a given block.
     * @param {?string} id ID of block to query.
     * @return {?string} ID of top-level script block.
     */
    getTopLevelScript (id) {
        let block = this._blocks[id];
        if (typeof block === 'undefined') return null;
        while (block.parent !== null) {
            block = this._blocks[block.parent];
        }
        return block.id;
    }

    /**
     * Get the procedure definition for a given name.
     * @param {?string} name Name of procedure to query.
     * @return {?string} ID of procedure definition.
     */
    getProcedureDefinition (name) {
        const blockID = this._cache.procedureDefinitions[name];
        if (typeof blockID !== 'undefined') {
            return blockID;
        }

        for (const id in this._blocks) {
            if (!Object.prototype.hasOwnProperty.call(this._blocks, id)) {
                continue;
            }
            const block = this._blocks[id];
            if (block.opcode === 'procedures_definition') {
                const internal = this._getCustomBlockInternal(block);
                if (internal && internal.mutation.proccode === name) {
                    this._cache.procedureDefinitions[name] = id; // The outer define block id
                    return id;
                }
            }
        }

        this._cache.procedureDefinitions[name] = null;
        return null;
    }

    /**
     * Get names and ids of parameters for the given procedure.
     * @param {?string} name Name of procedure to query.
     * @return {?Array.<string>} List of param names for a procedure.
     */
    getProcedureParamNamesAndIds (name) {
        return this.getProcedureParamNamesIdsAndDefaults(name).slice(0, 2);
    }

    /**
     * Get names, ids, and defaults of parameters for the given procedure.
     * @param {?string} name Name of procedure to query.
     * @return {?Array.<string>} List of param names for a procedure.
     */
    getProcedureParamNamesIdsAndDefaults (name) {
        const cachedNames = this._cache.procedureParamNames[name];
        if (typeof cachedNames !== 'undefined') {
            return cachedNames;
        }

        for (const id in this._blocks) {
            if (!Object.prototype.hasOwnProperty.call(this._blocks, id)) {
                continue;
            }
            const block = this._blocks[id];
            if (
                block.opcode === 'procedures_prototype' &&
                block.mutation.proccode === name
            ) {
                const names = JSON.parse(block.mutation.argumentnames);
                const ids = JSON.parse(block.mutation.argumentids);
                const defaults = JSON.parse(block.mutation.argumentdefaults);

                this._cache.procedureParamNames[name] = [names, ids, defaults];
                return this._cache.procedureParamNames[name];
            }
        }

        this._cache.procedureParamNames[name] = null;
        return null;
    }

    duplicate () {
        const newBlocks = new Blocks(this.runtime, this.forceNoGlow);
        newBlocks._blocks = Clone.simple(this._blocks);
        newBlocks._scripts = Clone.simple(this._scripts);
        return newBlocks;
    }
    // ---------------------------------------------------------------------

    /**
     * Create event listener for blocks, variables, and comments. Handles validation and
     * serves as a generic adapter between the blocks, variables, and the
     * runtime interface.
     * @param {object} e Blockly "block" or "variable" event
     */
    blocklyListen (e) {
        // Validate event
        if (typeof e !== 'object') return;
        if (
            typeof e.blockId !== 'string' &&
            typeof e.varId !== 'string' &&
            typeof e.commentId !== 'string'
        ) {
            return;
        }
        const stage = this.runtime.getTargetForStage();
        const editingTarget = this.runtime.getEditingTarget();

        // UI event: clicked scripts toggle in the runtime.
        if (e.element === 'stackclick') {
            this.runtime.toggleScript(e.blockId, {stackClick: true});
            return;
        }

        // Block create/update/destroy
        switch (e.type) {
        case 'create': {
            const newBlocks = adapter(e);
            // A create event can create many blocks. Add them all.
            for (let i = 0; i < newBlocks.length; i++) {
                this.createBlock(newBlocks[i]);
            }
            break;
        }
        case 'change':
            this.changeBlock({
                id: e.blockId,
                element: e.element,
                name: e.name,
                value: e.newValue
            });
            break;
        case 'move':
            this.moveBlock({
                id: e.blockId,
                oldParent: e.oldParentId,
                oldInput: e.oldInputName,
                newParent: e.newParentId,
                newInput: e.newInputName,
                newCoordinate: e.newCoordinate
            });
            break;
        case 'dragOutside':
            this.runtime.emitBlockDragUpdate(e.isOutside);
            break;
        case 'endDrag':
            this.runtime.emitBlockDragUpdate(false /* areBlocksOverGui */);

            // Drag blocks onto another sprite
            if (e.isOutside) {
                const newBlocks = adapter(e);
                this.runtime.emitBlockEndDrag(newBlocks, e.blockId);
            }
            break;
        case 'delete':
            // Don't accept delete events for missing blocks,
            // or shadow blocks being obscured.
            if (
                !Object.prototype.hasOwnProperty.call(
                    this._blocks,
                    e.blockId
                ) ||
                    this._blocks[e.blockId].shadow
            ) {
                return;
            }
            // Inform any runtime to forget about glows on this script.
            if (this._blocks[e.blockId].topLevel) {
                this.runtime.quietGlow(e.blockId);
            }
            this.deleteBlock(e.blockId);
            break;
        case 'var_create':
            // Check if the variable being created is global or local
            // If local, create a local var on the current editing target, as long
            // as there are no conflicts, and the current target is actually a sprite
            // If global or if the editing target is not present or we somehow got
            // into a state where a local var was requested for the stage,
            // create a stage (global) var after checking for name conflicts
            // on all the sprites.
            if (
                e.isLocal &&
                    editingTarget &&
                    !editingTarget.isStage &&
                    !e.isCloud
            ) {
                if (!editingTarget.lookupVariableById(e.varId)) {
                    editingTarget.createVariable(
                        e.varId,
                        e.varName,
                        e.varType
                    );
                    this.emitProjectChanged();
                }
            } else {
                if (stage.lookupVariableById(e.varId)) {
                    // Do not re-create a variable if it already exists
                    return;
                }
                // Check for name conflicts in all of the targets
                const allTargets = this.runtime.targets.filter(
                    t => t.isOriginal
                );
                for (const target of allTargets) {
                    if (
                        target.lookupVariableByNameAndType(
                            e.varName,
                            e.varType,
                            true
                        )
                    ) {
                        return;
                    }
                }
                stage.createVariable(
                    e.varId,
                    e.varName,
                    e.varType,
                    e.isCloud
                );
                this.emitProjectChanged();
            }
            break;
        case 'var_rename':
            if (
                editingTarget &&
                    Object.prototype.hasOwnProperty.call(
                        editingTarget.variables,
                        e.varId
                    )
            ) {
                // This is a local variable, rename on the current target
                editingTarget.renameVariable(e.varId, e.newName);
                // Update all the blocks on the current target that use
                // this variable
                editingTarget.blocks.updateBlocksAfterVarRename(
                    e.varId,
                    e.newName
                );
            } else {
                // This is a global variable
                stage.renameVariable(e.varId, e.newName);
                // Update all blocks on all targets that use the renamed variable
                const targets = this.runtime.targets;
                for (let i = 0; i < targets.length; i++) {
                    const currTarget = targets[i];
                    currTarget.blocks.updateBlocksAfterVarRename(
                        e.varId,
                        e.newName
                    );
                }
            }
            this.emitProjectChanged();
            break;
        case 'var_delete': {
            const target =
                    editingTarget &&
                    Object.prototype.hasOwnProperty.call(
                        editingTarget.variables,
                        e.varId
                    ) ?
                        editingTarget :
                        stage;
            target.deleteVariable(e.varId);
            this.emitProjectChanged();
            break;
        }
        case 'comment_create':
            if (this.runtime.getEditingTarget()) {
                const currTarget = this.runtime.getEditingTarget();
                currTarget.createComment(
                    e.commentId,
                    e.blockId,
                    e.text,
                    e.xy.x,
                    e.xy.y,
                    e.width,
                    e.height,
                    e.minimized
                );

                if (
                    currTarget.comments[e.commentId].x === null &&
                        currTarget.comments[e.commentId].y === null
                ) {
                    // Block comments imported from 2.0 projects are imported with their
                    // x and y coordinates set to null so that scratch-blocks can
                    // auto-position them. If we are receiving a create event for these
                    // comments, then the auto positioning should have taken place.
                    // Update the x and y position of these comments to match the
                    // one from the event.
                    currTarget.comments[e.commentId].x = e.xy.x;
                    currTarget.comments[e.commentId].y = e.xy.y;
                }
            }
            this.emitProjectChanged();
            break;
        case 'comment_change':
            if (this.runtime.getEditingTarget()) {
                const currTarget = this.runtime.getEditingTarget();
                if (
                    !Object.prototype.hasOwnProperty.call(
                        currTarget.comments,
                        e.commentId
                    )
                ) {
                    log.warn(
                        `Cannot change comment with id ${e.commentId} because it does not exist.`
                    );
                    return;
                }
                const comment = currTarget.comments[e.commentId];
                const change = e.newContents_;
                if (Object.prototype.hasOwnProperty.call(change, 'minimized')) {
                    comment.minimized = change.minimized;
                }
                if (Object.prototype.hasOwnProperty.call(change, 'width') &&
                    Object.prototype.hasOwnProperty.call(change, 'height')) {
                    comment.width = change.width;
                    comment.height = change.height;
                }
                if (Object.prototype.hasOwnProperty.call(change, 'text')) {
                    comment.text = change.text;
                }
                this.emitProjectChanged();
            }
            break;
        case 'comment_move':
            if (this.runtime.getEditingTarget()) {
                const currTarget = this.runtime.getEditingTarget();
                if (
                    currTarget &&
                        !Object.prototype.hasOwnProperty.call(
                            currTarget.comments,
                            e.commentId
                        )
                ) {
                    log.warn(
                        `Cannot move comment with id ${e.commentId} because it does not exist.`
                    );
                    return;
                }
                const comment = currTarget.comments[e.commentId];
                const newCoord = e.newCoordinate_;
                comment.x = newCoord.x;
                comment.y = newCoord.y;

                this.emitProjectChanged();
            }
            break;
        case 'comment_delete':
            if (this.runtime.getEditingTarget()) {
                const currTarget = this.runtime.getEditingTarget();
                if (
                    !Object.prototype.hasOwnProperty.call(
                        currTarget.comments,
                        e.commentId
                    )
                ) {
                    // If we're in this state, we have probably received
                    // a delete event from a workspace that we switched from
                    // (e.g. a delete event for a comment on sprite a's workspace
                    // when switching from sprite a to sprite b)
                    return;
                }
                delete currTarget.comments[e.commentId];
                if (e.blockId) {
                    const block = currTarget.blocks.getBlock(e.blockId);
                    if (!block) {
                        log.warn(
                            `Could not find block referenced by comment with id: ${e.commentId}`
                        );
                        return;
                    }
                    delete block.comment;
                }

                this.emitProjectChanged();
            }
            break;
        }
    }

    // ---------------------------------------------------------------------

    /**
     * Reset all runtime caches.
     */
    resetCache () {
        this._cache.inputs = {};
        this._cache.procedureParamNames = {};
        this._cache.procedureDefinitions = {};
        this._cache._executeCached = {};
        this._cache._monitored = null;
        this._cache.scripts = {};
    }

    /**
     * Emit a project changed event if this is a block container
     * that can affect the project state.
     */
    emitProjectChanged () {
        if (!this.forceNoGlow) {
            this.runtime.emitProjectChanged();
        }
    }

    /**
     * Block management: create blocks and scripts from a `create` event
     * @param {!object} block Blockly create event to be processed
     */
    createBlock (block) {
        // Does the block already exist?
        // Could happen, e.g., for an unobscured shadow.
        if (Object.prototype.hasOwnProperty.call(this._blocks, block.id)) {
            return;
        }
        // Create new block.
        this._blocks[block.id] = block;
        // Push block id to scripts array.
        // Blocks are added as a top-level stack if they are marked as a top-block
        // (if they were top-level XML in the event).
        if (block.topLevel) {
            this._addScript(block.id);
        }

        this.resetCache();

        // A new block was actually added to the block container,
        // emit a project changed event
        this.emitProjectChanged();
    }

    /**
     * Block management: change block field values
     * @param {!object} args Blockly change event to be processed
     */
    changeBlock (args) {
        // Validate
        if (['field', 'mutation', 'checkbox'].indexOf(args.element) === -1) {
            return;
        }
        let block = this._blocks[args.id];
        if (typeof block === 'undefined') return;
        switch (args.element) {
        case 'field':
            // TODO when the field of a monitored block changes,
            // update the checkbox in the flyout based on whether
            // a monitor for that current combination of selected parameters exists
            // e.g.
            // 1. check (current [v year])
            // 2. switch dropdown in flyout block to (current [v minute])
            // 3. the checkbox should become unchecked if we're not already
            //    monitoring current minute

            // Update block value
            if (!block.fields[args.name]) return;
            if (
                args.name === 'VARIABLE' ||
                    args.name === 'LIST' ||
                    args.name === 'BROADCAST_OPTION'
            ) {
                // Get variable name using the id in args.value.
                const variable = this.runtime
                    .getEditingTarget()
                    .lookupVariableById(args.value);
                if (variable) {
                    block.fields[args.name].value = variable.name;
                    block.fields[args.name].id = args.value;
                }
            } else {
                // Changing the value in a dropdown
                block.fields[args.name].value = args.value;

                // The selected item in the sensing of block menu needs to change based on the
                // selected target.  Set it to the first item in the menu list.
                // TODO: (#1787)
                if (block.opcode === 'sensing_of_object_menu') {
                    if (block.fields.OBJECT.value === '_stage_') {
                        this._blocks[block.parent].fields.PROPERTY.value =
                                'backdrop #';
                    } else {
                        this._blocks[block.parent].fields.PROPERTY.value =
                                'x position';
                    }
                    this.runtime.requestBlocksUpdate();
                }

                const flyoutBlock =
                        block.shadow && block.parent ?
                            this._blocks[block.parent] :
                            block;
                if (flyoutBlock.isMonitored) {
                    this.runtime.requestUpdateMonitor(
                        Map({
                            id: flyoutBlock.id,
                            params: this._getBlockParams(flyoutBlock)
                        })
                    );
                }
            }
            break;
        case 'mutation':
            block.mutation = mutationAdapter(args.value);
            break;
        case 'checkbox': {
            // A checkbox usually has a one to one correspondence with the monitor
            // block but in the case of monitored reporters that have arguments,
            // map the old id to a new id, creating a new monitor block if necessary
            if (
                block.fields &&
                    Object.keys(block.fields).length > 0 &&
                    block.opcode !== 'data_variable' &&
                    block.opcode !== 'data_listcontents'
            ) {
                // This block has an argument which needs to get separated out into
                // multiple monitor blocks with ids based on the selected argument
                // Note: we're not just constantly creating a longer and longer id everytime we check
                // the checkbox because we're using the id of the block in the flyout as the base
                const newId = getMonitorIdForBlockWithArgs(
                    block.id,
                    block.fields
                );

                // check if a block with the new id already exists, otherwise create
                let newBlock = this.runtime.monitorBlocks.getBlock(newId);
                if (!newBlock) {
                    newBlock = JSON.parse(JSON.stringify(block));
                    newBlock.id = newId;
                    this.runtime.monitorBlocks.createBlock(newBlock);
                }

                block = newBlock; // Carry on through the rest of this code with newBlock
            }

            const wasMonitored = block.isMonitored;
            block.isMonitored = args.value;

            // Variable blocks may be sprite specific depending on the owner of the variable
            let isSpriteLocalVariable = false;
            if (block.opcode === 'data_variable') {
                isSpriteLocalVariable =
                        !this.runtime.getTargetForStage().variables[
                            block.fields.VARIABLE.id
                        ];
            } else if (block.opcode === 'data_listcontents') {
                isSpriteLocalVariable =
                        !this.runtime.getTargetForStage().variables[
                            block.fields.LIST.id
                        ];
            }

            const isSpriteSpecific =
                    isSpriteLocalVariable ||
                    (Object.prototype.hasOwnProperty.call(
                        this.runtime.monitorBlockInfo,
                        block.opcode
                    ) &&
                        this.runtime.monitorBlockInfo[block.opcode]
                            .isSpriteSpecific);
            if (isSpriteSpecific) {
                // If creating a new sprite specific monitor, the only possible target is
                // the current editing one b/c you cannot dynamically create monitors.
                // Also, do not change the targetId if it has already been assigned
                block.targetId =
                        block.targetId || this.runtime.getEditingTarget().id;
            } else {
                block.targetId = null;
            }

            if (wasMonitored && !block.isMonitored) {
                this.runtime.requestHideMonitor(block.id);
            } else if (!wasMonitored && block.isMonitored) {
                // Tries to show the monitor for specified block. If it doesn't exist, add the monitor.
                if (!this.runtime.requestShowMonitor(block.id)) {
                    this.runtime.requestAddMonitor(
                        MonitorRecord({
                            id: block.id,
                            targetId: block.targetId,
                            spriteName: block.targetId ?
                                this.runtime
                                    .getTargetById(block.targetId)
                                    .getName() :
                                null,
                            opcode: block.opcode,
                            params: this._getBlockParams(block),
                            // @todo(vm#565) for numerical values with decimals, some countries use comma
                            value: '',
                            mode:
                                    block.opcode === 'data_listcontents' ?
                                        'list' :
                                        'default'
                        })
                    );
                }
            }
            break;
        }
        }

        this.emitProjectChanged();

        this.resetCache();
    }

    /**
     * Block management: move blocks from parent to parent
     * @param {!object} e Blockly move event to be processed
     */
    moveBlock (e) {
        if (!Object.prototype.hasOwnProperty.call(this._blocks, e.id)) {
            return;
        }

        const block = this._blocks[e.id];
        // Track whether a change actually occurred
        // ignoring changes like routine re-positioning
        // of a block when loading a workspace
        let didChange = false;

        // Move coordinate changes.
        if (e.newCoordinate) {
            didChange =
                block.x !== e.newCoordinate.x || block.y !== e.newCoordinate.y;

            block.x = e.newCoordinate.x;
            block.y = e.newCoordinate.y;
        }

        // Remove from any old parent.
        if (typeof e.oldParent !== 'undefined') {
            const oldParent = this._blocks[e.oldParent];
            if (
                typeof e.oldInput !== 'undefined' &&
                oldParent.inputs[e.oldInput].block === e.id
            ) {
                // This block was connected to the old parent's input.
                oldParent.inputs[e.oldInput].block = null;
            } else if (oldParent.next === e.id) {
                // This block was connected to the old parent's next connection.
                oldParent.next = null;
            }
            this._blocks[e.id].parent = null;
            didChange = true;
        }

        // Is this block a top-level block?
        if (typeof e.newParent === 'undefined') {
            this._addScript(e.id);
        } else {
            // Remove script, if one exists.
            this._deleteScript(e.id);
            // Otherwise, try to connect it in its new place.
            if (typeof e.newInput === 'undefined') {
                // Moved to the new parent's next connection.
                this._blocks[e.newParent].next = e.id;
            } else {
                // Moved to the new parent's input.
                // Don't obscure the shadow block.
                let oldShadow = null;
                if (
                    Object.prototype.hasOwnProperty.call(
                        this._blocks[e.newParent].inputs,
                        e.newInput
                    )
                ) {
                    oldShadow =
                        this._blocks[e.newParent].inputs[e.newInput].shadow;
                }

                // If the block being attached is itself a shadow, make sure to set
                // both block and shadow to that blocks ID. This happens when adding
                // inputs to a custom procedure.
                if (this._blocks[e.id].shadow) oldShadow = e.id;

                this._blocks[e.newParent].inputs[e.newInput] = {
                    name: e.newInput,
                    block: e.id,
                    shadow: oldShadow
                };
            }
            this._blocks[e.id].parent = e.newParent;
            didChange = true;
        }
        this.resetCache();

        if (didChange) this.emitProjectChanged();
    }

    /**
     * Block management: run all blocks.
     * @param {!object} runtime Runtime to run all blocks in.
     */
    runAllMonitored (runtime) {
        if (this._cache._monitored === null) {
            this._cache._monitored = Object.keys(this._blocks)
                .filter(blockId => this.getBlock(blockId).isMonitored)
                .map(blockId => {
                    const targetId = this.getBlock(blockId).targetId;
                    return {
                        blockId,
                        target: targetId ?
                            runtime.getTargetById(targetId) :
                            null
                    };
                });
        }

        const monitored = this._cache._monitored;
        for (let i = 0; i < monitored.length; i++) {
            const {blockId, target} = monitored[i];
            runtime.addMonitorScript(blockId, target);
        }
    }

    /**
     * Block management: delete blocks and their associated scripts. Does nothing if a block
     * with the given ID does not exist.
     * @param {!string} blockId Id of block to delete
     */
    deleteBlock (blockId) {
        // @todo In runtime, stop threads running on this script.

        // Get block
        const block = this._blocks[blockId];
        if (!block) {
            // No block with the given ID exists
            return;
        }

        // Delete children
        if (block.next !== null) {
            this.deleteBlock(block.next);
        }

        // Delete inputs (including branches)
        for (const input in block.inputs) {
            // If it's null, the block in this input moved away.
            if (block.inputs[input].block !== null) {
                this.deleteBlock(block.inputs[input].block);
            }
            // Delete obscured shadow blocks.
            if (
                block.inputs[input].shadow !== null &&
                block.inputs[input].shadow !== block.inputs[input].block
            ) {
                this.deleteBlock(block.inputs[input].shadow);
            }
        }

        // Delete any script starting with this block.
        this._deleteScript(blockId);

        // Delete block itself.
        delete this._blocks[blockId];

        this.resetCache();
        this.emitProjectChanged();
    }

    /**
     * Delete all blocks and their associated scripts.
     */
    deleteAllBlocks () {
        const blockIds = Object.keys(this._blocks);
        blockIds.forEach(blockId => this.deleteBlock(blockId));
    }

    /**
     * Returns a map of all references to variables or lists from blocks
     * in this block container.
     * @param {Array<object>} optBlocks Optional list of blocks to constrain the search to.
     * This is useful for getting variable/list references for a stack of blocks instead
     * of all blocks on the workspace
     * @param {?boolean} optIncludeBroadcast Optional whether to include broadcast fields.
     * @return {object} A map of variable ID to a list of all variable references
     * for that ID. A variable reference contains the field referencing that variable
     * and also the type of the variable being referenced.
     */
    getAllVariableAndListReferences (optBlocks, optIncludeBroadcast) {
        const blocks = optBlocks ? optBlocks : this._blocks;
        const allReferences = Object.create(null);
        for (const blockId in blocks) {
            let varOrListField = null;
            let varType = null;
            if (blocks[blockId].fields.VARIABLE) {
                varOrListField = blocks[blockId].fields.VARIABLE;
                varType = Variable.SCALAR_TYPE;
            } else if (blocks[blockId].fields.LIST) {
                varOrListField = blocks[blockId].fields.LIST;
                varType = Variable.LIST_TYPE;
            } else if (
                optIncludeBroadcast &&
                blocks[blockId].fields.BROADCAST_OPTION
            ) {
                varOrListField = blocks[blockId].fields.BROADCAST_OPTION;
                varType = Variable.BROADCAST_MESSAGE_TYPE;
            }
            if (varOrListField) {
                const currVarId = varOrListField.id;
                if (allReferences[currVarId]) {
                    allReferences[currVarId].push({
                        referencingField: varOrListField,
                        type: varType
                    });
                } else {
                    allReferences[currVarId] = [
                        {
                            referencingField: varOrListField,
                            type: varType
                        }
                    ];
                }
            }
        }
        return allReferences;
    }

    /**
     * Keep blocks up to date after a variable gets renamed.
     * @param {string} varId The id of the variable that was renamed
     * @param {string} newName The new name of the variable that was renamed
     */
    updateBlocksAfterVarRename (varId, newName) {
        const blocks = this._blocks;
        for (const blockId in blocks) {
            let varOrListField = null;
            if (blocks[blockId].fields.VARIABLE) {
                varOrListField = blocks[blockId].fields.VARIABLE;
            } else if (blocks[blockId].fields.LIST) {
                varOrListField = blocks[blockId].fields.LIST;
            }
            if (varOrListField) {
                const currFieldId = varOrListField.id;
                if (varId === currFieldId) {
                    varOrListField.value = newName;
                }
            }
        }
    }

    /**
     * Keep blocks up to date after they are shared between targets.
     * @param {boolean} isStage If the new target is a stage.
     */
    updateTargetSpecificBlocks (isStage) {
        const blocks = this._blocks;
        for (const blockId in blocks) {
            if (
                isStage &&
                blocks[blockId].opcode === 'event_whenthisspriteclicked'
            ) {
                blocks[blockId].opcode = 'event_whenstageclicked';
            } else if (
                !isStage &&
                blocks[blockId].opcode === 'event_whenstageclicked'
            ) {
                blocks[blockId].opcode = 'event_whenthisspriteclicked';
            }
        }
    }

    /**
     * Update blocks after a sound, costume, or backdrop gets renamed.
     * Any block referring to the old name of the asset should get updated
     * to refer to the new name.
     * @param {string} oldName The old name of the asset that was renamed.
     * @param {string} newName The new name of the asset that was renamed.
     * @param {string} assetType String representation of the kind of asset
     * that was renamed. This can be one of 'sprite','costume', 'sound', or
     * 'backdrop'.
     */
    updateAssetName (oldName, newName, assetType) {
        let getAssetField;
        if (assetType === 'costume') {
            getAssetField = this._getCostumeField.bind(this);
        } else if (assetType === 'sound') {
            getAssetField = this._getSoundField.bind(this);
        } else if (assetType === 'backdrop') {
            getAssetField = this._getBackdropField.bind(this);
        } else if (assetType === 'sprite') {
            getAssetField = this._getSpriteField.bind(this);
        } else {
            return;
        }
        const blocks = this._blocks;
        for (const blockId in blocks) {
            const assetField = getAssetField(blockId);
            if (assetField && assetField.value === oldName) {
                assetField.value = newName;
            }
        }
    }

    /**
     * Update sensing_of blocks after a variable gets renamed.
     * @param {string} oldName The old name of the variable that was renamed.
     * @param {string} newName The new name of the variable that was renamed.
     * @param {string} targetName The name of the target the variable belongs to.
     * @return {boolean} Returns true if any of the blocks were updated.
     */
    updateSensingOfReference (oldName, newName, targetName) {
        const blocks = this._blocks;
        let blockUpdated = false;
        for (const blockId in blocks) {
            const block = blocks[blockId];
            if (
                block.opcode === 'sensing_of' &&
                block.fields.PROPERTY.value === oldName &&
                // If block and shadow are different, it means a block is inserted to OBJECT, and should be ignored.
                block.inputs.OBJECT.block === block.inputs.OBJECT.shadow
            ) {
                const inputBlock = this.getBlock(block.inputs.OBJECT.block);
                if (inputBlock.fields.OBJECT.value === targetName) {
                    block.fields.PROPERTY.value = newName;
                    blockUpdated = true;
                }
            }
        }
        if (blockUpdated) this.resetCache();
        return blockUpdated;
    }

    /**
     * Helper function to retrieve a costume menu field from a block given its id.
     * @param {string} blockId A unique identifier for a block
     * @return {?object} The costume menu field of the block with the given block id.
     * Null if either a block with the given id doesn't exist or if a costume menu field
     * does not exist on the block with the given id.
     */
    _getCostumeField (blockId) {
        const block = this.getBlock(blockId);
        if (
            block &&
            Object.prototype.hasOwnProperty.call(block.fields, 'COSTUME')
        ) {
            return block.fields.COSTUME;
        }
        return null;
    }

    /**
     * Helper function to retrieve a sound menu field from a block given its id.
     * @param {string} blockId A unique identifier for a block
     * @return {?object} The sound menu field of the block with the given block id.
     * Null, if either a block with the given id doesn't exist or if a sound menu field
     * does not exist on the block with the given id.
     */
    _getSoundField (blockId) {
        const block = this.getBlock(blockId);
        if (
            block &&
            Object.prototype.hasOwnProperty.call(block.fields, 'SOUND_MENU')
        ) {
            return block.fields.SOUND_MENU;
        }
        return null;
    }

    /**
     * Helper function to retrieve a backdrop menu field from a block given its id.
     * @param {string} blockId A unique identifier for a block
     * @return {?object} The backdrop menu field of the block with the given block id.
     * Null, if either a block with the given id doesn't exist or if a backdrop menu field
     * does not exist on the block with the given id.
     */
    _getBackdropField (blockId) {
        const block = this.getBlock(blockId);
        if (
            block &&
            Object.prototype.hasOwnProperty.call(block.fields, 'BACKDROP')
        ) {
            return block.fields.BACKDROP;
        }
        return null;
    }

    /**
     * Helper function to retrieve a sprite menu field from a block given its id.
     * @param {string} blockId A unique identifier for a block
     * @return {?object} The sprite menu field of the block with the given block id.
     * Null, if either a block with the given id doesn't exist or if a sprite menu field
     * does not exist on the block with the given id.
     */
    _getSpriteField (blockId) {
        const block = this.getBlock(blockId);
        if (!block) {
            return null;
        }
        const spriteMenuNames = [
            'TOWARDS',
            'TO',
            'OBJECT',
            'VIDEOONMENU2',
            'DISTANCETOMENU',
            'TOUCHINGOBJECTMENU',
            'CLONE_OPTION'
        ];
        for (let i = 0; i < spriteMenuNames.length; i++) {
            const menuName = spriteMenuNames[i];
            if (Object.prototype.hasOwnProperty.call(block.fields, menuName)) {
                return block.fields[menuName];
            }
        }
        return null;
    }

    // ---------------------------------------------------------------------

    /**
     * Encode all of `this._blocks` as an XML string usable
     * by a Blockly/scratch-blocks workspace.
     * @param {object<string, Comment>} comments Map of comments referenced by id
     * @return {string} String of XML representing this object's blocks.
     */
    toXML (comments) {
        return this._scripts
            .map(script => this.blockToXML(script, comments))
            .join();
    }

    /**
     * Recursively encode an individual block and its children
     * into a Blockly/scratch-blocks XML string.
     * @param {!string} blockId ID of block to encode.
     * @param {object<string, Comment>} comments Map of comments referenced by id
     * @return {string} String of XML representing this block and any children.
     */
    blockToXML (blockId, comments) {
        const block = this._blocks[blockId];
        // block should exist, but currently some blocks' next property point
        // to a blockId for non-existent blocks. Until we track down that behavior,
        // this early exit allows the project to load.
        if (!block) return;
        // Encode properties of this block.
        const tagName = block.shadow ? 'shadow' : 'block';
        let xmlString = `<${tagName}
                id="${block.id}"
                type="${block.opcode}"
                ${block.topLevel ? `x="${block.x}" y="${block.y}"` : ''}
            >`;
        const commentId = block.comment;
        if (commentId) {
            if (comments) {
                if (Object.prototype.hasOwnProperty.call(comments, commentId)) {
                    xmlString += comments[commentId].toXML();
                } else {
                    log.warn(
                        `Could not find comment with id: ${commentId} in provided comment descriptions.`
                    );
                }
            } else {
                log.warn(
                    `Cannot serialize comment with id: ${commentId}; no comment descriptions provided.`
                );
            }
        }
        // Add any mutation. Must come before inputs.
        if (block.mutation) {
            xmlString += this.mutationToXML(block.mutation);
        }
        // Add any inputs on this block.
        for (const input in block.inputs) {
            if (!Object.prototype.hasOwnProperty.call(block.inputs, input)) {
                continue;
            }
            const blockInput = block.inputs[input];
            // Only encode a value tag if the value input is occupied.
            if (blockInput.block || blockInput.shadow) {
                xmlString += `<value name="${blockInput.name}">`;
                if (blockInput.block) {
                    xmlString += this.blockToXML(blockInput.block, comments);
                }
                if (
                    blockInput.shadow &&
                    blockInput.shadow !== blockInput.block
                ) {
                    // Obscured shadow.
                    xmlString += this.blockToXML(blockInput.shadow, comments);
                }
                xmlString += '</value>';
            }
        }
        // Add any fields on this block.
        for (const field in block.fields) {
            if (!Object.prototype.hasOwnProperty.call(block.fields, field)) {
                continue;
            }
            const blockField = block.fields[field];
            xmlString += `<field name="${blockField.name}"`;
            const fieldId = blockField.id;
            if (fieldId) {
                xmlString += ` id="${fieldId}"`;
            }
            const varType = blockField.variableType;
            if (typeof varType === 'string') {
                xmlString += ` variabletype="${varType}"`;
            }
            let value = blockField.value;
            if (typeof value === 'string') {
                value = xmlEscape(blockField.value);
            }
            xmlString += `>${value}</field>`;
        }
        // Add blocks connected to the next connection.
        if (block.next) {
            xmlString += `<next>${this.blockToXML(
                block.next,
                comments
            )}</next>`;
        }
        xmlString += `</${tagName}>`;
        return xmlString;
    }

    /**
     * Recursively encode a mutation object to XML.
     * @param {!object} mutation Object representing a mutation.
     * @return {string} XML string representing a mutation.
     */
    mutationToXML (mutation) {
        let mutationString = `<${mutation.tagName}`;
        for (const prop in mutation) {
            if (prop === 'children' || prop === 'tagName') continue;
            let mutationValue =
                typeof mutation[prop] === 'string' ?
                    xmlEscape(mutation[prop]) :
                    mutation[prop];

            // Handle dynamic extension blocks
            if (prop === 'blockInfo') {
                mutationValue = xmlEscape(JSON.stringify(mutation[prop]));
            }

            mutationString += ` ${prop}="${mutationValue}"`;
        }
        mutationString += '>';
        for (let i = 0; i < mutation.children.length; i++) {
            mutationString += this.mutationToXML(mutation.children[i]);
        }
        mutationString += `</${mutation.tagName}>`;
        return mutationString;
    }

    // ---------------------------------------------------------------------
    /**
     * Helper to serialize block fields and input fields for reporting new monitors
     * @param {!object} block Block to be paramified.
     * @return {!object} object of param key/values.
     */
    _getBlockParams (block) {
        const params = {};
        for (const key in block.fields) {
            params[key] = block.fields[key].value;
        }
        for (const inputKey in block.inputs) {
            const inputBlock = this._blocks[block.inputs[inputKey].block];
            for (const key in inputBlock.fields) {
                params[key] = inputBlock.fields[key].value;
            }
        }
        return params;
    }

    /**
     * Helper to get the corresponding internal procedure definition block
     * @param {!object} defineBlock Outer define block.
     * @return {!object} internal definition block which has the mutation.
     */
    _getCustomBlockInternal (defineBlock) {
        if (defineBlock.inputs && defineBlock.inputs.custom_block) {
            return this._blocks[defineBlock.inputs.custom_block.block];
        }
    }

    /**
     * Helper to add a stack to `this._scripts`.
     * @param {?string} topBlockId ID of block that starts the script.
     */
    _addScript (topBlockId) {
        const i = this._scripts.indexOf(topBlockId);
        if (i > -1) return; // Already in scripts.
        this._scripts.push(topBlockId);
        // Update `topLevel` property on the top block.
        this._blocks[topBlockId].topLevel = true;
    }

    /**
     * Helper to remove a script from `this._scripts`.
     * @param {?string} topBlockId ID of block that starts the script.
     */
    _deleteScript (topBlockId) {
        const i = this._scripts.indexOf(topBlockId);
        if (i > -1) this._scripts.splice(i, 1);
        // Update `topLevel` property on the top block.
        if (this._blocks[topBlockId]) this._blocks[topBlockId].topLevel = false;
    }
}

/**
 * A private method shared with execute to build an object containing the block
 * information execute needs and that is reset when other cached Blocks info is
 * reset.
 * @param {Blocks} blocks Blocks containing the expected blockId
 * @param {string} blockId blockId for the desired execute cache
 * @param {function} CacheType constructor for cached block information
 * @return {object} execute cache object
 */
BlocksExecuteCache.getCached = function (blocks, blockId, CacheType) {
    let cached = blocks._cache._executeCached[blockId];
    if (typeof cached !== 'undefined') {
        return cached;
    }

    const block = blocks.getBlock(blockId);
    if (typeof block === 'undefined') return null;

    if (typeof CacheType === 'undefined') {
        cached = {
            id: blockId,
            opcode: blocks.getOpcode(block),
            fields: blocks.getFields(block),
            inputs: blocks.getInputs(block),
            mutation: blocks.getMutation(block)
        };
    } else {
        cached = new CacheType(blocks, {
            id: blockId,
            opcode: blocks.getOpcode(block),
            fields: blocks.getFields(block),
            inputs: blocks.getInputs(block),
            mutation: blocks.getMutation(block)
        });
    }

    blocks._cache._executeCached[blockId] = cached;
    return cached;
};

/**
 * Cache class constructor for runtime. Used to consider what threads should
 * start based on hat data.
 * @type {function}
 */
const RuntimeScriptCache = BlocksRuntimeCache._RuntimeScriptCache;

/**
 * Get an array of scripts from a block container prefiltered to match opcode.
 * @param {Blocks} blocks - Container of blocks
 * @param {string} opcode - Opcode to filter top blocks by
 * @returns {Array.<RuntimeScriptCache>} - Array of RuntimeScriptCache cache
 *   objects
 */
BlocksRuntimeCache.getScripts = function (blocks, opcode) {
    let scripts = blocks._cache.scripts[opcode];
    if (!scripts) {
        scripts = blocks._cache.scripts[opcode] = [];

        const allScripts = blocks._scripts;
        for (let i = 0; i < allScripts.length; i++) {
            const topBlockId = allScripts[i];
            const block = blocks.getBlock(topBlockId);
            if (block.opcode === opcode) {
                scripts.push(new RuntimeScriptCache(blocks, topBlockId));
            }
        }
    }
    return scripts;
};

module.exports = Blocks;