(function (ext) {

    var device = null;

    var motors = [
        new Motor(0),
        new Motor(1)
    ];

    var sensors = {
        tiltX: 0,
        tiltY: 0,
        distance: 0
    };

    ext.motorOnFor = function (motorId, time, callback) {
        var milliseconds = 1000 * time;
        // Tell each motor to turn on for `time`
        forEachMotor(motorId, function (motor) {
            motor.cancelMotorTimeout();
            motor.setMotorOnFor(milliseconds);
        });
        // This block runs for a fixed amount of time, even if the motors end up getting interrupted by another block
        setTimeout(function () {
            if (callback) callback();
        }, milliseconds);
    };

    ext.motorOn = function (motorId) {
        forEachMotor(motorId, function (motor) {
            motor.cancelMotorTimeout();
            motor.setMotorOn();
        });
    };

    ext.motorOff = function (motorId) {
        forEachMotor(motorId, function (motor) {
            motor.cancelMotorTimeout();
            motor.startBraking();
        });
    };

    ext.startMotorPower = function (motorId, power) {
        power = Math.max(0, Math.min(power, 100));
        forEachMotor(motorId, function (motor) {
            motor.power = power;
        });
        ext.motorOn(motorId);
    };

    ext.setMotorDirection = function (motorId, direction) {
        forEachMotor(motorId, function (motor) {
            switch (direction) {
            case strings.DIR_FORWARD:
                motor.dir = 1;
                break;
            case strings.DIR_BACK:
                motor.dir = -1;
                break;
            case strings.DIR_REV:
                motor.dir = -motor.dir;
                break;
            default:
                console.log('Unknown motor direction: ' + direction);
                break;
            }
            if (motor.isOn) {
                // change direction immediately, without altering power or timeout state
                motor.setMotorOn();
            }
        });
    };

    ext.setLED = function (hue) {
        if (device) {
            // Change from [0,100] range to [0,360] range
            hue = hue * 360 / 100;

            var rgbArray = HSVToRGB(hue, 1, 1);

            var r = Math.floor(rgbArray[0] * 255);
            var g = Math.floor(rgbArray[1] * 255);
            var b = Math.floor(rgbArray[2] * 255);

            // Form hexadecimal number: 0xRRGGBB
            var rgbNumber = (((r << 8) | g) << 8) | b;

            device.set_led(rgbNumber);
        }
    };

    ext.playNote = function (note, duration, callback) {
        var durationMs = duration * 1000;
        if (device) {
            // TODO: offer music helpers to extensions:
            // - convert beats to duration
            // - convert note number to frequency

            var tone = noteToTone(note);
            device.play_tone(tone, durationMs);
        }
        // Keep disconnected behavior similar to connected behavior by delaying the callback even with no device.
        setTimeout(callback, durationMs);
    };

    ext.stopNote = function () {
        if (device) {
            device.stop_tone();
        }
    };

    ext.whenDistance = function (op, reference) {
        if (device) {
            switch (op) {
            case strings.COMP_LESS:
                return ext.getDistance() < reference;
            case strings.COMP_MORE:
                return ext.getDistance() > reference;
            default:
                console.log('Unknown operator in whenDistance: ' + op);
            }
        }
        return false;
    };

    ext.getDistance = function () {
        return device ? sensors.distance * 10 : 0;
    };

    ext.isTilted = function (tiltDirAny) {
        if (device) {
            var threshold = 15;
            // TODO: share code with getTilt
            switch(tiltDirAny) {
            case strings.TILT_ANY:
                return (Math.abs(sensors.tiltX) >= threshold) || (Math.abs(sensors.tiltY) >= threshold);
            case strings.TILT_UP:
                return -sensors.tiltY > threshold;
            case strings.TILT_DOWN:
                return sensors.tiltY > threshold;
            case strings.TILT_LEFT:
                return -sensors.tiltX > threshold;
            case strings.TILT_RIGHT:
                return sensors.tiltX > threshold;
            }
        }
        return false;
    };

    // Each block must have a unique function name even if the implementation is identical.
    ext.whenTilted = ext.isTilted;

    ext.getTilt = function (tiltDir) {
        var tiltValue;
        switch(tiltDir) {
        case strings.TILT_UP:
            tiltValue = -sensors.tiltY;
            break;
        case strings.TILT_DOWN:
            tiltValue = sensors.tiltY;
            break;
        case strings.TILT_LEFT:
            tiltValue = -sensors.tiltX;
            break;
        case strings.TILT_RIGHT:
            tiltValue = sensors.tiltX;
            break;
        default:
            console.log('Unknown tilt direction in getTilt: ' + tiltDir);
            tiltValue = 0;
            break;
        }
        return tiltValue;
    };

    function forEachMotor (motorId, motorFunction) {
        var motorIndices;
        switch (motorId) {
        case strings.MOTOR_A:
            motorIndices = [0];
            break;
        case strings.MOTOR_B:
            motorIndices = [1];
            break;
        case strings.MOTOR_DEFAULT:
        case strings.MOTOR_ALL:
            motorIndices = [0, 1];
            break;
        default:
            console.log('Invalid motor ID');
            motorIndices = [];
            break;
        }
        var numMotors = motorIndices.length;
        for (var i = 0; i < numMotors; ++i) {
            motorFunction(motors[motorIndices[i]]);
        }
    }

    function onSensorChanged (event) {
        sensors[event.sensorName] = event.sensorValue;
    }

    function clamp (val, min, max) {
        return Math.max(min, Math.min(val, max));
    }

    // See https://en.wikipedia.org/wiki/HSL_and_HSV#From_HSV
    // Returns an array of [R, G, B] where each component is in the range [0,1]
    function HSVToRGB (hueDegrees, saturation, value) {
        hueDegrees %= 360;
        if (hueDegrees < 0) hueDegrees += 360;
        saturation = clamp(saturation, 0, 1);
        value = clamp(value, 0, 1);

        var chroma = value * saturation;
        var huePrime = hueDegrees / 60;
        var x = chroma * (1 - Math.abs(huePrime % 2 - 1));
        var rgb;
        switch(Math.floor(huePrime)) {
        case 0:
            rgb = [chroma, x, 0];
            break;
        case 1:
            rgb = [x, chroma, 0];
            break;
        case 2:
            rgb = [0, chroma, x];
            break;
        case 3:
            rgb = [0, x, chroma];
            break;
        case 4:
            rgb = [x, 0, chroma];
            break;
        case 5:
            rgb = [chroma, 0, x];
            break;
        }

        var m = value - chroma;
        rgb[0] += m;
        rgb[1] += m;
        rgb[2] += m;

        return rgb;
    }

    function noteToTone (note) {
        return 440 * Math.pow(2, (note - 69) / 12); // midi key 69 is A (440 Hz)
    }

    ext._deviceConnected = function (dev) {
        if (device) return;

        device = dev;
        device.open(function (d) {
            if (device == d) {
                device.set_sensor_handler(onSensorChanged);
            }
            else if (d) {
                console.log('Received open callback for wrong device');
            }
            else {
                console.log('Opening device failed');
                device = null;
            }
        });
    };

    ext._deviceRemoved = function (dev) {
        if (device != dev) return;
        device = null;
    };

    ext._stop = function () {
        if (device) {
            device.stop_tone();
            forEachMotor(strings.MOTOR, function (motor) {
                motor.cancelMotorTimeout();
                motor.setMotorOff();
            });
        }
    };

    ext._shutdown = function () {
        if (device) {
            ext._stop();
            device.close();
            device = null;
        }
    };

    ext._getStatus = function () {
        if (device) {
            if (device.is_open()) {
                return {status: 2, msg: 'LEGO WeDo 2.0 connected'};
            }
            else {
                return {status: 1, msg: 'LEGO WeDo 2.0 connecting...'};
            }
        }
        else {
            return {status: 1, msg: 'LEGO WeDo 2.0 disconnected'};
        }
    };

    var strings = {
        MOTOR_DEFAULT: 'motor',
        MOTOR_A: 'motor A',
        MOTOR_B: 'motor B',
        MOTOR_ALL: 'all motors',
        DIR_FORWARD: 'this way',
        DIR_BACK: 'that way',
        DIR_REV: 'reverse',
        TILT_UP: 'up',
        TILT_DOWN: 'down',
        TILT_LEFT: 'left',
        TILT_RIGHT: 'right',
        TILT_ANY: 'any',
        COMP_LESS: '<',
        COMP_MORE: '>',
        COMP_EQ: '=',
        COMP_NEQ: 'not ='
    };

    var descriptor = {
        blocks: [
            ['w', 'turn %m.motor on for %n secs', 'motorOnFor', strings.MOTOR_DEFAULT, 1],
            [' ', 'turn %m.motor on', 'motorOn', strings.MOTOR_DEFAULT],
            [' ', 'turn %m.motor off', 'motorOff', strings.MOTOR_DEFAULT],
            [' ', 'set %m.motor power to %n', 'startMotorPower', strings.MOTOR_DEFAULT, 100],
            [' ', 'set %m.motor direction to %m.motorDir', 'setMotorDirection', strings.MOTOR_DEFAULT, strings.DIR_FORWARD],
            [' ', 'set light color to %n', 'setLED', 50],
            ['w', 'play note %d.note for %n seconds', 'playNote', 60, 0.5],
            ['h', 'when distance %m.lessMore %n', 'whenDistance', strings.COMP_LESS, 50],
            ['h', 'when tilted %m.tiltDirAny', 'whenTilted', strings.TILT_ANY],
            ['r', 'distance', 'getDistance'],
            ['b', 'tilted %m.tiltDirAny ?', 'isTilted', strings.TILT_ANY],
            ['r', 'tilt angle %m.tiltDir', 'getTilt', strings.TILT_UP]
        ],
        menus: {
            motor: [strings.MOTOR_DEFAULT, strings.MOTOR_A, strings.MOTOR_B, strings.MOTOR_ALL],
            motorDir: [strings.DIR_FORWARD, strings.DIR_BACK, strings.DIR_REV],
            tiltDir: [strings.TILT_UP, strings.TILT_DOWN, strings.TILT_LEFT, strings.TILT_RIGHT],
            tiltDirAny: [strings.TILT_ANY, strings.TILT_UP, strings.TILT_DOWN, strings.TILT_LEFT, strings.TILT_RIGHT],
            lessMore: [strings.COMP_LESS, strings.COMP_MORE],
            eNe: [strings.COMP_EQ, strings.COMP_NEQ]
        },
        url: '/info/help/studio/tips/ext/LEGO WeDo 2/'
    };
    ScratchExtensions.register('LEGO WeDo 2.0', descriptor, ext, { type: 'wedo2' });

    function Motor (motorIndex) {
        var motor = this;

        // Motor power: 0 to 100
        motor.power = 100;

        // Motor direction: 1 for "this way" or -1 for "that way"
        motor.dir = 1;

        // Is the motor currently on (not braking or drifting)?
        motor.isOn = false;

        // Pending timeout set by motorOnFor() or startBraking()
        motor.pendingTimeoutId = null;

        motor.setMotorOn = function () {
            if (device) {
                device.set_motor_on(motorIndex, motor.power * motor.dir);
                motor.isOn = true;
            }
        };

        motor.setMotorOnFor = function (milliseconds) {
            if (device) {
                motor.setMotorOn();
                motor.pendingTimeoutId = setTimeout(motor.startBraking, milliseconds);
            }
        };

        // Turn on the brake now, then turn the motor completely off in a bit to save battery
        var motorBrakeTime = 1000; // milliseconds
        motor.startBraking = function () {
            if (device) {
                device.set_motor_brake(motorIndex);
                motor.isOn = false;
                motor.pendingTimeoutId = setTimeout(motor.setMotorOff, motorBrakeTime);
            }
        };

        // Turn the motor off and forget the timeout ID
        motor.setMotorOff = function () {
            if (device) {
                device.set_motor_off(motorIndex);
                motor.isOn = false;

                motor.pendingTimeoutId = null;
            }
        };

        // If there's a pending timeout (off/break or on/off/break sequence) for the given motor, cancel it
        motor.cancelMotorTimeout = function () {
            if (device) {
                if (motor.pendingTimeoutId !== null) {
                    clearTimeout(motor.pendingTimeoutId);
                    motor.pendingTimeoutId = null;
                }
            }
        };
    }
})({});