// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
//
// SPDX-License-Identifier: GPL-2.0-or-later

import Gdk from 'gi://Gdk';
import GdkPixbuf from 'gi://GdkPixbuf';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Gtk from 'gi://Gtk';

import system from 'system';


/**
 * Return a random color
 *
 * @param {*} [salt] - If not %null, will be used as salt for generating a color
 * @param {number} alpha - A value in the [0...1] range for the alpha channel
 * @returns {Gdk.RGBA} A new Gdk.RGBA object generated from the input
 */
function randomRGBA(salt = null, alpha = 1.0) {
    let red, green, blue;

    if (salt !== null) {
        const hash = new GLib.Variant('s', `${salt}`).hash();
        red = ((hash & 0xFF0000) >> 16) / 255;
        green = ((hash & 0x00FF00) >> 8) / 255;
        blue = (hash & 0x0000FF) / 255;
    } else {
        red = Math.random();
        green = Math.random();
        blue = Math.random();
    }

    return new Gdk.RGBA({red: red, green: green, blue: blue, alpha: alpha});
}


/**
 * Get the relative luminance of a RGB set
 * See: https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
 *
 * @param {Gdk.RGBA} rgba - A GdkRGBA object
 * @returns {number} The relative luminance of the color
 */
function relativeLuminance(rgba) {
    const {red, green, blue} = rgba;

    const R = (red > 0.03928) ? red / 12.92 : Math.pow(((red + 0.055) / 1.055), 2.4);
    const G = (green > 0.03928) ? green / 12.92 : Math.pow(((green + 0.055) / 1.055), 2.4);
    const B = (blue > 0.03928) ? blue / 12.92 : Math.pow(((blue + 0.055) / 1.055), 2.4);

    return 0.2126 * R + 0.7152 * G + 0.0722 * B;
}


/**
 * Get a GdkRGBA contrasted for the input
 * See: https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef
 *
 * @param {Gdk.RGBA} rgba - A GdkRGBA object for the background color
 * @returns {Gdk.RGBA} A GdkRGBA object for the foreground color
 */
function getFgRGBA(rgba) {
    const bgLuminance = relativeLuminance(rgba);
    const lightContrast = (0.07275541795665634 + 0.05) / (bgLuminance + 0.05);
    const darkContrast = (bgLuminance + 0.05) / (0.0046439628482972135 + 0.05);

    const value = (darkContrast > lightContrast) ? 0.06 : 0.94;
    return new Gdk.RGBA({red: value, green: value, blue: value, alpha: 0.5});
}


/**
 * Get a GdkPixbuf for @path, allowing the corrupt JPEG's KDE Connect sometimes
 * sends. This function is synchronous.
 *
 * @param {string} path - A local file path
 * @param {number} size - Size in pixels
 * @param {scale} [scale] - Scale factor for the size
 * @returns {Gdk.Pixbuf} A pixbuf
 */
function getPixbufForPath(path, size, scale = 1.0) {
    let data, loader;

    // Catch missing avatar files
    try {
        data = GLib.file_get_contents(path)[1];
    } catch (e) {
        debug(e, path);
        return undefined;
    }

    // Consider errors from partially corrupt JPEGs to be warnings
    try {
        loader = new GdkPixbuf.PixbufLoader();
        loader.write(data);
        loader.close();
    } catch (e) {
        debug(e, path);
    }

    const pixbuf = loader.get_pixbuf();

    // Scale to monitor
    size = Math.floor(size * scale);
    return pixbuf.scale_simple(size, size, GdkPixbuf.InterpType.HYPER);
}

/**
 * Retrieve the GdkPixbuf for a named icon
 *
 * @param {string} name - The icon name to load
 * @param {number} size - The pixel size requested
 * @param {number} scale - The scale multiplier
 * @param {string} bgColor - The background color the icon will be used against
 * @returns {GdkPixbuf.pixbuf|null} The icon image
 */
function getPixbufForIcon(name, size, scale, bgColor) {
    const color = getFgRGBA(bgColor);
    const theme = Gtk.IconTheme.get_default();
    const info = theme.lookup_icon_for_scale(
        name,
        size,
        scale,
        Gtk.IconLookupFlags.FORCE_SYMBOLIC
    );

    return info.load_symbolic(color, null, null, null)[0];
}


