var widgets = require("@jupyter-widgets/base")
import * as NGL from "ngl"
import * as $ from 'jquery'
import * as _ from 'underscore'
import "./lib/signals.min.js"
import "./lib/colorpicker.min.js"
import "./ui/ui.js"
import "./ui/ui.extra.js"
import "./ui/ui.ngl.js"
import 'jquery-ui/ui/widgets/dialog' // FIXME: remove?
import 'jquery-ui/themes/base/all.css' // FIXME: remove?

import { StageWidget } from "./gui"
import { FullscreenModel, FullscreenView } from "./fullscreen"
import { ColormakerRegistryModel, ColormakerRegistryView } from "./color"
import { ThemeManagerModel, ThemeManagerView } from "./theme"
import { MolstarModel, MolstarView } from "./molstarview/widget"


// From NGL
// http://www.broofa.com/Tools/Math.uuid.htm
const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split('')
const uuid = new Array(36)

function generateUUID() {
    let rnd = 0
    let r

    for (let i = 0; i < 36; i++) {
        if (i === 8 || i === 13 || i === 18 || i === 23) {
            uuid[i] = '-'
        } else if (i === 14) {
            uuid[i] = '4'
        } else {
            if (rnd <= 0x02) rnd = 0x2000000 + (Math.random() * 0x1000000) | 0
            r = rnd & 0xf
            rnd = rnd >> 4
            uuid[i] = chars[(i === 19) ? (r & 0x3) | 0x8 : r]
        }
    }

    return uuid.join('')
}


export class NGLModel extends widgets.DOMWidgetModel {
    defaults() {
        return _.extend(widgets.DOMWidgetModel.prototype.defaults(), {
            _model_name: 'NGLModel',
            _model_module: 'nglview-js-widgets',
            _model_module_version: require("../package.json").version,
            _view_name: "NGLView",
            _view_module: "nglview-js-widgets",
            _view_module_version: require("../package.json").version,
        });
    }
}

