// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
/* exported ScreenshotService */

const { Clutter, Gio, GObject, GLib, Meta, Shell, St } = imports.gi;

const GrabHelper = imports.ui.grabHelper;
const Lightbox = imports.ui.lightbox;
const Main = imports.ui.main;

Gio._promisify(Shell.Screenshot.prototype, 'pick_color', 'pick_color_finish');
Gio._promisify(Shell.Screenshot.prototype, 'screenshot', 'screenshot_finish');
Gio._promisify(Shell.Screenshot.prototype,
    'screenshot_window', 'screenshot_window_finish');
Gio._promisify(Shell.Screenshot.prototype,
    'screenshot_area', 'screenshot_area_finish');

const { loadInterfaceXML } = imports.misc.fileUtils;
const { DBusSenderChecker } = imports.misc.util;

const ScreenshotIface = loadInterfaceXML('org.gnome.Shell.Screenshot');

var ScreenshotService = class {
    constructor() {
        this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(ScreenshotIface, this);
        this._dbusImpl.export(Gio.DBus.session, '/org/gnome/Shell/Screenshot');

        this._screenShooter = new Map();
        this._senderChecker = new DBusSenderChecker([
            'org.gnome.SettingsDaemon.MediaKeys',
            'org.freedesktop.impl.portal.desktop.gtk',
            'org.freedesktop.impl.portal.desktop.gnome',
            'org.gnome.Screenshot',
        ]);

        this._lockdownSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.lockdown' });

        Gio.DBus.session.own_name('org.gnome.Shell.Screenshot', Gio.BusNameOwnerFlags.REPLACE, null, null);
    }

    _createScreenshot(invocation, needsDisk = true) {
        let lockedDown = false;
        if (needsDisk)
            lockedDown = this._lockdownSettings.get_boolean('disable-save-to-disk');

        let sender = invocation.get_sender();
        if (this._screenShooter.has(sender)) {
            invocation.return_error_literal(
                Gio.IOErrorEnum, Gio.IOErrorEnum.BUSY,
                'There is an ongoing operation for this sender');
            return null;
        } else if (lockedDown) {
            invocation.return_error_literal(
                Gio.IOErrorEnum, Gio.IOErrorEnum.PERMISSION_DENIED,
                'Saving to disk is disabled');
            return null;
        } else {
            try {
                this._senderChecker.checkInvocation(invocation);
            } catch (e) {
                invocation.return_gerror(e);
                return null;
            }
        }

        let shooter = new Shell.Screenshot();
        shooter._watchNameId =
                        Gio.bus_watch_name(Gio.BusType.SESSION, sender, 0, null,
                                           this._onNameVanished.bind(this));

        this._screenShooter.set(sender, shooter);

        return shooter;
    }

    _onNameVanished(connection, name) {
        this._removeShooterForSender(name);
    }

    _removeShooterForSender(sender) {
        let shooter = this._screenShooter.get(sender);
        if (!shooter)
            return;

        Gio.bus_unwatch_name(shooter._watchNameId);
        this._screenShooter.delete(sender);
    }

    _checkArea(x, y, width, height) {
        return x >= 0 && y >= 0 &&
               width > 0 && height > 0 &&
               x + width <= global.screen_width &&
               y + height <= global.screen_height;
    }

    *_resolveRelativeFilename(filename) {
        filename = filename.replace(/\.png$/, '');

        let path = [
            GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_PICTURES),
            GLib.get_home_dir(),
        ].find(p => p && GLib.file_test(p, GLib.FileTest.EXISTS));

        if (!path)
            return null;

        yield Gio.File.new_for_path(
            GLib.build_filenamev([path, '%s.png'.format(filename)]));

        for (let idx = 1; ; idx++) {
            yield Gio.File.new_for_path(
                GLib.build_filenamev([path, '%s-%s.png'.format(filename, idx)]));
        }
    }

    _createStream(filename, invocation) {
        if (filename == '')
            return [Gio.MemoryOutputStream.new_resizable(), null];

        if (GLib.path_is_absolute(filename)) {
            try {
                let file = Gio.File.new_for_path(filename);
                let stream = file.replace(null, false, Gio.FileCreateFlags.NONE, null);
                return [stream, file];
            } catch (e) {
                invocation.return_gerror(e);
                this._removeShooterForSender(invocation.get_sender());
                return [null, null];
            }
        }

        let err;
        for (let file of this._resolveRelativeFilename(filename)) {
            try {
                let stream = file.create(Gio.FileCreateFlags.NONE, null);
                return [stream, file];
            } catch (e) {
                err = e;
                if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.EXISTS))
                    break;
            }
        }

        invocation.return_gerror(err);
        this._removeShooterForSender(invocation.get_sender());
        return [null, null];
    }

    _flashAsync(shooter) {
        return new Promise((resolve, _reject) => {
            shooter.connect('screenshot_taken', (s, area) => {
                const flashspot = new Flashspot(area);
                flashspot.fire(resolve);

                global.display.get_sound_player().play_from_theme(
                    'screen-capture', _('Screenshot taken'), null);
            });
        });
    }

    _onScreenshotComplete(stream, file, invocation) {
        stream.close(null);

        let filenameUsed = '';
        if (file) {
            filenameUsed = file.get_path();
        } else {
            let bytes = stream.steal_as_bytes();
            let clipboard = St.Clipboard.get_default();
            clipboard.set_content(St.ClipboardType.CLIPBOARD, 'image/png', bytes);
        }

        let retval = GLib.Variant.new('(bs)', [true, filenameUsed]);
        invocation.return_value(retval);
    }

    _scaleArea(x, y, width, height) {
        let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor;
        x *= scaleFactor;
        y *= scaleFactor;
        width *= scaleFactor;
        height *= scaleFactor;
        return [x, y, width, height];
    }

    _unscaleArea(x, y, width, height) {
        let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor;
        x /= scaleFactor;
        y /= scaleFactor;
        width /= scaleFactor;
        height /= scaleFactor;
        return [x, y, width, height];
    }

    async ScreenshotAreaAsync(params, invocation) {
        let [x, y, width, height, flash, filename] = params;
        [x, y, width, height] = this._scaleArea(x, y, width, height);
        if (!this._checkArea(x, y, width, height)) {
            invocation.return_error_literal(Gio.IOErrorEnum,
                                            Gio.IOErrorEnum.CANCELLED,
                                            "Invalid params");
            return;
        }
        let screenshot = this._createScreenshot(invocation);
        if (!screenshot)
            return;

        let [stream, file] = this._createStream(filename, invocation);
        if (!stream)
            return;

        try {
            await Promise.all([
                flash ? this._flashAsync(screenshot) : null,
                screenshot.screenshot_area(x, y, width, height, stream),
            ]);
            this._onScreenshotComplete(stream, file, invocation);
        } catch (e) {
            invocation.return_value(new GLib.Variant('(bs)', [false, '']));
        } finally {
            this._removeShooterForSender(invocation.get_sender());
        }
    }

    async ScreenshotWindowAsync(params, invocation) {
        let [includeFrame, includeCursor, flash, filename] = params;
        let screenshot = this._createScreenshot(invocation);
        if (!screenshot)
            return;

        let [stream, file] = this._createStream(filename, invocation);
        if (!stream)
            return;

        try {
            await Promise.all([
                flash ? this._flashAsync(screenshot) : null,
                screenshot.screenshot_window(includeFrame, includeCursor, stream),
            ]);
            this._onScreenshotComplete(stream, file, invocation);
        } catch (e) {
            invocation.return_value(new GLib.Variant('(bs)', [false, '']));
        } finally {
            this._removeShooterForSender(invocation.get_sender());
        }
    }

    async ScreenshotAsync(params, invocation) {
        let [includeCursor, flash, filename] = params;
        let screenshot = this._createScreenshot(invocation);
        if (!screenshot)
            return;

        let [stream, file] = this._createStream(filename, invocation);
        if (!stream)
            return;

        try {
            await Promise.all([
                flash ? this._flashAsync(screenshot) : null,
                screenshot.screenshot(includeCursor, stream),
            ]);
            this._onScreenshotComplete(stream, file, invocation);
        } catch (e) {
            invocation.return_value(new GLib.Variant('(bs)', [false, '']));
        } finally {
            this._removeShooterForSender(invocation.get_sender());
        }
    }

    async SelectAreaAsync(params, invocation) {
        try {
            this._senderChecker.checkInvocation(invocation);
        } catch (e) {
            invocation.return_gerror(e);
            return;
        }

        let selectArea = new SelectArea();
        try {
            let areaRectangle = await selectArea.selectAsync();
            let retRectangle = this._unscaleArea(
                areaRectangle.x, areaRectangle.y,
                areaRectangle.width, areaRectangle.height);
            invocation.return_value(GLib.Variant.new('(iiii)', retRectangle));
        } catch (e) {
            invocation.return_error_literal(
                Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED,
                'Operation was cancelled');
        }
    }

    FlashAreaAsync(params, invocation) {
        try {
            this._senderChecker.checkInvocation(invocation);
        } catch (e) {
            invocation.return_gerror(e);
            return;
        }

        let [x, y, width, height] = params;
        [x, y, width, height] = this._scaleArea(x, y, width, height);
        if (!this._checkArea(x, y, width, height)) {
            invocation.return_error_literal(Gio.IOErrorEnum,
                                            Gio.IOErrorEnum.CANCELLED,
                                            "Invalid params");
            return;
        }
        let flashspot = new Flashspot({ x, y, width, height });
        flashspot.fire();
        invocation.return_value(null);
    }

    async PickColorAsync(params, invocation) {
        const screenshot = this._createScreenshot(invocation, false);
        if (!screenshot)
            return;

        const pickPixel = new PickPixel(screenshot);
        try {
            const color = await pickPixel.pickAsync();
            const { red, green, blue } = color;
            const retval = GLib.Variant.new('(a{sv})', [{
                color: GLib.Variant.new('(ddd)', [
                    red / 255.0,
                    green / 255.0,
                    blue / 255.0,
                ]),
            }]);
            invocation.return_value(retval);
        } catch (e) {
            invocation.return_error_literal(
                Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED,
                'Operation was cancelled');
        } finally {
            this._removeShooterForSender(invocation.get_sender());
        }
    }
};

