const ArgumentType = require('../../extension-support/argument-type');
const BlockType = require('../../extension-support/block-type');
const Cast = require('../../util/cast');
const log = require('../../util/log');
const Base64Util = require('../../util/base64-util');
const BTSession = require('../../io/btSession');

// TODO: Refactor/rename all these high level primitives to be clearer/match

/**
 * High-level primitives / constants used by the extension.
 * @type {object}
 */
const BTCommand = {
    LAYER: 0x00,
    NUM8: 0x81,
    NUM16: 0x82,
    NUM32: 0x83,
    COAST: 0x0,
    BRAKE: 0x1,
    LONGRAMP: 50,
    STEPSPEED: 0xAE,
    TIMESPEED: 0xAF,
    OUTPUTSTOP: 0xA3,
    OUTPUTRESET: 0xA2,
    STEPSPEEDSYNC: 0xB0,
    TIMESPEEDSYNC: 0xB1
};

/**
 * Array of accepted motor ports.
 * @note These should not be translated as they correspond to labels on
 *       the EV3 hub.
 * @type {array}
 */
const MOTOR_PORTS = [
    {
        name: 'A',
        value: 1
    },
    {
        name: 'B',
        value: 2
    },
    {
        name: 'C',
        value: 4
    },
    {
        name: 'D',
        value: 8
    }
];

/**
 * Array of accepted sensor ports.
 * @note These should not be translated as they correspond to labels on
 *       the EV3 hub.
 * @type {array}
 */
const SENSOR_PORTS = [
    {
        name: '1',
        value: 1
    },
    {
        name: '2',
        value: 2
    },
    {
        name: '3',
        value: 3
    },
    {
        name: '4',
        value: 4
    }
];

// firmware pdf page 100
const EV_DEVICE_TYPES = {
    29: 'color',
    30: 'ultrasonic',
    32: 'gyro',
    16: 'touch',
    8: 'mediumMotor',
    7: 'largeMotor',
    126: 'none'
};

// firmware pdf page 100?
const EV_DEVICE_MODES = {
    touch: 0,
    color: 1,
    ultrasonic: 1
};

const EV_DEVICE_LABELS = {
    touch: 'button',
    color: 'brightness',
    ultrasonic: 'distance'
};


class EV3 {

    constructor (runtime, extensionId) {

        /**
         * The Scratch 3.0 runtime used to trigger the green flag button.
         * @type {Runtime}
         * @private
         */
        this._runtime = runtime;

        /**
         * EV3 State
         */
        this.connected = false;
        this.speed = 50;
        this._sensors = {
            distance: 0,
            brightness: 0
        };
        this._motorPositions = {
            1: 0,
            2: 0,
            4: 0,
            8: 0
        };
        this._sensorPorts = [];
        this._motorPorts = [];
        this._sensorPortsWaiting = [false, false, false, false];
        this._motorPortsWaiting = [false, false, false, false];
        this._pollingIntervalID = null;

        /**
         * The Bluetooth connection session for reading/writing device data.
         * @type {BTSession}
         * @private
         */
        this._bt = null;
        this._runtime.registerExtensionDevice(extensionId, this);
    }

    // TODO: keep here?
    /**
     * Called by the runtime when user wants to scan for a device.
     */
    startDeviceScan () {
        this._bt = new BTSession(this._runtime, {
            majorDeviceClass: 8,
            minorDeviceClass: 1
        }, this._onSessionConnect.bind(this), this._onSessionMessage.bind(this));
    }

    // TODO: keep here?
    /**
     * Called by the runtime when user wants to connect to a certain device.
     * @param {number} id - the id of the device to connect to.
     */
    connectDevice (id) {
        this._bt.connectDevice(id);
    }

    // TODO: keep here?
    /**
     * Called by the runtime when user wants to disconnect from the device.
     */
    disconnectSession () {
        this._bt.disconnectSession();
        window.clearInterval(this._pollingIntervalID); // TODO: window?
        this._sensorPorts = [];
        this._motorPorts = [];
    }

    /**
     * Called by the runtime to detect whether the device is connected.
     * @return {boolean} - the connected state.
     */
    getPeripheralIsConnected () {
        let connected = false;
        if (this._bt) {
            connected = this._bt.getPeripheralIsConnected();
        }
        return connected;
    }