/**
 * Return a localized string for a phone number type
 * See: http://www.ietf.org/rfc/rfc2426.txt
 *
 * @param {string} type - An RFC2426 phone number type
 * @returns {string} A localized string like 'Mobile'
 */
function getNumberTypeLabel(type) {
    if (type.includes('fax'))
        // TRANSLATORS: A fax number
        return _('Fax');

    if (type.includes('work'))
        // TRANSLATORS: A work or office phone number
        return _('Work');

    if (type.includes('cell'))
        // TRANSLATORS: A mobile or cellular phone number
        return _('Mobile');

    if (type.includes('home'))
        // TRANSLATORS: A home phone number
        return _('Home');

    // TRANSLATORS: All other phone number types
    return _('Other');
}

/**
 * Get a display number from @contact for @address.
 *
 * @param {object} contact - A contact object
 * @param {string} address - A phone number
 * @returns {string} A (possibly) better display number for the address
 */
export function getDisplayNumber(contact, address) {
    const number = address.toPhoneNumber();

    for (const contactNumber of contact.numbers) {
        const cnumber = contactNumber.value.toPhoneNumber();

        if (number.endsWith(cnumber) || cnumber.endsWith(number))
            return GLib.markup_escape_text(contactNumber.value, -1);
    }

    return GLib.markup_escape_text(address, -1);
}


/**
 * Contact Avatar
 */
const AvatarCache = new WeakMap();

export const Avatar = GObject.registerClass({
    GTypeName: 'GSConnectContactAvatar',
}, class ContactAvatar extends Gtk.DrawingArea {

    _init(contact = null) {
        super._init({
            height_request: 32,
            width_request: 32,
            valign: Gtk.Align.CENTER,
            visible: true,
        });

        this.contact = contact;
    }

    get rgba() {
        if (this._rgba === undefined) {
            if (this.contact)
                this._rgba = randomRGBA(this.contact.name);
            else
                this._rgba = randomRGBA(GLib.uuid_string_random());
        }

        return this._rgba;
    }

    get contact() {
        if (this._contact === undefined)
            this._contact = null;

        return this._contact;
    }

    set contact(contact) {
        if (this.contact === contact)
            return;

        this._contact = contact;
        this._surface = undefined;
        this._rgba = undefined;
        this._offset = 0;
    }

    _loadSurface() {
        // Get the monitor scale
        const display = Gdk.Display.get_default();
        const monitor = display.get_monitor_at_window(this.get_window());
        const scale = monitor.get_scale_factor();

        // If there's a contact with an avatar, try to load it
        if (this.contact && this.contact.avatar) {
            // Check the cache
            this._surface = AvatarCache.get(this.contact);

            // Try loading the pixbuf
            if (!this._surface) {
                const pixbuf = getPixbufForPath(
                    this.contact.avatar,
                    this.width_request,
                    scale
                );

                if (pixbuf) {
                    this._surface = Gdk.cairo_surface_create_from_pixbuf(
                        pixbuf,
                        0,
                        this.get_window()
                    );
                    AvatarCache.set(this.contact, this._surface);
                }
            }
        }

        // If we still don't have a surface, load a fallback
        if (!this._surface) {
            let iconName;

            // If we were given a contact, it's direct message otherwise group
            if (this.contact)
                iconName = 'avatar-default-symbolic';
            else
                iconName = 'group-avatar-symbolic';

            // Center the icon
            this._offset = (this.width_request - 24) / 2;

            // Load the fallback
            const pixbuf = getPixbufForIcon(iconName, 24, scale, this.rgba);

            this._surface = Gdk.cairo_surface_create_from_pixbuf(
                pixbuf,
                0,
                this.get_window()
            );
        }
    }

    vfunc_draw(cr) {
        if (!this._surface)
            this._loadSurface();

        // Clip to a circle
        const rad = this.width_request / 2;
        cr.arc(rad, rad, rad, 0, 2 * Math.PI);
        cr.clipPreserve();

        // Fill the background if the the surface is offset
        if (this._offset > 0) {
            Gdk.cairo_set_source_rgba(cr, this.rgba);
            cr.fill();
        }

        // Draw the avatar/icon
        cr.setSourceSurface(this._surface, this._offset, this._offset);
        cr.paint();

        cr.$dispose();
        return Gdk.EVENT_PROPAGATE;
    }
});


