const {createReadStream} = require('fs');
const {join} = require('path');

const {PNG} = require('pngjs');
const {test} = require('tap');

const {wrapClamp} = require('../../src/util/math-util');

const VideoSensing = require('../../src/extensions/scratch3_video_sensing/index.js');
const VideoMotion = require('../../src/extensions/scratch3_video_sensing/library.js');

/**
 * Prefix to the mock frame images used to test the video sensing extension.
 * @type {string}
 */
const pngPrefix = 'extension_video_sensing_';

/**
 * Map of frame keys to the image filenames appended to the pngPrefix.
 * @type {object}
 */
const framesMap = {
    center: 'center',
    left: 'left-5',
    left2: 'left-10',
    down: 'down-10'
};

/**
 * Asynchronously read a png file and copy its pixel data into a typed array
 * VideoMotion will accept.
 * @param {string} name - partial filename to read
 * @returns {Promise.<Uint32Array>} pixel data of the image
 */
const readPNG = name => (
    new Promise((resolve, reject) => {
        const png = new PNG();
        createReadStream(join(__dirname, `${pngPrefix}${name}.png`))
            .pipe(png)
            .on('parsed', () => {
                // Copy the RGBA pixel values into a separate typed array and
                // cast the array to Uint32, the array format VideoMotion takes.
                resolve(new Uint32Array(new Uint8ClampedArray(png.data).buffer));
            })
            .on('error', reject);
    })
);

/**
 * Read all the frames for testing asynchrnously and produce an object with
 * keys following the keys in framesMap.
 * @returns {object} mapping of keys in framesMap to image data read from disk
 */
const readFrames = (() => {
    // Use this immediately invoking function expression (IIFE) to delay reading
    // once to the first test that calls readFrames.
    let _promise = null;

    return () => {
        if (_promise === null) {
            _promise = Promise.all(Object.keys(framesMap).map(key => readPNG(framesMap[key])))
                .then(pngs => (
                    Object.keys(framesMap).reduce((frames, key, i) => {
                        frames[key] = pngs[i];
                        return frames;
                    }, {})
                ));
        }
        return _promise;
    };
})();

/**
 * Match if actual is within optMargin to expect. If actual is under -180,
 * match if actual + 360 is near expect. If actual is over 180, match if actual
 * - 360 is near expect.
 * @param {number} actual - actual angle in degrees
 * @param {number} expect - expected angle in degrees
 * @param {number} optMargin - allowed margin between actual and expect in degrees
 * @returns {boolean} true if actual is close to expect
 */
const isNearAngle = (actual, expect, optMargin = 10) => (
    (wrapClamp(actual - expect, 0, 359) < optMargin) ||
    (wrapClamp(actual - expect, 0, 359) > 360 - optMargin)
);

// A fake scratch-render drawable that will be used by VideoMotion to restrain
// the area considered for motion detection in VideoMotion.getLocalMotion
const fakeDrawable = {
    updateCPURenderAttributes () {}, // no-op, since isTouching always returns true

    getFastBounds () {
        return {
            left: -120,
            top: 60,
            right: 0,
            bottom: -60
        };
    },

    isTouching () {
        return true;
    }
};

// A fake MotionState used to test the stored values in
// VideoMotion.getLocalMotion, VideoSensing.videoOn and
// VideoSensing.whenMotionGreaterThan.
const fakeMotionState = {
    motionFrameNumber: -1,
    motionAmount: -1,
    motionDirection: -Infinity
};

// A fake target referring to the fake drawable and MotionState.
const fakeTarget = {
    drawableID: 0,

    getCustomState () {
        return fakeMotionState;
    },
    setCustomState () {}
};

const fakeRuntime = {
    targets: [fakeTarget],

    // Without defined devices, VideoSensing will not try to start sampling from
    // a video source.
    ioDevices: null,

    renderer: {
        _allDrawables: [
            fakeDrawable
        ]
    }
};

const fakeBlockUtility = {
    target: fakeTarget
};

test('detect motionAmount between frames', t => {
    t.plan(6);

    return readFrames()
        .then(frames => {
            const detect = new VideoMotion();

            // Each of these pairs should have enough motion for the detector.
            const framePairs = [
                [frames.center, frames.left],
                [frames.center, frames.left2],
                [frames.left, frames.left2],
                [frames.left, frames.center],
                [frames.center, frames.down],
                [frames.down, frames.center]
            ];

            // Add both frames of a pair and test for motion.
            let index = 0;
            for (const [frame1, frame2] of framePairs) {
                detect.addFrame(frame1);
                detect.addFrame(frame2);

                detect.analyzeFrame();
                t.ok(
                    detect.motionAmount > 10,
                    `frame pair ${index + 1} has motion ${detect.motionAmount} over threshold (10)`
                );
                index += 1;
            }

            t.end();
        });
});

test('detect local motionAmount between frames', t => {
    t.plan(6);

    return readFrames()
        .then(frames => {
            const detect = new VideoMotion();

            // Each of these pairs should have enough motion for the detector.
            const framePairs = [
                [frames.center, frames.left],
                [frames.center, frames.left2],
                [frames.left, frames.left2],
                [frames.left, frames.center],
                [frames.center, frames.down],
                [frames.down, frames.center]
            ];

            // Add both frames of a pair and test for local motion.
            let index = 0;
            for (const [frame1, frame2] of framePairs) {
                detect.addFrame(frame1);
                detect.addFrame(frame2);

                detect.analyzeFrame();
                detect.getLocalMotion(fakeDrawable, fakeMotionState);
                t.ok(
                    fakeMotionState.motionAmount > 10,
                    `frame pair ${index + 1} has motion ${fakeMotionState.motionAmount} over threshold (10)`
                );
                index += 1;
            }

            t.end();
        });
});