    get distance () {
        if (!this.connected) return 0;

        // https://shop.lego.com/en-US/EV3-Ultrasonic-Sensor-45504
        // Measures distances between one and 250 cm (one to 100 in.)
        // Accurate to +/- 1 cm (+/- .394 in.)
        let value = this._sensors.distance > 100 ? 100 : this._sensors.distance;
        value = value < 0 ? 0 : value;

        return Math.round(value);
    }

    get brightness () {
        if (!this.connected) return 0;

        return this._sensors.brightness;
    }

    getMotorPosition (port) {
        if (!this.connected) return;

        return this._motorPositions[port];
    }

    isButtonPressed (/* args */) {
        if (!this.connected) return;

        return this._sensors.button;
    }

    beep () {
        if (!this.connected) return;

        this._bt.sendMessage({
            message: 'DwAAAIAAAJQBgQKC6AOC6AM=',
            encoding: 'base64'
        });
    }

    motorTurnClockwise (port, time) {
        if (!this.connected) return;

        // Build up motor command
        const cmd = this._applyPrefix(0, this._motorCommand(
            BTCommand.TIMESPEED,
            port,
            time,
            this.speed,
            BTCommand.LONGRAMP
        ));

        // Send message
        this._bt.sendMessage({
            message: Base64Util.arrayBufferToBase64(cmd),
            encoding: 'base64'
        });

        // Yield for time
        return new Promise(resolve => {
            setTimeout(() => {
                resolve();
            }, time);
        });
    }

    motorTurnCounterClockwise (port, time) {
        if (!this.connected) return;

        // Build up motor command
        const cmd = this._applyPrefix(0, this._motorCommand(
            BTCommand.TIMESPEED,
            port,
            time,
            this.speed * -1,
            BTCommand.LONGRAMP
        ));

        // Send message
        this._bt.sendMessage({
            message: Base64Util.arrayBufferToBase64(cmd),
            encoding: 'base64'
        });

        // Yield for time
        return new Promise(resolve => {
            setTimeout(() => {
                resolve();
            }, time);
        });
    }

    motorRotate (port, degrees) {
        if (!this.connected) return;

        // TODO: Build up motor command
        log.info(`motor rotate port: ${port} and degrees: ${degrees}`);
    }

    motorSetPosition (port, degrees) {
        if (!this.connected) return;

        // TODO: Build up motor command
        log.info(`motor set position port: ${port} and degrees: ${degrees}`);
    }

    motorSetPower (port, power) {
        if (!this.connected) return;

        // TODO: Build up motor command
        log.info(`motor set power port: ${port} and degrees: ${power}`);
    }

    _applyPrefix (n, cmd) {
        const len = cmd.length + 5;
        return [].concat(
            len & 0xFF,
            (len >> 8) & 0xFF,
            0x1,
            0x0,
            0x0,
            n,
            0x0,
            cmd
        );
    }

    /**
     * Generate a motor command in EV3 byte array format (CMD, LAYER, PORT,
     * SPEED, RAMP UP, RUN, RAMP DOWN, BREAKING TYPE)
     * @param  {string} command Motor command primitive (i.e. "prefix")
     * @param  {string} port    Port to address
     * @param  {number} n       Value to be passed to motor command
     * @param  {number} speed   Speed value
     * @param  {number} ramp    Ramp value
     * @return {array}          Byte array
     */
    _motorCommand (command, port, n, speed, ramp) {
        /**
         * Generate run values for a given input.
         * @param  {number} run Run input
         * @return {array}      Run values (byte array)
         */
        const getRunValues = function (run) {
            // If run duration is less than max 16-bit integer
            if (run < 0x7fff) {
                return [
                    BTCommand.NUM16,
                    run & 0xff,
                    (run >> 8) & 0xff
                ];
            }

            // Run forever
            return [
                BTCommand.NUM32,
                run & 0xff,
                (run >> 8) & 0xff,
                (run >> 16) & 0xff,
                (run >> 24) & 0xff
            ];
        };

        // If speed is less than zero, make it positive and multiply the input
        // value by -1
        if (speed < 0) {
            speed = -1 * speed;
            n = -1 * n;
        }

        // If the input value is less than 0
        const dir = (n < 0) ? 0x100 - speed : speed; // step negative or possitive
        n = Math.abs(n);

        // Setup motor run duration and ramping behavior
        let rampup = ramp;
        let rampdown = ramp;
        let run = n - (ramp * 2);
        if (run < 0) {
            rampup = Math.floor(n / 2);
            run = 0;
            rampdown = n - rampup;
        }

        // Generate motor command
        const runcmd = getRunValues(run);
        return [
            command,
            BTCommand.LAYER,
            port,
            BTCommand.NUM8,
            dir & 0xff,
            BTCommand.NUM8,
            rampup
        ].concat(runcmd.concat([
            BTCommand.NUM8,
            rampdown,
            BTCommand.BRAKE
        ]));
    }