/**
 * A row for a contact address (usually a phone number).
 */
const AddressRow = GObject.registerClass({
    GTypeName: 'GSConnectContactsAddressRow',
    Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/contacts-address-row.ui',
    Children: ['avatar', 'name-label', 'address-label', 'type-label'],
}, class AddressRow extends Gtk.ListBoxRow {

    _init(contact, index = 0) {
        super._init();

        this._index = index;
        this._number = contact.numbers[index];
        this.contact = contact;
    }

    get contact() {
        if (this._contact === undefined)
            this._contact = null;

        return this._contact;
    }

    set contact(contact) {
        if (this.contact === contact)
            return;

        this._contact = contact;

        if (this._index === 0) {
            this.avatar.contact = contact;
            this.avatar.visible = true;

            this.name_label.label = GLib.markup_escape_text(contact.name, -1);
            this.name_label.visible = true;

            this.address_label.margin_start = 0;
            this.address_label.margin_end = 0;
        } else {
            this.avatar.visible = false;
            this.name_label.visible = false;

            // TODO: rtl inverts margin-start so the number don't align
            this.address_label.margin_start = 38;
            this.address_label.margin_end = 38;
        }

        this.address_label.label = GLib.markup_escape_text(this.number.value, -1);

        if (this.number.type !== undefined)
            this.type_label.label = getNumberTypeLabel(this.number.type);
    }

    get number() {
        if (this._number === undefined)
            return {value: 'unknown', type: 'unknown'};

        return this._number;
    }
});


/**
 * A widget for selecting contact addresses (usually phone numbers)
 */