var SelectArea = GObject.registerClass(
class SelectArea extends St.Widget {
    _init() {
        this._startX = -1;
        this._startY = -1;
        this._lastX = 0;
        this._lastY = 0;
        this._result = null;

        super._init({
            visible: false,
            reactive: true,
            x: 0,
            y: 0,
        });
        Main.uiGroup.add_actor(this);

        this._grabHelper = new GrabHelper.GrabHelper(this);

        let constraint = new Clutter.BindConstraint({ source: global.stage,
                                                      coordinate: Clutter.BindCoordinate.ALL });
        this.add_constraint(constraint);

        this._rubberband = new St.Widget({
            style_class: 'select-area-rubberband',
            visible: false,
        });
        this.add_actor(this._rubberband);
    }

    async selectAsync() {
        global.display.set_cursor(Meta.Cursor.CROSSHAIR);
        Main.uiGroup.set_child_above_sibling(this, null);
        this.show();

        try {
            await this._grabHelper.grabAsync({ actor: this });
        } finally {
            global.display.set_cursor(Meta.Cursor.DEFAULT);

            GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
                this.destroy();
                return GLib.SOURCE_REMOVE;
            });
        }

        return this._result;
    }

    _getGeometry() {
        return new Meta.Rectangle({
            x: Math.min(this._startX, this._lastX),
            y: Math.min(this._startY, this._lastY),
            width: Math.abs(this._startX - this._lastX) + 1,
            height: Math.abs(this._startY - this._lastY) + 1,
        });
    }

    vfunc_motion_event(motionEvent) {
        if (this._startX == -1 || this._startY == -1 || this._result)
            return Clutter.EVENT_PROPAGATE;

        [this._lastX, this._lastY] = [motionEvent.x, motionEvent.y];
        this._lastX = Math.floor(this._lastX);
        this._lastY = Math.floor(this._lastY);
        let geometry = this._getGeometry();

        this._rubberband.set_position(geometry.x, geometry.y);
        this._rubberband.set_size(geometry.width, geometry.height);
        this._rubberband.show();

        return Clutter.EVENT_PROPAGATE;
    }

    vfunc_button_press_event(buttonEvent) {
        if (this._result)
            return Clutter.EVENT_PROPAGATE;

        [this._startX, this._startY] = [buttonEvent.x, buttonEvent.y];
        this._startX = Math.floor(this._startX);
        this._startY = Math.floor(this._startY);
        this._rubberband.set_position(this._startX, this._startY);

        return Clutter.EVENT_PROPAGATE;
    }

    vfunc_button_release_event() {
        if (this._startX === -1 || this._startY === -1 || this._result)
            return Clutter.EVENT_PROPAGATE;

        this._result = this._getGeometry();
        this.ease({
            opacity: 0,
            duration: 200,
            mode: Clutter.AnimationMode.EASE_OUT_QUAD,
            onComplete: () => this._grabHelper.ungrab(),
        });
        return Clutter.EVENT_PROPAGATE;
    }
});