    _onSessionConnect () {
        this.connected = true;

        // GET EV3 SENSOR LIST
        /*
        0B [ 11]
        00 [  0]
        01 [  1]
        00 [  0]
        00 [  0]
        21 [ 33]
        00 [  0]
        98 [152] opInput_Device_List
        81 [129] LENGTH
        21 [ 33] ARRAY
        60 [ 96] CHANGED
        E1 [225] size of global var?
        20 [ 32] global var index
        */
        this._bt.sendMessage({
            message: 'CwABAAAhAJiBIWDhIA==', // [11, 0, 1, 0, 0, 33, 0, 152, 129, 33, 96, 225, 32]
            encoding: 'base64'
        }).then(
            x => {
                log.info(`get device list resolved: ${x}`);
            },
            e => {
                log.info(`get device list rejected: ${e}`);
            }
        );
    }

    _getSessionData () {
        if (!this.connected) {
            window.clearInterval(this._pollingIntervalID);
            return;
        }

        // GET EV3 DISTANCE PORT 0
        /*
        99 [153] input device
        1D [ 29] ready si
        00 [  0] layer (this brick)
        00 [  0] sensor port 0
        00 [  0] do not change type
        01 [  1] mode 1 = EV3-Ultrasonic-Inch
        01 [  1] one data set
        60 [ 96] global var index
        */
        /*
        this._bt.sendMessage({
            message: 'DQAAAAAEAJkdAAAAAQFg', // [13, 0, 0, 0, 0, 4, 0, 153, 29, 0, 0, 0, 1, 1, 96]
            encoding: 'base64'
        });
        */

        // GET EV3 BRIGHTNESS PORT 1
        /*
        0x99 [153] input device
        0x1D [ 29] ready si
        0x00 [  0] layer (this brick)
        0x01 [  1] sensor port 1
        0x00 [  0] do not change type
        0x01 [  1] mode 1 = EV3-Color-Ambient
        0x01 [  1] one data set
        0x60 [ 96] global var index
        */
        /*
        this._bt.sendMessage({
            message: 'DQAAAAAEAJkdAAEAAQFg', // [13, 0, 0, 0, 0, 4, 0, 153, 29, 0, 1, 0, 1, 1, 96]
            encoding: 'base64'
        });
        */


        // COMPOUND COMMAND FOR READING sensors0x27   command size
        // 0x??  [    ]   command size
        // 0x00  [   0]   command size
        // 0x01  [   1]   message counter
        // 0x00  [   0]   message counter
        // 0x00  [   0]   command type
        // 0x??  [    ]   result payload size of global/local vars
        // 0x00  [   0]   result payload size of global/local vars
        const compoundCommand = [];
        compoundCommand[0] = 0; // calculate length later
        compoundCommand[1] = 0; // command size
        compoundCommand[2] = 1; // message counter // TODO: ?????
        compoundCommand[3] = 0; // message counter
        compoundCommand[4] = 0; // command type: direct command
        compoundCommand[5] = 0; // global/local vars
        compoundCommand[6] = 0; // global/local vars
        let compoundCommandIndex = 7;
        let sensorCount = -1;

        // Read from available sensors
        for (let i = 0; i < this._sensorPorts.length; i++) {
            if (this._sensorPorts[i] !== 'none') {
                sensorCount++;
                // make up sensor command array
                // 0x9D  [ 157]   op: get sensor value
                // 0x00  [   0]   layer
                // 0x02  [    ]   port
                // 0x00  [   0]   do not change type
                // 0x00  [    ]   mode
                // 0xE1  [ 225]
                // 0x0C  [    ]   global index
                compoundCommand[compoundCommandIndex + 0] = 157;
                compoundCommand[compoundCommandIndex + 1] = 0;
                compoundCommand[compoundCommandIndex + 2] = i;
                compoundCommand[compoundCommandIndex + 3] = 0;
                compoundCommand[compoundCommandIndex + 4] = EV_DEVICE_MODES[this._sensorPorts[i]];
                compoundCommand[compoundCommandIndex + 5] = 225;
                compoundCommand[compoundCommandIndex + 6] = sensorCount * 4;
                compoundCommandIndex += 7;
            }
        }
        // Read from available motors
        // let motorCount = 0;
        for (let i = 0; i < this._motorPorts.length; i++) {
            if (this._motorPorts[i] !== 'none') {
                sensorCount++;
                // make up sensor command array
                // 0xB3  [ 179]   op: get motor position value
                // 0x00  [   0]   layer
                // 0x02  [    ]   output bit fields ??
                // 0xE1  [ 225]
                // 0x??  [   0]   global index
                compoundCommand[compoundCommandIndex + 0] = 179;
                compoundCommand[compoundCommandIndex + 1] = 0;
                compoundCommand[compoundCommandIndex + 2] = i;
                compoundCommand[compoundCommandIndex + 3] = 225;
                compoundCommand[compoundCommandIndex + 4] = sensorCount * 4;
                compoundCommandIndex += 5;
                // motorCount++;
            }
        }


        // Calculate compound command length
        compoundCommand[0] = compoundCommand.length - 2;
        // Calculate global var payload length needed
        compoundCommand[5] = (sensorCount + 1) * 4;
        // console.log('compound command to send: ' + compoundCommand);
        this._bt.sendMessage({
            message: Base64Util.uint8ArrayToBase64(compoundCommand),
            encoding: 'base64'
        });

        // TODO: Read from available motor ports
    }

