import Meta from 'gi://Meta';
import Gio from 'gi://Gio';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import * as Config from 'resource:///org/gnome/shell/misc/config.js';

import { ApplicationsService } from '../dbus/services.js';
import { PaintSignals } from '../conveniences/paint_signals.js';
import { DummyPipeline } from '../conveniences/dummy_pipeline.js';
import { Pipeline } from '../conveniences/pipeline.js';


/// Converts a wildcard pattern to a RegExp object.
/// Supports * (matches any sequence) and ? (matches any single character).
/// Matching is case-insensitive.
///
/// @param {string} pattern - The wildcard pattern (e.g., "Firefox*", "*Code*")
/// @returns {RegExp} The compiled regex pattern
function wildcardToRegex(pattern) {
    // Escape special regex characters except * and ?
    const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
    // Convert wildcards: * -> .*, ? -> .
    const regex = '^' + escaped.replace(/\*/g, '.*').replace(/\?/g, '.') + '$';
    return new RegExp(regex, 'i');
}


/// Compiles an array of wildcard patterns into RegExp objects.
/// Caches the results to avoid recompilation on every check.
///
/// @param {string[]} patterns - Array of wildcard patterns
/// @param {Map} cache - Cache map storing pattern -> regex mappings
/// @returns {RegExp[]} Array of compiled regex patterns
function compilePatterns(patterns, cache) {
    return patterns.map(pattern => {
        if (cache.has(pattern)) {
            return cache.get(pattern);
        }
        const regex = wildcardToRegex(pattern);
        cache.set(pattern, regex);
        return regex;
    });
}


/// Tests if a value matches any of the compiled patterns.
///
/// @param {string} value - The value to test (e.g., wm_class)
/// @param {RegExp[]} patterns - Array of compiled regex patterns
/// @returns {boolean} True if value matches any pattern
function matchesAnyPattern(value, patterns) {
    if (!value || patterns.length === 0) {
        return false;
    }
    return patterns.some(pattern => pattern.test(value));
}