var RecolorEffect = GObject.registerClass({
    Properties: {
        color: GObject.ParamSpec.boxed(
            'color', 'color', 'replacement color',
            GObject.ParamFlags.WRITABLE,
            Clutter.Color.$gtype),
        chroma: GObject.ParamSpec.boxed(
            'chroma', 'chroma', 'color to replace',
            GObject.ParamFlags.WRITABLE,
            Clutter.Color.$gtype),
        threshold: GObject.ParamSpec.float(
            'threshold', 'threshold', 'threshold',
            GObject.ParamFlags.WRITABLE,
            0.0, 1.0, 0.0),
        smoothing: GObject.ParamSpec.float(
            'smoothing', 'smoothing', 'smoothing',
            GObject.ParamFlags.WRITABLE,
            0.0, 1.0, 0.0),
    },
}, class RecolorEffect extends Shell.GLSLEffect {
    _init(params) {
        this._color = new Clutter.Color();
        this._chroma = new Clutter.Color();
        this._threshold = 0;
        this._smoothing = 0;

        this._colorLocation = null;
        this._chromaLocation = null;
        this._thresholdLocation = null;
        this._smoothingLocation = null;

        super._init(params);

        this._colorLocation = this.get_uniform_location('recolor_color');
        this._chromaLocation = this.get_uniform_location('chroma_color');
        this._thresholdLocation = this.get_uniform_location('threshold');
        this._smoothingLocation = this.get_uniform_location('smoothing');

        this._updateColorUniform(this._colorLocation, this._color);
        this._updateColorUniform(this._chromaLocation, this._chroma);
        this._updateFloatUniform(this._thresholdLocation, this._threshold);
        this._updateFloatUniform(this._smoothingLocation, this._smoothing);
    }

    _updateColorUniform(location, color) {
        if (!location)
            return;

        this.set_uniform_float(location,
            3, [color.red / 255, color.green / 255, color.blue / 255]);
        this.queue_repaint();
    }

    _updateFloatUniform(location, value) {
        if (!location)
            return;

        this.set_uniform_float(location, 1, [value]);
        this.queue_repaint();
    }

    set color(c) {
        if (this._color.equal(c))
            return;

        this._color = c;
        this.notify('color');

        this._updateColorUniform(this._colorLocation, this._color);
    }

    set chroma(c) {
        if (this._chroma.equal(c))
            return;

        this._chroma = c;
        this.notify('chroma');

        this._updateColorUniform(this._chromaLocation, this._chroma);
    }

    set threshold(value) {
        if (this._threshold === value)
            return;

        this._threshold = value;
        this.notify('threshold');

        this._updateFloatUniform(this._thresholdLocation, this._threshold);
    }

    set smoothing(value) {
        if (this._smoothing === value)
            return;

        this._smoothing = value;
        this.notify('smoothing');

        this._updateFloatUniform(this._smoothingLocation, this._smoothing);
    }

    vfunc_build_pipeline() {
        // Conversion parameters from https://en.wikipedia.org/wiki/YCbCr
        const decl = `
            vec3 rgb2yCrCb(vec3 c) {                                \n
                float y = 0.299 * c.r + 0.587 * c.g + 0.114 * c.b;  \n
                float cr = 0.7133 * (c.r - y);                      \n
                float cb = 0.5643 * (c.b - y);                      \n
                return vec3(y, cr, cb);                             \n
            }                                                       \n
                                                                    \n
            uniform vec3 chroma_color;                              \n
            uniform vec3 recolor_color;                             \n
            uniform float threshold;                                \n
            uniform float smoothing;                                \n`;
        const src = `
            vec3 mask = rgb2yCrCb(chroma_color.rgb);                \n
            vec3 yCrCb = rgb2yCrCb(cogl_color_out.rgb);             \n
            float blend =                                           \n
              smoothstep(threshold,                                 \n
                         threshold + smoothing,                     \n
                         distance(yCrCb.gb, mask.gb));              \n
            cogl_color_out.rgb =                                    \n
              mix(recolor_color, cogl_color_out.rgb, blend);        \n`;

        this.add_glsl_snippet(Shell.SnippetHook.FRAGMENT, decl, src, false);
    }
});