    _onSessionMessage (params) {
        const message = params.message;
        const array = Base64Util.base64ToUint8Array(message);

        if (this._sensorPorts.length === 0) {
            // SENSOR LIST
            // JAABAAIefn5+fn5+fn5+fn5+fn5+Bwd+fn5+fn5+fn5+fn5+fgA=
            log.info(`device array: ${array}`);
            this._sensorPorts[0] = EV_DEVICE_TYPES[array[5]];
            this._sensorPorts[1] = EV_DEVICE_TYPES[array[6]];
            this._sensorPorts[2] = EV_DEVICE_TYPES[array[7]];
            this._sensorPorts[3] = EV_DEVICE_TYPES[array[8]];
            this._motorPorts[0] = EV_DEVICE_TYPES[array[21]];
            this._motorPorts[1] = EV_DEVICE_TYPES[array[22]];
            this._motorPorts[2] = EV_DEVICE_TYPES[array[23]];
            this._motorPorts[3] = EV_DEVICE_TYPES[array[24]];
            log.info(`sensor ports: ${this._sensorPorts}`);
            log.info(`motor ports: ${this._motorPorts}`);

            // Now ready to read from assigned _sensors
            // Start reading sensor data
            // TODO: window?
            this._pollingIntervalID = window.setInterval(this._getSessionData.bind(this), 100);
        } else {
            // log.info(`received compound command result: ${array}`);
            let offset = 5;
            for (let i = 0; i < this._sensorPorts.length; i++) {
                if (this._sensorPorts[i] !== 'none') {
                    const value = this._array2float([
                        array[offset],
                        array[offset + 1],
                        array[offset + 2],
                        array[offset + 3]
                    ]);
                    log.info(`sensor at port ${i} ${this._sensorPorts[i]} value: ${value}`);
                    this._sensors[EV_DEVICE_LABELS[this._sensorPorts[i]]] = value;
                    offset += 4;
                }
            }
            for (let i = 0; i < this._motorPorts.length; i++) {
                if (this._motorPorts[i] !== 'none') {
                    let value = this._tachoValue([
                        array[offset],
                        array[offset + 1],
                        array[offset + 2],
                        array[offset + 3]
                    ]);
                    if (value > 0x7fffffff) {
                        value = value - 0x100000000;
                    }
                    log.info(`motor at port ${i} ${this._motorPorts[i]} value: ${value}`);
                    this._motorPositions[MOTOR_PORTS[i].value] = value;
                    offset += 4;
                }
            }
            // const sensorValue = this._array2float([array[5], array[6], array[7], array[8]]);
            // log.info('receiving port array?: ' + array);
            // log.info('receiving port sensorValue?: ' + sensorValue);
            // this._sensors.distance = distance;
        }

    }