export const ApplicationsBlur = class ApplicationsBlur {
    constructor(connections, settings, effects_manager) {
        this.connections = connections;
        this.settings = settings;
        this.effects_manager = effects_manager;
        this.paint_signals = new PaintSignals(connections);

        // stores every blurred meta window
        this.meta_window_map = new Map();

        // cache for compiled patterns to avoid recompilation
        this._whitelist_pattern_cache = new Map();
        this._blacklist_pattern_cache = new Map();
        this._compiled_whitelist = [];
        this._compiled_blacklist = [];

        // compile initial patterns
        this._update_patterns();
    }

    /// Updates the compiled whitelist and blacklist patterns from settings.
    /// Called during initialization and when whitelist/blacklist settings change.
    _update_patterns() {
        const whitelist = this.settings.applications.WHITELIST || [];
        const blacklist = this.settings.applications.BLACKLIST || [];

        this._compiled_whitelist = compilePatterns(whitelist, this._whitelist_pattern_cache);
        this._compiled_blacklist = compilePatterns(blacklist, this._blacklist_pattern_cache);

        this._log(`Patterns updated - whitelist: ${whitelist.length}, blacklist: ${blacklist.length}`);
    }

    enable() {
        this._log("blurring applications...");

        // export dbus service for preferences
        this.service = new ApplicationsService;
        this.service.export();

        this.mutter_gsettings = new Gio.Settings({ schema: 'org.gnome.mutter' });

        // blur already existing windows
        this.update_all_windows();

        // blur every new window
        this.connections.connect(
            global.display,
            'window-created',
            (_meta_display, meta_window) => {
                this._log("window created");

                if (meta_window)
                    this.track_new(meta_window);
            }
        );

        // update window blur when focus is changed
        this.focused_window_pid = null;
        this.init_dynamic_opacity();
        this.connections.connect(
            global.display,
            'focus-window',
            (_meta_display, meta_window, _p0) => {
                if (meta_window && meta_window.bms_pid != this.focused_window_pid)
                    this.set_focus_for_window(meta_window);
                else if (!meta_window)
                    this.set_focus_for_window(null);
            }
        );

        this.connect_to_overview();
    }

    /// Initializes the dynamic opacity for windows, without touching to the connections.
    /// This is used both when enabling the component, and when changing the dynamic-opacity pref.
    init_dynamic_opacity() {
        if (this.settings.applications.DYNAMIC_OPACITY) {
            // make the currently focused window solid
            if (global.display.focus_window)
                this.set_focus_for_window(global.display.focus_window);
        } else {
            // remove old focused window if the pref was changed
            if (this.focused_window_pid)
                this.set_focus_for_window(null);
        }
    }

    /// Connect to the overview being opened/closed to force the blur being
    /// shown on every window of the workspaces viewer.
    connect_to_overview() {
        this.connections.disconnect_all_for(Main.overview);

        if (this.settings.applications.BLUR_ON_OVERVIEW) {
            // when the overview is opened, show every window actors (which
            // allows the blur to be shown too)
            this.connections.connect(
                Main.overview, 'showing',
                _ => this.meta_window_map.forEach((meta_window, _pid) => {
                    let window_actor = meta_window.get_compositor_private();
                    window_actor?.show();
                })
            );

            // when the overview is closed, hide every actor that is not on the
            // current workspace (to mimic the original behaviour)
            this.connections.connect(
                Main.overview, 'hidden',
                _ => {
                    this.meta_window_map.forEach((meta_window, _pid) => {
                        let window_actor = meta_window.get_compositor_private();

                        if (
                            (!meta_window.get_workspace().active) || meta_window.minimized
                        )
                            window_actor.hide();
                    });
                }
            );
        }
    }

    /// Iterate through all existing windows and add blur as needed.
    update_all_windows() {
        // Recompile patterns in case whitelist/blacklist changed
        this._update_patterns();

        // remove all previously blurred windows, in the case where the
        // whitelist was changed
        this.meta_window_map.forEach(((_meta_window, pid) => {
            this.remove_blur(pid);
        }));

        for (
            let i = 0;
            i < global.workspace_manager.get_n_workspaces();
            ++i
        ) {
            let workspace = global.workspace_manager.get_workspace_by_index(i);
            let windows = workspace.list_windows();

            windows.forEach(meta_window => this.track_new(meta_window));
        }
    }

    /// Adds the needed signals to every new tracked window, and adds blur if
    /// needed.
    /// Accepts only untracked meta windows (i.e no `bms_pid` set)
    track_new(meta_window) {
        // create a pid that will follow the window during its whole life
        const pid = ("" + Math.random()).slice(2, 16);
        meta_window.bms_pid = pid;

        this._log(`new window tracked, pid: ${pid}`);

        // register the blurred window
        this.meta_window_map.set(pid, meta_window);

        // update the blur when wm-class is changed
        this.connections.connect(
            meta_window, 'notify::wm-class',
            _ => this.check_blur(meta_window)
        );

        // update the clip, position, and/or size when the window changes
        this.connections.connect(
            meta_window, 'size-changed',
            _ => this.update_size(pid)
        );
        if (this.settings.applications.STATIC_BLUR) {
            this.connections.connect(
                meta_window, 'position-changed',
                _ => this.update_size(pid)
            );
        }

        // remove the blur when the window is unmanaged
        this.connections.connect(
            meta_window, 'unmanaging',
            _ => this.untrack_meta_window(pid)
        );

        this.check_blur(meta_window);

        if (this.settings.applications.STATIC_BLUR && meta_window.get_client_type() === Meta.WindowClientType.X11) {
            const window_actor = meta_window.get_compositor_private();
            window_actor.connect('child-added', _ => {
                if (!meta_window.blur_actor) {
                    this._warn("can't move blur actor to back, it doesn't exist");
                    return;
                }

                window_actor.set_child_below_sibling(meta_window.blur_actor, null);
            });
        }
    }

    /// Updates the size of the blur actor associated to a meta window from its pid.
    /// Accepts only tracked meta window (i.e `bms_pid` set), be it blurred or not.
    update_size(pid) {
        if (this.meta_window_map.has(pid)) {
            const meta_window = this.meta_window_map.get(pid);
            const blur_actor = meta_window.blur_actor;
            if (blur_actor) {
                if (this.settings.applications.STATIC_BLUR) {
                    const bg_manager = meta_window.bg_manager;
                    const bg_actor_monitor_index = bg_manager.backgroundActor.monitor;
                    const window_monitor_index = meta_window.get_monitor();
                    const monitor = Main.layoutManager.monitors[window_monitor_index];

                    if (bg_actor_monitor_index !== window_monitor_index) {
                        this._log(`application (pid ${pid}) switching to monitor: ${window_monitor_index}`);

                        // Recreate the BackgroundActor on the right monitor. This is necessary to make sure differently
                        // sized monitors have the correct scaled image of the wallpaper.
                        bg_manager._monitorIndex = window_monitor_index;
                        bg_manager._updateBackgroundActor();

                        // Also to fix differently sized monitor issues.
                        blur_actor.width = monitor.width;
                        blur_actor.height = monitor.height;
                    }

                    const frame = meta_window.get_frame_rect();
                    const buffer = meta_window.get_buffer_rect();
                    blur_actor.x = monitor.x - buffer.x;
                    blur_actor.y = monitor.y - buffer.y;

                    // set_clip(x-offset, y-offset, width, height)
                    blur_actor.set_clip(frame.x - monitor.x, frame.y - monitor.y, frame.width, frame.height);
                } else {
                    const allocation = this.compute_allocation(meta_window);
                    blur_actor.x = allocation.x;
                    blur_actor.y = allocation.y;
                    blur_actor.width = allocation.width;
                    blur_actor.height = allocation.height;
                }
            }
        } else
            // the pid was visibly not removed
            this.untrack_meta_window(pid);
    }

    /// Checks if the given actor needs to be blurred.
    /// Accepts only tracked meta window, be it blurred or not.
    ///
    /// In order to be blurred, a window either:
    /// - is whitelisted in the user preferences if not enable-all
    /// - is not blacklisted if enable-all
    ///
    /// Whitelist and blacklist support wildcard patterns:
    /// - * matches any sequence of characters
    /// - ? matches any single character
    /// - Matching is case-insensitive
    check_blur(meta_window) {
        const window_wm_class = meta_window.get_wm_class();
        const enable_all = this.settings.applications.ENABLE_ALL;
        if (window_wm_class)
            this._log(`pid ${meta_window.bms_pid} associated to wm class name ${window_wm_class}`);


        // if we are in blacklist mode and the window is not blacklisted
        // or if we are in whitelist mode and the window is whitelisted
        if (
            window_wm_class !== ""
            && ((enable_all && !matchesAnyPattern(window_wm_class, this._compiled_blacklist))
                || (!enable_all && matchesAnyPattern(window_wm_class, this._compiled_whitelist))
            )
            && [
                Meta.FrameType.NORMAL,
                Meta.FrameType.DIALOG,
                Meta.FrameType.MODAL_DIALOG
            ].includes(meta_window.get_frame_type())
        ) {
            // only blur the window if it is not already done
            if (!meta_window.blur_actor)
                this.create_blur_effect(meta_window);
        }

        // remove blur it is not explicitly whitelisted or un-blacklisted
        else if (meta_window.blur_actor)
            this.remove_blur(meta_window.bms_pid);
    }

    /// Add the blur effect to the window.
    /// Accepts only tracked meta window that is NOT already blurred.
    create_blur_effect(meta_window) {
        const pid = meta_window.bms_pid;
        const window_actor = meta_window.get_compositor_private();

        let blur_actor;

        if (this.settings.applications.STATIC_BLUR) {
            const pipeline = new Pipeline(this.effects_manager, global.blur_my_shell._pipelines_manager, this.settings.applications.PIPELINE);
            const bg_managers = [];
            blur_actor = pipeline.create_background_with_effects(
                meta_window.get_monitor(), bg_managers, window_actor,
                'bms-application-blurred-widget'
            );

            if (bg_managers.length > 0) meta_window.bg_manager = bg_managers[0];
            else // I've never seen this happen, but just in case
                this._warn(`no bg_manager on blur creation for pid ${pid}`);
        } else {
            const pipeline = new DummyPipeline(this.effects_manager, this.settings.applications);
            [blur_actor, meta_window.bg_manager] = pipeline.create_background_with_effect(
                window_actor, 'bms-application-blurred-widget'
            );

            // if hacks are selected, force to repaint the window
            if (this.settings.HACKS_LEVEL === 1) {
                this._log("hack level 1");

                this.paint_signals.disconnect_all_for_actor(blur_actor);
                this.paint_signals.connect(blur_actor, pipeline.effect);
            } else {
                this.paint_signals.disconnect_all_for_actor(blur_actor);
            }
        }

        meta_window.blur_actor = blur_actor;

        // make sure window is blurred in overview
        if (this.settings.applications.BLUR_ON_OVERVIEW)
            this.enforce_window_visibility_on_overview_for(window_actor);

        // update the size
        this.update_size(pid);

        // set the window actor's opacity
        this.set_window_opacity(window_actor, this.settings.applications.OPACITY);

        // update corner radius based on window state
        this.update_corner_radius(meta_window);

        // now set up the signals, for the window actor only: they are disconnected
        // in `remove_blur`, whereas the signals for the meta window are disconnected
        // only when the whole component is disabled

        // listen for window state changes (maximized/fullscreen)
        this.connections.connect(
            meta_window, 'notify::maximized-horizontally',
            _ => this.update_corner_radius(meta_window)
        );
        this.connections.connect(
            meta_window, 'notify::maximized-vertically',
            _ => this.update_corner_radius(meta_window)
        );
        this.connections.connect(
            meta_window, 'notify::fullscreen',
            _ => this.update_corner_radius(meta_window)
        );

        // update the window opacity when it changes, else we don't control it fully
        this.connections.connect(
            window_actor, 'notify::opacity',
            _ => {
                if (this.focused_window_pid != pid)
                    this.set_window_opacity(window_actor, this.settings.applications.OPACITY);
            }
        );

        // hide the blur if window becomes invisible
        if (!window_actor.visible)
            blur_actor.hide();

        this.connections.connect(
            window_actor,
            'notify::visible',
            window_actor => {
                if (window_actor.visible)
                    meta_window.blur_actor.show();
                else
                    meta_window.blur_actor.hide();
            }
        );
    }

    /// With `focus=true`, tells us we are focused on said window (which can be null if
    /// we are not focused anymore). It automatically removes the ancient focus.
    /// With `focus=false`, just remove the focus from said window (which can still be null).
    set_focus_for_window(meta_window, focus = true) {
        let blur_actor = null;
        let window_actor = null;
        let new_pid = null;
        if (meta_window) {
            blur_actor = meta_window.blur_actor;
            window_actor = meta_window.get_compositor_private();
            new_pid = meta_window.bms_pid;
        }

        if (focus) {
            // remove old focused window if any
            if (this.focused_window_pid) {
                const old_focused_window = this.meta_window_map.get(this.focused_window_pid);
                if (old_focused_window)
                    this.set_focus_for_window(old_focused_window, false);
            }
            // set new focused window pid
            this.focused_window_pid = new_pid;
            // if we have blur, hide it and make the window opaque
            if (this.settings.applications.DYNAMIC_OPACITY && blur_actor) {
                blur_actor.hide();
                this.set_window_opacity(window_actor, 255);
            }
        }
        // if we remove the focus and have blur, show it and make the window transparent
        else if (blur_actor) {
            blur_actor.show();
            this.set_window_opacity(window_actor, this.settings.applications.OPACITY);
        }
    }

    /// Makes sure that, when the overview is visible, the window actor will
    /// stay visible no matter what.
    /// We can instead hide the last child of the window actor, which will
    /// improve performances without hiding the blur effect.
    enforce_window_visibility_on_overview_for(window_actor) {
        this.connections.connect(window_actor, 'notify::visible',
            _ => {
                if (this.settings.applications.BLUR_ON_OVERVIEW) {
                    if (
                        !window_actor.visible
                        && Main.overview.visible
                    ) {
                        window_actor.show();
                        window_actor.get_last_child().hide();
                    } else if (
                        window_actor.visible
                    )
                        window_actor.get_last_child().show();
                }
            }
        );
    }

    /// Update the corner radius based on window state (0 for maximized/fullscreen)
    /// if the preferences say so.
    update_corner_radius(meta_window) {
        const is_maximized = meta_window.maximized_horizontally || meta_window.maximized_vertically;
        const is_fullscreen = meta_window.fullscreen;

        let use_0_radius = !this.settings.applications.CORNER_WHEN_MAXIMIZED && (is_maximized || is_fullscreen);

        if (this.settings.applications.STATIC_BLUR) {
            meta_window.bg_manager?._bms_pipeline?.effects.forEach(effect => {
                if (effect.constructor.name === "CornerEffect")
                    effect.straight_corners = use_0_radius;
            })
        } else {
            if (meta_window.bg_manager?._bms_pipeline?.effect)
                meta_window.bg_manager._bms_pipeline.effect.corner_radius = use_0_radius ?
                    0 : this.settings.applications.CORNER_RADIUS;
        }
    }

    /// Update all corners, to use when the setting has been changed.
    update_all_corner_radii() {
        this.meta_window_map.forEach(
            (meta_window, _pid) => this.update_corner_radius(meta_window)
        )
    }

    /// Set the opacity of the window actor that sits on top of the blur effect.
    set_window_opacity(window_actor, opacity) {
        window_actor?.get_children().forEach(child => {
            if (child.name !== "blur-actor" && child.opacity != opacity)
                child.opacity = opacity;
        });
    }

    /// Update the opacity of all window actors.
    set_opacity() {
        let opacity = this.settings.applications.OPACITY;

        this.meta_window_map.forEach(((meta_window, pid) => {
            if (pid != this.focused_window_pid && meta_window.blur_actor) {
                let window_actor = meta_window.get_compositor_private();
                this.set_window_opacity(window_actor, opacity);
            }
        }));
    }

    /// Find the system's window scaling.
    /// If `scale-monitor-framebuffer` experimental feature if on, we don't need to manage scaling.
    /// Else, on wayland, we need to divide by the scale to get the correct result.
    compute_scale(meta_window) {
        // TODO: Drop GNOME <50 compatibility
        const gnome_shell_major_version = parseInt(Config.PACKAGE_VERSION.split('.')[0]);
        const scale_monitor_framebuffer =
            gnome_shell_major_version >= 50 ||
            this.mutter_gsettings
                .get_strv('experimental-features')
                .includes('scale-monitor-framebuffer');
        const is_wayland = gnome_shell_major_version >= 50 || Meta.is_wayland_compositor();
        const monitor_index = meta_window.get_monitor();
        // check if the window is using wayland, or xwayland/xorg for rendering
        return !scale_monitor_framebuffer && is_wayland && meta_window.get_client_type() == 0
            ? Main.layoutManager.monitors[monitor_index].geometry_scale
            : 1;
    }

    /// Compute the size and position for a blur actor.
    /// Coordinates are relative to window buffer's corner.
    compute_allocation(meta_window) {
        const scale = this.compute_scale(meta_window);

        let frame = meta_window.get_frame_rect();
        let buffer = meta_window.get_buffer_rect();

        return {
            x: (frame.x - buffer.x) / scale,
            y: (frame.y - buffer.y) / scale,
            width: frame.width / scale,
            height: frame.height / scale
        };
    }

    change_blur_type() {
        this._log("resetting...");

        this.disable();
        setTimeout(_ => this.enable(), 1);
    }

    change_pipeline() {
        this.update_all_windows();
    }

    /// Removes the blur actor to make a blurred window become normal again.
    /// It however does not untrack the meta window itself.
    /// Accepts a pid corresponding (or not) to a blurred (or not) meta window.
    remove_blur(pid) {
        this._log(`removing blur for pid ${pid}`);

        let meta_window = this.meta_window_map.get(pid);
        if (meta_window) {
            let window_actor = meta_window.get_compositor_private();
            let blur_actor = meta_window.blur_actor;
            let bg_manager = meta_window.bg_manager;

            if (blur_actor && window_actor) {
                // reset the opacity
                this.set_window_opacity(window_actor, 255);

                // remove the blurred actor
                window_actor.remove_child(blur_actor);
                bg_manager._bms_pipeline.destroy();
                bg_manager.destroy();
                blur_actor.destroy();

                // kinda untrack the blurred actor, as its presence is how we know
                // whether we are blurred or not
                delete meta_window.blur_actor;
                delete meta_window.bg_manager;

                // disconnect the signals of the window actor
                this.paint_signals.disconnect_all_for_actor(blur_actor);
                this.connections.disconnect_all_for(window_actor);
            }
        }
    }

    /// Kinda the same as `remove_blur`, but better: it also untracks the window.
    /// This needs to be called when the component is being disabled, else it
    /// would cause havoc by having untracked windows during normal operations,
    /// which is not the point at all!
    /// Accepts a pid corresponding (or not) to a blurred (or not) meta window.
    untrack_meta_window(pid) {
        this.remove_blur(pid);
        let meta_window = this.meta_window_map.get(pid);
        if (meta_window) {
            this.connections.disconnect_all_for(meta_window);
            this.meta_window_map.delete(pid);
        }
    }

    disable() {
        this._log("removing blur from applications...");

        this.service?.unexport();
        delete this.mutter_gsettings;

        this.meta_window_map.forEach((_meta_window, pid) => {
            this.untrack_meta_window(pid);
        });

        this.connections.disconnect_all();
        this.paint_signals.disconnect_all();
    }

    _log(str) {
        if (this.settings.DEBUG)
            console.log(`[Blur my Shell > applications] ${str}`);
    }

    _warn(str) {
        console.warn(`[Blur my Shell > applications] ${str}`);
    }
};