var PickPixel = GObject.registerClass(
class PickPixel extends St.Widget {
    _init(screenshot) {
        super._init({ visible: false, reactive: true });

        this._screenshot = screenshot;

        this._result = null;
        this._color = null;
        this._inPick = false;

        Main.uiGroup.add_actor(this);

        this._grabHelper = new GrabHelper.GrabHelper(this);

        let constraint = new Clutter.BindConstraint({ source: global.stage,
                                                      coordinate: Clutter.BindCoordinate.ALL });
        this.add_constraint(constraint);

        const action = new Clutter.ClickAction();
        action.connect('clicked', async () => {
            await this._pickColor(...action.get_coords());
            this._result = this._color;
            this._grabHelper.ungrab();
        });
        this.add_action(action);

        this._recolorEffect = new RecolorEffect({
            chroma: new Clutter.Color({
                red: 80,
                green: 219,
                blue: 181,
            }),
            threshold: 0.04,
            smoothing: 0.07,
        });
        this._previewCursor = new St.Icon({
            icon_name: 'color-pick',
            icon_size: Meta.prefs_get_cursor_size(),
            effect: this._recolorEffect,
            visible: false,
        });
        Main.uiGroup.add_actor(this._previewCursor);
    }

    async pickAsync() {
        global.display.set_cursor(Meta.Cursor.BLANK);
        Main.uiGroup.set_child_above_sibling(this, null);
        this.show();

        this._pickColor(...global.get_pointer());

        try {
            await this._grabHelper.grabAsync({ actor: this });
        } finally {
            global.display.set_cursor(Meta.Cursor.DEFAULT);
            this._previewCursor.destroy();

            GLib.idle_add(GLib.PRIORITY_DEFAULT, () => {
                this.destroy();
                return GLib.SOURCE_REMOVE;
            });
        }

        return this._result;
    }

    async _pickColor(x, y) {
        if (this._inPick)
            return;

        this._inPick = true;
        this._previewCursor.set_position(x, y);
        [this._color] = await this._screenshot.pick_color(x, y);
        this._inPick = false;

        if (!this._color)
            return;

        this._recolorEffect.color = this._color;
        this._previewCursor.show();
    }

    vfunc_motion_event(motionEvent) {
        const { x, y } = motionEvent;
        this._pickColor(x, y);
        return Clutter.EVENT_PROPAGATE;
    }
});

var FLASHSPOT_ANIMATION_OUT_TIME = 500; // milliseconds

var Flashspot = GObject.registerClass(
class Flashspot extends Lightbox.Lightbox {
    _init(area) {
        super._init(Main.uiGroup, {
            inhibitEvents: true,
            width: area.width,
            height: area.height,
        });
        this.style_class = 'flashspot';
        this.set_position(area.x, area.y);
    }

    fire(doneCallback) {
        this.set({ visible: true, opacity: 255 });
        this.ease({
            opacity: 0,
            duration: FLASHSPOT_ANIMATION_OUT_TIME,
            mode: Clutter.AnimationMode.EASE_OUT_QUAD,
            onComplete: () => {
                if (doneCallback)
                    doneCallback();
                this.destroy();
            },
        });
    }
});