export
    class NGLView extends widgets.DOMWidgetView {
    stage: NGL.Stage

    render() {
        this.beforeDisplay();
        this.displayed.then(() => {
            // move all below code inside 'displayed'
            // to make sure the NGLView and NGLModel are created
            this.createStage();
            this.handlePicking();
            this.handleSignals();
            this.handleMessage();
            this.finalizeDisplay();
        });
    }

    beforeDisplay() {
        this.model.on("change:_parameters", this.parametersChanged, this);
        this.model.on("change:gui_style", this.GUIStyleChanged, this);
        this.model.set('_ngl_version', NGL.Version);
        this._ngl_focused = 0
        this.uuid = generateUUID()
        this.stage_widget = undefined
        this.comp_uuids = []
        this._synced_model_ids = this.model.get("_synced_model_ids");
        this._synced_repr_model_ids = this.model.get("_synced_repr_model_ids")

        if (this.isEmbeded()) {
            // embed mode
            this._handleEmbedBeforeStage()
        }
    }

    createStage() {
        // init NGL stage
        var stage_params = {
            // Shallow copy so that _ngl_full_stage_parameters is not updated yet
            ...this.model.get("_ngl_full_stage_parameters")
        };
        if (!("backgroundColor" in stage_params)) {
            stage_params["backgroundColor"] = "white"
        }
        this.stage = new NGL.Stage(undefined)
        this.$container = $(this.stage.viewer.container);
        this.$el.append(this.$container)
        this.stage.setParameters(stage_params);
        this.$container = $(this.stage.viewer.container);
        this.handleResizable()
        this.ngl_view_id = this.uuid
        this.touch();
        var that = this;
        var width = this.model.get("_view_width") || this.$el.parent().width() + "px";
        var height = this.model.get("_view_height") || "300px";
        this.setSize(width, height);
        this.createFullscreenBtn(); // FIXME: move up?
        this.createIPlayer(); // FIXME: move up?
        this.GUIStyleChanged(); // must be called after displaying to get correct width and height

        this.$container.resizable(
            "option", "maxWidth", this.$el.parent().width()
        );
        if (this.isEmbeded()) {
            console.log("Embed mode for NGLView")
            that.handleEmbed();
        } else {
            this.requestUpdateStageParameters();
            if (this.model.views.length == 1) {
                this.serialize_camera_orientation();
            } else {
                this.set_camera_orientation(that.model.get("_camera_orientation"));
            }
        }
    }

    isEmbeded() {
        return (this.model.get("_ngl_serialize") || (this.model.comm == undefined))
    }

    handleMessage() {
        this.model.on("msg:custom", function (msg) {
            this.on_msg(msg);
        }, this);

        if (this.model.comm) {
            this.model.comm.on_msg(function (msg) {
                var buffers = msg.buffers;
                var content = msg.content.data.content;
                if (buffers.length && content) {
                    content.buffers = buffers;
                }
                this.model._handle_comm_msg.call(this.model, msg);
            }.bind(this));
        }
    }


    finalizeDisplay() {
        // for callbacks from Python
        // must be after initializing NGL.Stage
        this.send({
            'type': 'request_loaded',
            'data': true
        })
        var state_params = this.stage.getParameters();
        this.model.set('_ngl_original_stage_parameters', state_params);
        this.touch();
        if (!this.isEmbeded() && this.stage.compList.length < this.model.get("n_components")) {
            // only call this in notebook to avoid calling handleEmbed twice in embeded mode.
            this.handleEmbed()
        }
        var ngl_view_ids = this.model.get("_ngl_view_id")
        ngl_view_ids.push(this.ngl_view_id)
        this.send({ "type": "updateIDs", "data": ngl_view_ids })

        // FIXME: Why below doesn't update _ngl_view_id in backend?
        // this.model.set("_ngl_view_id", ngl_view_ids)
        // this.touch()
    }

    handleSignals() {
        var container = this.stage.viewer.container;
        var that = this;
        container.addEventListener('mouseover', function (e) {
            that._ngl_focused = 1;
            e; // linter
            that.mouseOverDisplay('block')
        }, false);

        container.addEventListener('mouseout', function (e) {
            that._ngl_focused = 0;
            e; // linter
            that.mouseOverDisplay('none')
        }, false);

        container.addEventListener('contextmenu', function (e) {
            e.stopPropagation();
            e.preventDefault();
        }, true);

        this.stage.signals.componentAdded.add(function (component) {
            this.comp_uuids.push(component.uuid)
            var len = this.stage.compList.length;
            this.model.set("n_components", len);
            this.touch();
            var comp = this.stage.compList[len - 1];
            comp.signals.representationRemoved.add(function () {
                that.request_repr_dict();
            });
            comp.signals.representationAdded.add(function (repr) {
                that.request_repr_dict();
                repr.signals.parametersChanged.add(function () {
                    console.log("repr.parametersChanged")
                    that.request_repr_dict();
                })
            });
        }, this);

        this.stage.signals.componentRemoved.add(async function (component) {
            var that = this
            var cindex = this.comp_uuids.indexOf(component.uuid)
            this.comp_uuids.splice(cindex, 1)
            var n_components = this.stage.compList.length
            this.model.set("n_components", n_components)
            this.touch()
            console.log('componentRemoved', component, component.uuid)

            var pviews = []
            for (var k in this.model.views) {
                pviews.push(this.model.views[k])
            }

            var views = await Promise.all(pviews)
            console.log(views)
            var update_backend = false
            for (var k in views) {
                var view = views[k]
                if ((view.uuid != that.uuid) && (view.stage.compList.length > n_components)) {
                    // remove component from NGL's GUI
                    // pass
                    view.stage.removeComponent(view.stage.compList[cindex])
                    update_backend = true
                }
            }
            if (update_backend) {
                console.log("should update backend")
                that.send({ "type": "removeComponent", "data": cindex })
            }
        }, this);

        this.stage.signals.parametersChanged.add(function () {
            this.requestUpdateStageParameters();
        }, this);

        this.stage.viewerControls.signals.changed.add(function () {
            setTimeout(() => {
                // https://github.com/nglviewer/nglview/issues/948#issuecomment-898121063
                this.serialize_camera_orientation();
            }, 100);

            var m = this.stage.viewerControls.getOrientation();
            if (that._synced_model_ids.length > 0 && that._ngl_focused == 1) {
                that._synced_model_ids.forEach(async function (mid) {
                    var model = await that.model.widget_manager.get_model(mid)
                    for (var k in model.views) {
                        var view = await model.views[k]
                        if (view.uuid != that.uuid) {
                            view.stage.viewerControls.orient(m);
                        }
                    }
                })
            }
        }.bind(this));

    }

    handlePicking() {
        this.$pickingInfo = $("<div></div>")
            .css("position", "absolute")
            .css("top", "5%")
            .css("left", "3%")
            .css("background-color", "white")
            .css("padding", "2px 5px 2px 5px")
            .css("opacity", "0.7")
            .appendTo(this.$container);

        this.stage.signals.clicked.add((pd) => {
            if (pd) {
                this.model.set('picked', {}); //refresh signal
                this.touch();

                var pd2 = {} as any;
                var pickingText = "";
                if (pd.atom) {
                    pd2.atom1 = pd.atom.toObject();
                    pd2.atom1.name = pd.atom.qualifiedName();
                    pickingText = "Atom: " + pd2.atom1.name;
                } else if (pd.bond) {
                    pd2.bond = pd.bond.toObject();
                    pd2.atom1 = pd.bond.atom1.toObject();
                    pd2.atom1.name = pd.bond.atom1.qualifiedName();
                    pd2.atom2 = pd.bond.atom2.toObject();
                    pd2.atom2.name = pd.bond.atom2.qualifiedName();
                    pickingText = "Bond: " + pd2.atom1.name + " - " + pd2.atom2.name;
                }
                if (pd.instance) pd2.instance = pd.instance;

                var n_components = this.stage.compList.length;
                for (var i = 0; i < n_components; i++) {
                    var comp = this.stage.compList[i];
                    if (comp.uuid == pd.component.uuid) {
                        pd2.component = i;
                    }
                }

                this.model.set('picked', pd2);
                this.touch();

                this.$pickingInfo.text(pickingText);
            }
        });
    }

    async mouseOverDisplay(type) {
        var that = this;
        if (this.btn_pview_fullscreen) {
            var btn = await this.btn_pview_fullscreen
            btn.el.style.display = type
            if (that.stage_widget) {
                // If NGL's GUI exists, use its fullscreen button.
                btn.el.style.display = 'none'
            }
        }

        var that = this;
        if (this.player_pview) {
            var v = await this.player_pview
            v.el.style.display = type
            // Need to check if max_frame is available (otherwise NaN)
            // https://github.com/jupyter-widgets/ipywidgets/issues/2485
            if (!that.model.get("max_frame") || (that.model.get("max_frame") == 0)) {
                // always hide if there's no trajectory.
                v.el.style.display = 'none'
            }
        }
    }

    updateNGLTheme(css_content) {
        var ele = document.getElementById("nglview_style")
        if (ele != undefined) {
            document.head.removeChild(ele)
        }
        var style = document.createElement("style")
        style.id = 'nglview_style'
        style.type = 'text/css'
        style.appendChild(document.createTextNode(css_content))
        document.head.appendChild(style)
    }

    serialize_camera_orientation() {
        var m = this.stage.viewerControls.getOrientation();
        this.model.set('_camera_orientation', m.elements);
        this.touch();
    }

    set_camera_orientation(orientation) {
        if (orientation.length > 0) {
            this.stage.viewerControls.orient(orientation);
            this.serialize_camera_orientation();
        }
    }

    executeCode(code) {
        eval(code);
    }

    _handleEmbedBeforeStage() {
        // Only need to reconstruct colors in embeding mode (outside notebook)
        // FIXME: remove this function
        var that = this
        var ngl_color_dict = that.model.get("_ngl_color_dict");
        var label
        // Old API (_ColorScheme)
        for (label in ngl_color_dict) {
            if (!NGL.ColormakerRegistry.hasScheme(label)) {
                that.addColorScheme(ngl_color_dict[label], label);
            }
        }
    }

    async handleEmbed() {
        var that = this;
        var ngl_msg_archive = that.model.get("_ngl_msg_archive");
        var ngl_stage_params = that.model.get('_ngl_full_stage_parameters');
        const camera_orientation = that.model.get("_camera_orientation");

        if (
            Object.keys(ngl_stage_params).length === 0
            && camera_orientation.length === 0
        ) {
            console.log("No state stored; initializing embedded widget for the first time.");
            for (const msg of ngl_msg_archive) {
                await that.on_msg(msg);
            }
            return
        }

        var loadfile_list = [];

        _.each(ngl_msg_archive, function (msg: any) {
            if (msg.methodName == 'loadFile') {
                if (msg.kwargs && msg.kwargs.defaultRepresentation) {
                    // no need to add default representation as all representations
                    // are serialized separately, also it unwantedly sets the orientation
                    msg.kwargs.defaultRepresentation = false
                }
                loadfile_list.push(that._getLoadFilePromise(msg));
            }
        });


        await Promise.all(loadfile_list)
        that.stage.setParameters(ngl_stage_params);
        that.set_camera_orientation(camera_orientation);
        that.touch();

        // Outside notebook
        if (that.model.comm === undefined) {
            var ngl_coordinate_resource = that.model.get("_ngl_coordinate_resource");
            var n_frames = ngl_coordinate_resource['n_frames'] || 1
            that.model.set("max_frame", n_frames - 1);  // trigger updating slider and player's max
            that.touch()
            var model = await that.getPlayerModel()
            var pmodel = model.get("children")[0];
            that.listenTo(pmodel,
                "change:value", function () {
                    that.updateCoordinatesFromDict(ngl_coordinate_resource,
                        pmodel.get("value"))
                })
        }


        // fire any msg with "fire_embed"
        for (const msg of that.model.get("_ngl_msg_archive")) {
            if (msg.fire_embed) {
                await that.on_msg(msg);
            }
        }

        // Must call _set_representation_from_repr_dict after "fire_embed"
        // User might add Shape (buffer component) to the view and the buffer component
        // is not created yet via loadFile
        // https://github.com/nglviewer/nglview/issues/1003
        that._set_representation_from_repr_dict(that.model.get("_ngl_repr_dict"))
        that.handleResize() // FIXME: really need this?
    }

    updateCoordinatesFromDict(cdict, frame_index) {
        // update coordinates for given "index"
        // cdict = Dict[int, List[base64]]
        var keys = Object.keys(cdict).filter(k => (k !== 'n_frames'));

        for (var i = 0; i < keys.length; i++) {
            var traj_index = parseInt(keys[i], 10);
            var coordinates = this.decode_base64(cdict[traj_index][frame_index]);
            if (coordinates && coordinates.byteLength > 0) {
                this.updateCoordinates(coordinates, traj_index);
            }
        }
    }

    requestFrame() {
        this.send({
            'type': 'request_frame',
            'data': 'frame'
        });
    }

    requestUpdateStageParameters() {
        var updated_params = this.stage.getParameters();
        this.model.set('_ngl_full_stage_parameters', updated_params);
        this.touch();
    }

    requestReprParameters(component_index, repr_index) {
        var comp = this.stage.compList[component_index];
        var repr = comp.reprList[repr_index];
        var msg = repr.repr.getParameters();

        if (msg) {
            msg['name'] = repr.name;
            this.send({
                'type': 'repr_parameters',
                'data': msg
            });
        }
    }

    request_repr_dict() {
        var repr_dict = this.getReprDictFrontEnd()
        this.send({
            // make sure we are using "request_repr_dict" name
            // in backend too.
            'type': 'request_repr_dict',
            'data': repr_dict,
        });
        var that = this
        if (that._synced_repr_model_ids.length > 0) {
            that._synced_repr_model_ids.forEach(async function (mid) {
                var model = await that.model.widget_manager.get_model(mid)
                for (var k in model.views) {
                    var view = await model.views[k];
                    // not sync with itself
                    if (view.uuid != that.uuid) {
                        view._set_representation_from_repr_dict(repr_dict)
                    }
                }
            })
        }
    }

    getReprDictFrontEnd() {
        var repr_dict = {};
        var n_components = this.stage.compList.length;
        for (var i = 0; i < n_components; i++) {
            var comp = this.stage.compList[i];
            repr_dict[i] = {};
            var msgi = repr_dict[i];
            for (var j = 0; j < comp.reprList.length; j++) {
                var repr = comp.reprList[j];
                msgi[j] = {};
                msgi[j]['type'] = repr.name;
                msgi[j]['params'] = repr.repr.getParameters();
            }
        }
        return repr_dict
    }

    syncReprForAllViews() {
        var repr_dict_backend = this.model.get("_ngl_repr_dict")
        var repr_dict_frontend = this.getReprDictFrontEnd()
        if (JSON.stringify(repr_dict_frontend) !== JSON.stringify(repr_dict_backend)) {
            this._set_representation_from_repr_dict(repr_dict_backend)
        }
    }

    async syncReprWithMe() {
        // Make sure views of the same model has the same representations
        // Only needed if we use Sidebar that connects to specific view.
        var that = this
        var repr_dict = this.getReprDictFrontEnd()
        for (var k in this.model.views) {
            var v = await this.model.views[k]
            if (v.uuid != that.uuid) {
                v._set_representation_from_repr_dict(repr_dict)
            }
        }
        this.request_repr_dict()
    }

    setSyncRepr(model_ids) {
        this._synced_repr_model_ids = model_ids
    }

    setSyncCamera(model_ids) {
        this._synced_model_ids = model_ids
    }

    viewXZPlane() {
        var m = new NGL.Matrix4().makeRotationX(Math.PI / 2);
        var q = new NGL.Quaternion().setFromRotationMatrix(m);
        this.stage.viewerControls.rotate(q);
    }

    set_representation_from_backend() {
        var repr_dict = this.model.get('_ngl_repr_dict')
        this._set_representation_from_repr_dict(repr_dict)
    }

    _set_representation_from_repr_dict(repr_dict) {
        var compList = this.stage.compList
        if (compList.length > 0) {
            for (var index in repr_dict) {
                var comp = compList[index];
                comp.removeAllRepresentations();
                var reprlist = repr_dict[index];
                for (var j in reprlist) {
                    var repr = reprlist[j];
                    if (repr) {
                        comp.addRepresentation(repr.type, repr.params);
                    }
                }
            }
        }
    }

    async createView(trait_name) {
        // Create a view for the model with given `trait_name`
        // e.g: in backend, 'view.<trait_name>`
        console.log("Creating view for model " + trait_name);
        var manager = this.model.widget_manager;
        var model_id = this.model.get(trait_name).replace("IPY_MODEL_", "");
        return await manager.create_view(await manager.get_model(model_id))
    }

    getPlayerModel() {
        // return a Promise
        var model_id = this.model.get("_iplayer").replace("IPY_MODEL_", "");
        return this.model.widget_manager.get_model(model_id)
    }

    async createIPlayer() {
        this.player_pview = this.createView("_iplayer");
        var view = await this.player_pview
        var that = this;
        var pe = view.el
        pe.style.position = 'absolute'
        pe.style.zIndex = 100
        pe.style.bottom = '5%'
        pe.style.left = '10%'
        pe.style.opacity = '0.7'
        that.stage.viewer.container.append(view.el);
        pe.style.display = 'none'
    }

    async createImageBtn() {
        this.image_btn_pview = this.createView("_ibtn_image");
        var view = await this.image_btn_pview
        var pe = view.el
        pe.style.position = 'absolute'
        pe.style.zIndex = 100
        pe.style.top = '5%'
        pe.style.right = '10%'
        pe.style.opacity = '0.7'
        pe.style.width = '35px'
        this.stage.viewer.container.append(view.el);
    }

    async createFullscreenBtn() {
        this.btn_pview_fullscreen = this.createView("_ibtn_fullscreen");
        var view = await this.btn_pview_fullscreen
        var stage = this.stage;

        var pe = view.el
        pe.style.position = 'absolute'
        pe.style.zIndex = 100
        pe.style.top = '5%'
        pe.style.right = '5%'
        pe.style.opacity = '0.7'
        pe.style.width = '35px'
        pe.style.background = 'white'
        pe.style.opacity = '0.3'
        pe.style.display = 'none'
        pe.onclick = function () {
            this.stage.toggleFullscreen();
        }.bind(this)
        stage.viewer.container.append(view.el);
        stage.signals.fullscreenChanged.add((isFullscreen) => {
            if (isFullscreen) {
                view.model.set("icon", "compress");
            } else {
                view.model.set("icon", "expand");
            }
        });
    }


    async createGUI() {
        this.pgui_view = this.createView("_igui");
        var view = await this.pgui_view
        var pe = view.el
        pe.style.position = 'absolute'
        pe.style.zIndex = 100
        pe.style.top = '5%'
        pe.style.right = '10%'
        pe.style.width = '300px'
        this.stage.viewer.container.append(view.el);
    }


    createNglGUI() {
        this.stage_widget = new StageWidget(this)
    }


    setVisibilityForRepr(component_index, repr_index, value) {
        // value = True/False
        var component = this.stage.compList[component_index];
        var repr = component.reprList[repr_index];

        if (repr) {
            repr.setVisibility(value);
        }
    }

    removeRepresentation(component_index, repr_index) {
        var component = this.stage.compList[component_index];
        var repr = component.reprList[repr_index]

        if (repr) {
            component.removeRepresentation(repr);
        }
    }

    removeRepresentationsByName = (repr_name, component_index) => {
        var component = this.stage.compList[component_index];

        if (component) {
            component.reprList.forEach((repr) => {
                if (repr.name == repr_name) {
                    component.removeRepresentation(repr);
                }
            });
        }
    }

    updateRepresentationForComponent = (repr_index, component_index, params) => {
        var component = this.stage.compList[component_index];
        var repr = component.reprList[repr_index];
        if (repr) {
            repr.setParameters(params);
        }
    }

    updateRepresentationsByName = (repr_name: string, component_index: number, params: any): void => {
        const component = this.stage.compList[component_index];

        if (component) {
            component.reprList.forEach((repr: any) => {
                if (repr.name === repr_name) {
                    repr.setParameters(params);
                    this.request_repr_dict();
                }
            });
        }
    }

    setColorByResidue = (colors, component_index, repr_index) => {
        var repr = this.stage.compList[component_index].reprList[repr_index];
        var schemeId = NGL.ColormakerRegistry.addScheme((params) => {
            this.atomColor = (atom) => {
                var color = colors[atom.residueIndex];
                return color;
            };
            params; // to pass eslint; ack;
        });
        repr.setColor(schemeId);
    }

    addShape(name, shapes) {
        // shapes: List[Tuple[str, ...]]
        // e.g: [('sphere', ...), ('cone', ...)]
        var shape = new NGL.Shape(name);
        var shape_dict = {
            'sphere': shape.addSphere,
            'ellipsoid': shape.addEllipsoid,
            'cylinder': shape.addCylinder,
            'cone': shape.addCone,
            'mesh': shape.addMesh,
            'arrow': shape.addArrow,
            'text': shape.addText,
            'label': shape.addText,
            'tetrahedron': shape.addTetrahedron,
            'octahedron': shape.addOctahedron,
            'torus': shape.addTorus
        };
        for (var i = 0; i < shapes.length; i++) {
            var shapes_i = shapes[i]
            var shape_type = shapes_i[0];
            var params = shapes_i.slice(1, shapes_i.length);
            // e.g params = ('sphere', [ 0, 0, 9 ], [ 1, 0, 0 ], 1.5)

            var func = shape_dict[shape_type];
            func.apply(shape, params);
            // shape.func(params);
        }
        var shapeComp = this.stage.addComponentFromObject(shape);
        if (shapeComp) {
            shapeComp.addRepresentation("buffer", {});
        }
    }

    addBuffer(name, kwargs) {
        var class_dict = {
            "arrow": NGL.ArrowBuffer,
            "box": NGL.BoxBuffer,
            "cone": NGL.ConeBuffer,
            "cylinder": NGL.CylinderBuffer,
            "ellipsoid": NGL.EllipsoidBuffer,
            "octahedron": NGL.OctahedronBuffer,
            "sphere": NGL.SphereBuffer,
            "text": NGL.TextBuffer,
            "tetrahedron": NGL.TetrahedronBuffer,
            "torus": NGL.TorusBuffer
        };

        var params = {};
        for (var key in kwargs) {
            params[key] = new Float32Array(kwargs[key]);
        }
        var shape = new NGL.Shape("shape");
        var buffer_class = class_dict[name];
        var buffer = new buffer_class(params);
        shape.addBuffer(buffer);
        var shapeComp = this.stage.addComponentFromObject(shape);
        if (shapeComp) {
            shapeComp.addRepresentation("buffer", {});
        }
    }

    async replaceStructure(structure) {
        var blob = new Blob([structure.data], { type: "text/plain" });
        var stage = this.stage;
        var params = structure.params || {};
        params.ext = structure.ext;
        params.defaultRepresentation = false;
        var comp = this.stage.compList[0];
        var representations = comp.reprList.slice();
        var old_orientation = this.stage.viewerControls.getOrientation();
        var component = await this.stage.loadFile(blob, params);
        stage.viewerControls.orient(old_orientation);
        representations.forEach((repr) => {
            var repr_name = repr.name;
            var repr_params = repr.repr.getParameters();
            // Note: not using repr.repr.type, repr.repr.params
            // since seems to me that repr.repr.params won't return correct "sele"
            if (component) {
                component.addRepresentation(repr_name, repr_params);
            }
        });
        stage.removeComponent(comp);
        this._handleLoadFileFinished();
    }

    superpose(cindex0, cindex1, align, sele0, sele1) {
        // superpose two components with given params
        var component0 = this.stage.compList[cindex0];
        var component1 = this.stage.compList[cindex1];
        (component1 as NGL.StructureComponent).superpose(component0 as NGL.StructureComponent, align, sele0, sele1);
    }

    decode_base64(base64) {
        // lightly adapted from Niklas

        /*
         * base64-arraybuffer
         * https://github.com/niklasvh/base64-arraybuffer
         *
         * Copyright (c) 2012 Niklas von Hertzen
         * Licensed under the MIT license.
         */
        var chars =
            "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
        var bufferLength = base64.length * 0.75,
            len = base64.length,
            i, p = 0,
            encoded1, encoded2, encoded3, encoded4;

        if (base64[base64.length - 1] === "=") {
            bufferLength--;
            if (base64[base64.length - 2] === "=") {
                bufferLength--;
            }
        }

        var arraybuffer = new ArrayBuffer(bufferLength),
            bytes = new Uint8Array(arraybuffer);

        for (i = 0; i < len; i += 4) {
            encoded1 = chars.indexOf(base64[i]);
            encoded2 = chars.indexOf(base64[i + 1]);
            encoded3 = chars.indexOf(base64[i + 2]);
            encoded4 = chars.indexOf(base64[i + 3]);

            bytes[p++] = (encoded1 << 2) | (encoded2 >> 4);
            bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
            bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63);
        }

        return arraybuffer;
    }

    updateCoordinates(coordinates: ArrayBuffer, model: number) {
        // coordinates must be ArrayBuffer (use this.decode_base64)
        var component = this.stage.compList[model];
        if (coordinates && component) {
            var coords = new Float32Array(coordinates);
            (component as NGL.StructureComponent).structure.updatePosition(coords);
            component.updateRepresentations({
                "position": true
            });
        }
    }

    handleResizable() {
        this.$container.resizable({
            resize: function (event, ui) {
                this.setSize(ui.size.width + "px", ui.size.height + "px");
            }.bind(this)
        })
    }

    handleResize() {
        var width = this.$el.width()
        var height = this.$el.height() + "px"
        if (this.stage_widget) {
            width = width - $(this.stage_widget.sidebar.dom).width()
        }
        width = width + "px"
        this.setSize(width, height)
    }

    setSize(width, height) {
        this.stage.viewer.container.style.width = width;
        this.stage.viewer.container.style.height = height;
        this.stage.handleResize();
    }

    GUIStyleChanged() {
        var style = this.model.get("gui_style");
        if (style === 'ngl') {
            this.createNglGUI();
        } else {
            if (this.stage_widget) {
                this.stage_widget.dispose()
                this.stage_widget = undefined
                this.$container.resizable("enable")
                var width = this.$el.parent().width() + "px";
                var height = this.$el.parent().height() + "px";
                this.setSize(width, height);
            }
        }
    }

    parametersChanged() {
        var _parameters = this.model.get("_parameters");
        this.setParameters(_parameters);
    }

    setParameters(parameters) {
        this.stage.setParameters(parameters);

        // do not set _ngl_full_stage_parameters here
        // or parameters will be never updated (not sure why)
        // use observe in python side
        var updated_params = this.stage.getParameters();
        this.send({
            'type': 'stage_parameters',
            'data': updated_params
        })
    }

    async _downloadImage(filename, params) {
        if (this.ngl_view_id == this.get_last_child_id()) {
            var blob = await this.stage.makeImage(params)
            NGL.download(blob, filename);
        }
    }

    async _exportImage(wid, params) {
        if (this.ngl_view_id == this.get_last_child_id()) {
            var blob = await this.stage.makeImage(params)
            var reader = new FileReader();
            var arr_str;
            reader.onload = function () {
                arr_str = (reader.result as string).replace("data:image/png;base64,", "");
                // this.model.set("_image_data", arr_str);
                // this.touch();
                this.send({
                    "data": arr_str,
                    "type": "image_data",
                    "ID": wid,
                });
                this.send({ 'type': 'async_message', 'data': 'ok' });
            }.bind(this);
            reader.readAsDataURL(blob);
        }
    }

    async handleMovieMaking(render_params) {
        console.log('handleMovieMaking: render_params', render_params)
        if (this.ngl_view_id == this.get_last_child_id()) {
            var blob = await this.stage.makeImage(render_params)
            var reader = new FileReader();
            var arr_str;
            reader.onload = function () {
                arr_str = (reader.result as string).replace("data:image/png;base64,", "");
                this.send({
                    "data": arr_str,
                    "type": "movie_image_data",
                }); // tell backend that image render is finished,
                // backend will send next frame's coordinates.
                this.send({ 'type': 'async_message', 'data': 'ok' });
            }.bind(this);
            reader.readAsDataURL(blob);
        }
    }

    async _handleLoadFileFinished() {
        this.send({ 'type': 'async_message', 'data': 'ok' });
    }

    _getLoadFilePromise(msg) {
        // args = [{'type': ..., 'data': ...}]
        var args0 = msg.args[0];
        if (args0.type == 'blob') {
            var blob;
            if (args0.binary) {
                var decoded_data = this.decode_base64(args0.data);
                blob = new Blob([decoded_data], {
                    type: "application/octet-binary"
                });
            } else {
                blob = new Blob([args0.data], {
                    type: "text/plain"
                });
            }
            return this.stage.loadFile(blob, msg.kwargs)
        } else {
            var file = new File([""], args0.data);
            // FIXME: if not "any", typescipt complains there is no
            // "exists" method.
            var path = "";
            // FIXME: uncomment ("exists" does not exist)
            // if (args0.type == 'path' && ! file.exists){
            // hacky fix for jupyterlab
            // https://github.com/arose/nglview/issues/783
            // https://github.com/jupyterlab/jupyterlab/issues/6218
            // e.g: http://localhost:8888/files/ala3.pdb
            //    path = 'files/' + args0.data;
            // } else {
            // path = args0.data;
            //}
            path = args0.data;
            return this.stage.loadFile(path, msg.kwargs)
        }
    }

    get_last_child_id() {
        var keys = this.model.get('_ngl_view_id')
        return keys[keys.length - 1]
    }

    async _handleStageLoadFile(msg) {
        // args = [{'type': ..., 'data': ...}]
        if (this.ngl_view_id != this.get_last_child_id() && msg.last_child) {
            return
        }
        var o = await this._getLoadFilePromise(msg)
        this._handleLoadFileFinished();
    }

    addColorScheme(args, label) {
        var id = NGL.ColormakerRegistry.addSelectionScheme(args, label);
        var scheme = NGL.ColormakerRegistry.userSchemes[id];
        NGL.ColormakerRegistry.removeScheme(id);
        // hard code the scheme ID
        NGL.ColormakerRegistry.add(label, scheme);
        return label
    }

    async on_msg(msg) {
        if (msg.type === 'call_method') {
            await this.handleCallMethod(msg);
        } else if (msg.type === 'base64_single') {
            this.handleBase64Single(msg.data);
        } else if (msg.type === 'binary_single') {
            this.handleBinarySingle(msg);
        } else if (msg.type === 'movie_image_data') {
            this.handleMovieMaking(msg.render_params);
        } else if (msg.type === 'get') {
            this.handleGetRequest(msg.data);
        }
    }

    async handleCallMethod(msg) {
        var index, component, func, stage;
        var new_args = msg.args.slice();
        new_args.push(msg.kwargs);

        if (msg.methodName === 'addRepresentation' && msg.reconstruc_color_scheme) {
            msg.kwargs.color = this.addColorScheme(msg.kwargs.color, msg.kwargs.color_label);
        }

        // FIXME:  Property 'volume' does not exist on type 'Component'.
        // if ("colorVolume" in msg.kwargs) {
        //     index = msg.kwargs["colorVolume"];
        //     msg.kwargs["colorVolume"] = this.stage.compList[index].volume;
        // }

        switch (msg.target) {
            case 'Stage':
                await this.handleStageMethod(msg, new_args);
                break;
            case 'Viewer':
                this.stage.viewer[msg.methodName].apply(this.stage.viewer, new_args);
                break;
            case 'viewerControls':
                this.stage.viewerControls[msg.methodName].apply(this.stage.viewerControls, new_args);
                break;
            case 'compList':
                component = this.stage.compList[msg.component_index];
                component[msg.methodName].apply(component, new_args);
                break;
            case 'Widget':
                this[msg.methodName].apply(this, new_args);
                break;
            case 'Representation':
                component = this.stage.compList[msg.component_index];
                var repr = component.reprList[msg.repr_index];
                repr[msg.methodName].apply(repr, new_args);
                break;
            default:
                console.log('Unknown target: ' + msg.target);
                break;
        }
    }

    async handleStageMethod(msg, new_args) {
        var stage_func = this.stage[msg.methodName];
        if (msg.methodName === 'removeComponent') {
            var component = this.stage.compList[msg.args[0]];
            this.stage.removeComponent(component);
        } else if (msg.methodName === 'loadFile') {
            if (this.model.views.length > 1 && msg.kwargs && msg.kwargs.defaultRepresentation) {
                msg.kwargs.defaultRepresentation = false;
            }
            await this._handleStageLoadFile(msg);
        } else {
            stage_func.apply(this.stage, new_args);
        }
    }

    handleBase64Single(coordinatesDict) {
        var keys = Object.keys(coordinatesDict);
        for (var i = 0; i < keys.length; i++) {
            var traj_index = parseInt(keys[i], 10);
            var coordinates = this.decode_base64(coordinatesDict[traj_index]);
            if (coordinates && coordinates.byteLength > 0) {
                this.updateCoordinates(coordinates, traj_index);
            }
        }
    }

    handleBinarySingle(msg) {
        var coordinateMeta = msg.data;
        var keys = Object.keys(coordinateMeta);
        for (var i = 0; i < keys.length; i++) {
            var traj_index = parseInt(keys[i], 10);
            var coordinates = new Float32Array(msg.buffers[i].buffer);
            if (coordinates.byteLength > 0) {
                this.updateCoordinates(coordinates, traj_index);
            }
        }
        if (msg.movie_making) {
            this.handleMovieMaking(msg.render_params);
        }
    }

    handleGetRequest(data) {
        if (data === 'camera') {
            this.send(JSON.stringify(this.stage.viewer.camera));
        } else if (data === 'parameters') {
            this.send(JSON.stringify(this.stage.parameters));
        } else {
            console.log("Number of components", this.stage.compList.length);
            console.log("ngl_view_id", this.ngl_view_id);
        }
    }
}

// export all models and views here to make embeding a bit easier
module.exports = {
    'NGLView': NGLView,
    'NGLModel': NGLModel,
    'NGL': NGL,
    'FullscreenModel': FullscreenModel,
    'FullscreenView': FullscreenView,
    'ColormakerRegistryModel': ColormakerRegistryModel,
    'ColormakerRegistryView': ColormakerRegistryView,
    'ThemeManagerModel': ThemeManagerModel,
    'ThemeManagerView': ThemeManagerView,
    'MolstarModel': MolstarModel,
    'MolstarView': MolstarView,
}