test('detect motionDirection between frames', t => {
    t.plan(6);

    return readFrames()
        .then(frames => {
            const detect = new VideoMotion();

            // Each of these pairs is moving in the given direction. Does the detector
            // guess a value to that?
            const directionMargin = 10;
            const framePairs = [
                {
                    frames: [frames.center, frames.left],
                    direction: -90
                },
                {
                    frames: [frames.center, frames.left2],
                    direction: -90
                },
                {
                    frames: [frames.left, frames.left2],
                    direction: -90
                },
                {
                    frames: [frames.left, frames.center],
                    direction: 90
                },
                {
                    frames: [frames.center, frames.down],
                    direction: 180
                },
                {
                    frames: [frames.down, frames.center],
                    direction: 0
                }
            ];

            // Add both frames of a pair and check if the motionDirection is near the
            // expected angle.
            let index = 0;
            for (const {frames: [frame1, frame2], direction} of framePairs) {
                detect.addFrame(frame1);
                detect.addFrame(frame2);

                detect.analyzeFrame();
                t.ok(
                    isNearAngle(detect.motionDirection, direction, directionMargin),
                    `frame pair ${index + 1} is ${detect.motionDirection.toFixed(0)} ` +
                    `degrees and close to ${direction} degrees`
                );
                index += 1;
            }

            t.end();
        });
});

test('detect local motionDirection between frames', t => {
    t.plan(6);

    return readFrames()
        .then(frames => {
            const detect = new VideoMotion();

            // Each of these pairs is moving in the given direction. Does the detector
            // guess a value to that?
            const directionMargin = 10;
            const framePairs = [
                {
                    frames: [frames.center, frames.left],
                    direction: -90
                },
                {
                    frames: [frames.center, frames.left2],
                    direction: -90
                },
                {
                    frames: [frames.left, frames.left2],
                    direction: -90
                },
                {
                    frames: [frames.left, frames.center],
                    direction: 90
                },
                {
                    frames: [frames.center, frames.down],
                    direction: 180
                },
                {
                    frames: [frames.down, frames.center],
                    direction: 0
                }
            ];

            // Add both frames of a pair and check if the local motionDirection is near
            // the expected angle.
            let index = 0;
            for (const {frames: [frame1, frame2], direction} of framePairs) {
                detect.addFrame(frame1);
                detect.addFrame(frame2);

                detect.analyzeFrame();
                detect.getLocalMotion(fakeDrawable, fakeMotionState);
                const motionDirection = fakeMotionState.motionDirection;
                t.ok(
                    isNearAngle(motionDirection, direction, directionMargin),
                    `frame pair ${index + 1} is ${motionDirection.toFixed(0)} degrees and close to ${direction} degrees`
                );
                index += 1;
            }

            t.end();
        });
});

test('videoOn returns value dependent on arguments', t => {
    t.plan(4);

    return readFrames()
        .then(frames => {
            const sensing = new VideoSensing(fakeRuntime);

            // With these two frame test if we get expected values depending on the
            // arguments to videoOn.
            sensing.detect.addFrame(frames.center);
            sensing.detect.addFrame(frames.left);

            const motionAmount = sensing.videoOn({
                ATTRIBUTE: VideoSensing.SensingAttribute.MOTION,
                SUBJECT: VideoSensing.SensingSubject.STAGE
            }, fakeBlockUtility);
            t.ok(
                motionAmount > 10,
                `stage motionAmount ${motionAmount} is over the threshold (10)`
            );

            const localMotionAmount = sensing.videoOn({
                ATTRIBUTE: VideoSensing.SensingAttribute.MOTION,
                SUBJECT: VideoSensing.SensingSubject.SPRITE
            }, fakeBlockUtility);
            t.ok(
                localMotionAmount > 10,
                `sprite motionAmount ${localMotionAmount} is over the threshold (10)`
            );

            const motionDirection = sensing.videoOn({
                ATTRIBUTE: VideoSensing.SensingAttribute.DIRECTION,
                SUBJECT: VideoSensing.SensingSubject.STAGE
            }, fakeBlockUtility);
            t.ok(
                isNearAngle(motionDirection, -90),
                `stage motionDirection ${motionDirection.toFixed(0)} degrees is close to ${90} degrees`
            );

            const localMotionDirection = sensing.videoOn({
                ATTRIBUTE: VideoSensing.SensingAttribute.DIRECTION,
                SUBJECT: VideoSensing.SensingSubject.SPRITE
            }, fakeBlockUtility);
            t.ok(
                isNearAngle(localMotionDirection, -90),
                `sprite motionDirection ${localMotionDirection.toFixed(0)} degrees is close to ${90} degrees`
            );

            t.end();
        });
});

test('whenMotionGreaterThan returns true if local motion meets target', t => {
    t.plan(2);

    return readFrames()
        .then(frames => {
            const sensing = new VideoSensing(fakeRuntime);

            // With these two frame test if we get expected values depending on the
            // arguments to whenMotionGreaterThan.
            sensing.detect.addFrame(frames.center);
            sensing.detect.addFrame(frames.left);

            const over20 = sensing.whenMotionGreaterThan({
                REFERENCE: 20
            }, fakeBlockUtility);
            t.ok(
                over20,
                `enough motion in drawable bounds to reach reference of 20`
            );

            const over80 = sensing.whenMotionGreaterThan({
                REFERENCE: 80
            }, fakeBlockUtility);
            t.notOk(
                over80,
                `not enough motion in drawable bounds to reach reference of 80`
            );

            t.end();
        });
});