export const ContactChooser = GObject.registerClass({
    GTypeName: 'GSConnectContactChooser',
    Properties: {
        'device': GObject.ParamSpec.object(
            'device',
            'Device',
            'The device associated with this window',
            GObject.ParamFlags.READWRITE,
            GObject.Object
        ),
        'store': GObject.ParamSpec.object(
            'store',
            'Store',
            'The contacts store',
            GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT,
            GObject.Object
        ),
    },
    Signals: {
        'number-selected': {
            flags: GObject.SignalFlags.RUN_FIRST,
            param_types: [GObject.TYPE_STRING],
        },
    },
    Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/contact-chooser.ui',
    Children: ['entry', 'list', 'scrolled'],
}, class ContactChooser extends Gtk.Grid {

    _init(params) {
        super._init(params);

        // Setup the contact list
        this.list._entry = this.entry.text;
        this.list.set_filter_func(this._filter);
        this.list.set_sort_func(this._sort);

        // Make sure we're using the correct contacts store
        this.device.bind_property(
            'contacts',
            this,
            'store',
            GObject.BindingFlags.SYNC_CREATE
        );

        // Cleanup on ::destroy
        this.connect('destroy', this._onDestroy);
    }

    get store() {
        if (this._store === undefined)
            this._store = null;

        return this._store;
    }

    set store(store) {
        if (this.store === store)
            return;

        // Unbind the old store
        if (this._store) {
            // Disconnect from the store
            this._store.disconnect(this._contactAddedId);
            this._store.disconnect(this._contactRemovedId);
            this._store.disconnect(this._contactChangedId);

            // Clear the contact list
            const rows = this.list.get_children();

            for (let i = 0, len = rows.length; i < len; i++) {
                rows[i].destroy();
                // HACK: temporary mitigator for mysterious GtkListBox leak
                system.gc();
            }
        }

        // Set the store
        this._store = store;

        // Bind the new store
        if (this._store) {
            // Connect to the new store
            this._contactAddedId = store.connect(
                'contact-added',
                this._onContactAdded.bind(this)
            );

            this._contactRemovedId = store.connect(
                'contact-removed',
                this._onContactRemoved.bind(this)
            );

            this._contactChangedId = store.connect(
                'contact-changed',
                this._onContactChanged.bind(this)
            );

            // Populate the list
            this._populate();
        }
    }

    /*
     * ContactStore Callbacks
     */
    _onContactAdded(store, id) {
        const contact = this.store.get_contact(id);
        this._addContact(contact);
    }

    _onContactRemoved(store, id) {
        const rows = this.list.get_children();

        for (let i = 0, len = rows.length; i < len; i++) {
            const row = rows[i];

            if (row.contact.id === id) {
                row.destroy();
                // HACK: temporary mitigator for mysterious GtkListBox leak
                system.gc();
            }
        }
    }

    _onContactChanged(store, id) {
        this._onContactRemoved(store, id);
        this._onContactAdded(store, id);
    }

    _onDestroy(chooser) {
        chooser.store = null;
    }

    _onSearchChanged(entry) {
        this.list._entry = entry.text;
        let dynamic = this.list.get_row_at_index(0);

        // If the entry contains string with 2 or more digits...
        if (entry.text.replace(/\D/g, '').length >= 2) {
            // ...ensure we have a dynamic contact for it
            if (!dynamic || !dynamic.__tmp) {
                dynamic = new AddressRow({
                    // TRANSLATORS: A phone number (eg. "Send to 555-5555")
                    name: _('Send to %s').format(entry.text),
                    numbers: [{type: 'unknown', value: entry.text}],
                });
                dynamic.__tmp = true;
                this.list.add(dynamic);

            // ...or if we already do, then update it
            } else {
                const address = entry.text;

                // Update contact object
                dynamic.contact.name = address;
                dynamic.contact.numbers[0].value = address;

                // Update UI
                dynamic.name_label.label = _('Send to %s').format(address);
                dynamic.address_label.label = address;
            }

        // ...otherwise remove any dynamic contact that's been created
        } else if (dynamic && dynamic.__tmp) {
            dynamic.destroy();
        }

        this.list.invalidate_filter();
        this.list.invalidate_sort();
    }

    // GtkListBox::row-activated
    _onNumberSelected(box, row) {
        if (row === null)
            return;

        // Emit the number
        const address = row.number.value;
        this.emit('number-selected', address);

        // Reset the contact list
        this.entry.text = '';
        this.list.select_row(null);
        this.scrolled.vadjustment.value = 0;
    }

    _filter(row) {
        // Dynamic contact always shown
        if (row.__tmp)
            return true;

        const query = row.get_parent()._entry;

        // Show contact if text is substring of name
        const queryName = query.toLocaleLowerCase();

        if (row.contact.name.toLocaleLowerCase().includes(queryName))
            return true;

        // Show contact if text is substring of number
        const queryNumber = query.toPhoneNumber();

        if (queryNumber.length) {
            for (const number of row.contact.numbers) {
                if (number.value.toPhoneNumber().includes(queryNumber))
                    return true;
            }

        // Query is effectively empty
        } else if (/^0+/.test(query)) {
            return true;
        }

        return false;
    }

    _sort(row1, row2) {
        if (row1.__tmp)
            return -1;

        if (row2.__tmp)
            return 1;

        return row1.contact.name.localeCompare(row2.contact.name);
    }

    _populate() {
        // Add each contact
        const contacts = this.store.contacts;

        for (let i = 0, len = contacts.length; i < len; i++)
            this._addContact(contacts[i]);
    }

    _addContactNumber(contact, index) {
        const row = new AddressRow(contact, index);
        this.list.add(row);

        return row;
    }

    _addContact(contact) {
        try {
            // HACK: fix missing contact names
            if (contact.name === undefined)
                contact.name = _('Unknown Contact');

            if (contact.numbers.length === 1)
                return this._addContactNumber(contact, 0);

            for (let i = 0, len = contact.numbers.length; i < len; i++)
                this._addContactNumber(contact, i);
        } catch (e) {
            logError(e);
        }
    }

    /**
     * Get a dictionary of number-contact pairs for each selected phone number.
     *
     * @returns {object[]} A dictionary of contacts
     */
    getSelected() {
        try {
            const selected = {};

            for (const row of this.list.get_selected_rows())
                selected[row.number.value] = row.contact;

            return selected;
        } catch (e) {
            logError(e);
            return {};
        }
    }
});