    _tachoValue (list) {
        const value = list[0] + (list[1] * 256) + (list[2] * 256 * 256) + (list[3] * 256 * 256 * 256);
        return value;
    }

    // TODO: put elsewhere
    _array2float (list) {
        const buffer = new Uint8Array(list).buffer;
        const view = new DataView(buffer);
        return view.getFloat32(0, true);
    }

}

class Scratch3Ev3Blocks {

    /**
     * The ID of the extension.
     * @return {string} the id
     */
    static get EXTENSION_ID () {
        return 'ev3';
    }

    /**
     * Creates a new instance of the EV3 extension.
     * @param  {object} runtime VM runtime
     * @constructor
     */
    constructor (runtime) {
        /**
         * The Scratch 3.0 runtime.
         * @type {Runtime}
         */
        this.runtime = runtime;

        // Create a new EV3 device instance
        this._device = new EV3(this.runtime, Scratch3Ev3Blocks.EXTENSION_ID);
    }

    /**
     * Define the EV3 extension.
     * @return {object} Extension description.
     */
    getInfo () {
        return {
            id: Scratch3Ev3Blocks.EXTENSION_ID,
            name: 'LEGO MINDSTORMS EV3',
            iconURI: null,
            showStatusButton: true,
            blocks: [
                {
                    opcode: 'motorTurnClockwise',
                    text: 'motor [PORT] turn clockwise for [TIME] seconds',
                    blockType: BlockType.COMMAND,
                    arguments: {
                        PORT: {
                            type: ArgumentType.STRING,
                            menu: 'motorPorts',
                            defaultValue: MOTOR_PORTS[0].value
                        },
                        TIME: {
                            type: ArgumentType.NUMBER,
                            defaultValue: 1
                        }
                    }
                },
                {
                    opcode: 'motorTurnCounterClockwise',
                    text: 'motor [PORT] turn counter for [TIME] seconds',
                    blockType: BlockType.COMMAND,
                    arguments: {
                        PORT: {
                            type: ArgumentType.STRING,
                            menu: 'motorPorts',
                            defaultValue: MOTOR_PORTS[0].value
                        },
                        TIME: {
                            type: ArgumentType.NUMBER,
                            defaultValue: 1
                        }
                    }
                },
                {
                    opcode: 'motorRotate',
                    text: 'motor [PORT] rotate [DEGREES] degrees',
                    blockType: BlockType.COMMAND,
                    arguments: {
                        PORT: {
                            type: ArgumentType.STRING,
                            menu: 'motorPorts',
                            defaultValue: MOTOR_PORTS[0].value
                        },
                        DEGREES: {
                            type: ArgumentType.NUMBER,
                            defaultValue: 90
                        }
                    }
                },
                {
                    opcode: 'motorSetPosition',
                    text: 'motor [PORT] set position [DEGREES] degrees',
                    blockType: BlockType.COMMAND,
                    arguments: {
                        PORT: {
                            type: ArgumentType.STRING,
                            menu: 'motorPorts',
                            defaultValue: MOTOR_PORTS[0].value
                        },
                        DEGREES: {
                            type: ArgumentType.NUMBER,
                            defaultValue: 90
                        }
                    }
                },
                {
                    opcode: 'motorSetPower',
                    text: 'motor [PORT] set power [POWER] %',
                    blockType: BlockType.COMMAND,
                    arguments: {
                        PORT: {
                            type: ArgumentType.STRING,
                            menu: 'motorPorts',
                            defaultValue: MOTOR_PORTS[0].value
                        },
                        POWER: {
                            type: ArgumentType.NUMBER,
                            defaultValue: 50
                        }
                    }
                },
                {
                    opcode: 'getMotorPosition',
                    text: 'motor [PORT] position',
                    blockType: BlockType.REPORTER,
                    arguments: {
                        PORT: {
                            type: ArgumentType.STRING,
                            menu: 'motorPorts',
                            defaultValue: MOTOR_PORTS[0].value
                        }
                    }
                },
                {
                    opcode: 'whenButtonPressed',
                    text: 'when button [PORT] pressed',
                    blockType: BlockType.HAT,
                    arguments: {
                        PORT: {
                            type: ArgumentType.STRING,
                            menu: 'sensorPorts',
                            defaultValue: SENSOR_PORTS[0].value
                        }
                    }
                },
                {
                    opcode: 'whenDistanceLessThan',
                    text: 'when distance < [DISTANCE]',
                    blockType: BlockType.HAT,
                    arguments: {
                        DISTANCE: {
                            type: ArgumentType.NUMBER,
                            defaultValue: 5
                        }
                    }
                },
                {
                    opcode: 'whenBrightnessLessThan',
                    text: 'when brightness < [DISTANCE]',
                    blockType: BlockType.HAT,
                    arguments: {
                        DISTANCE: {
                            type: ArgumentType.NUMBER,
                            defaultValue: 50
                        }
                    }
                },
                {
                    opcode: 'buttonPressed',
                    text: 'button [PORT] pressed?',
                    blockType: BlockType.BOOLEAN,
                    arguments: {
                        PORT: {
                            type: ArgumentType.STRING,
                            menu: 'sensorPorts',
                            defaultValue: SENSOR_PORTS[0].value
                        }
                    }
                },
                {
                    opcode: 'getDistance',
                    text: 'distance',
                    blockType: BlockType.REPORTER
                },
                {
                    opcode: 'getBrightness',
                    text: 'brightness',
                    blockType: BlockType.REPORTER
                },
                {
                    opcode: 'beep',
                    text: 'beep',
                    blockType: BlockType.COMMAND
                }
            ],
            menus: {
                motorPorts: this._buildMenu(MOTOR_PORTS),
                sensorPorts: this._buildMenu(SENSOR_PORTS)
            }
        };
    }

    /**
     * Create data for a menu in scratch-blocks format, consisting of an array of objects with text and
     * value properties. The text is a translated string, and the value is one-indexed.
     * @param  {object[]} info - An array of info objects each having a name property.
     * @return {array} - An array of objects with text and value properties.
     * @private
     */
    _buildMenu (info) {
        return info.map((entry, index) => {
            const obj = {};
            obj.text = entry.name;
            obj.value = String(index + 1);
            return obj;
        });
    }

    motorTurnClockwise (args) {
        const port = Cast.toNumber(args.PORT);
        const time = Cast.toNumber(args.TIME) * 1000;

        this._device.motorTurnClockwise(port, time);
        return;
    }

    motorTurnCounterClockwise (args) {
        const port = Cast.toNumber(args.PORT);
        const time = Cast.toNumber(args.TIME) * 1000;

        this._device.motorTurnCounterClockwise(port, time);
        return;
    }

    motorRotate (args) {
        const port = Cast.toNumber(args.PORT);
        const degrees = Cast.toNumber(args.DEGREES);

        this._device.motorRotate(port, degrees);
        return;
    }

    motorSetPosition (args) {
        const port = Cast.toNumber(args.PORT);
        const degrees = Cast.toNumber(args.DEGREES);

        this._device.motorSetPosition(port, degrees);
        return;
    }

    motorSetPower (args) {
        const port = Cast.toNumber(args.PORT);
        const power = Cast.toNumber(args.POWER);

        this._device.motorSetPower(port, power);
        return;
    }

    getMotorPosition (args) {
        const port = Cast.toNumber(args.PORT);

        return this._device.getMotorPosition(port);
    }

    whenButtonPressed (args) {
        const port = Cast.toNumber(args.PORT);

        return this._device.isButtonPressed(port);
    }

    whenDistanceLessThan (args) {
        const distance = Cast.toNumber(args.DISTANCE);

        return this._device.distance < distance;
    }

    whenBrightnessLessThan (args) {
        const brightness = Cast.toNumber(args.DISTANCE);

        return this._device.brightness < brightness;
    }

    buttonPressed (args) {
        const port = Cast.toNumber(args.PORT);

        return this._device.isButtonPressed(port);
    }

    getDistance () {
        return this._device.distance;
    }

    getBrightness () {
        return this._device.brightness;
    }

    beep () {
        return this._device.beep();
    }
}

module.exports = Scratch3Ev3Blocks;