/* DING: Desktop Icons New Generation for GNOME Shell
 *
 * Gtk4 Port Copyright (C) 2022 - 2025 Sundeep Mediratta (smedius@gmail.com)
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, version 3 of the License.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */


// widget-js-api.js
// Exports the script that gets injected into each WebView.

const transparencyCSS = `
/* Keep the page transparent without nuking widget element backgrounds */
html, body {
    background: transparent !important;
    background-color: transparent !important;
    background-image: none !important;
}
`;

export const CSP_STRICT = `
default-src 'none';
base-uri 'none';
object-src 'none';
frame-ancestors 'none';
form-action 'none';

script-src 'self' ding-widget: 'unsafe-inline';
style-src  'self' ding-widget: 'unsafe-inline';

img-src    'self' ding-widget: data: blob:;
font-src   'self' ding-widget: data:;
media-src  'self' ding-widget: blob:;

connect-src 'self' ding-widget: https: ;
navigate-to 'self';
block-all-mixed-content;

worker-src 'none';
frame-src  'none';
`;

export const CSP_DEV = `
default-src 'none';
base-uri 'none';
object-src 'none';
frame-ancestors 'none';
form-action 'none';

script-src 'self' ding-widget: 'unsafe-inline';
style-src  'self' ding-widget: 'unsafe-inline';

img-src    'self' ding-widget: data: blob:;
font-src   'self' ding-widget: data:;
media-src  'self' ding-widget: blob:;

connect-src
    'self'
    ding-widget:
    https:
    http:
    http://localhost:*
    ws://localhost:*;

worker-src 'none';
frame-src  'none';
`;

export const CSP_RELAXED = `
default-src 'none';
base-uri 'self';
object-src 'none';
frame-ancestors 'none';

script-src
    'self'
    ding-widget:
    'unsafe-inline'
    https:;

style-src
    'self'
    ding-widget:
    'unsafe-inline'
    https:;

img-src    'self' ding-widget: data: blob: https:;
font-src   'self' ding-widget: data: https:;
media-src  'self' ding-widget: blob: https:;

connect-src
    'self'
    ding-widget:
    https:
    http:
    ws:
    wss:;

worker-src blob:;
frame-src  https:;
`;

export const WIDGET_UNAVAILABLE_HTML = `
<!doctype html>
<html>
<head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;display:flex;align-items:center;justify-content:center;font:14px system-ui;background:rgba(0,0,0,0.05);color:#555;">
<div>
<div style="font-weight:600;">Widget unavailable</div>
<div style="font-size:12px;opacity:0.8;">__REASON__</div>
</div>
</body>
</html>`;

export const WIDGET_API =
`(function() {
    'use strict';

    // Avoid re-injecting if the page has already been initialized
    if (window.ding)
        return;

    try {
        const style = document.createElement('style');
        style.id = 'ding-widget-background';
        style.textContent = \`${transparencyCSS}\`;
        document.documentElement.insertAdjacentElement('afterbegin', style);
    } catch (e) {
        // Failing to inject style is non-fatal.
        console.error('ding: failed to inject default style', e);
    }

    // Prevent in-webview reload shortcuts. Widgets should be refreshed only
    // through host-controlled actions, not arbitrary page reload keys.
    window.addEventListener('keydown', function(event) {
        var key = String(event.key || '').toLowerCase();
        if (key === 'f5' || (event.ctrlKey && key === 'r')) {
            event.preventDefault();
            event.stopPropagation();
        }
    }, true);

    // ---------------------------------------------------------------------
    // Local fetch hardening
    // ---------------------------------------------------------------------
    // During widget reload(), WebKit can surface ding-widget:// fetches as
    // generic "access control" errors instead of resolving them when the old
    // DOM is tearing down or the new DOM is not ready yet. Older widgets may
    // immediately retry those rejected fetches, turning a reload transition
    // into a tight loop of local requests. Gate local fetches off while page
    // lifecycle signals say the document is unloading (beforeunload/pagehide/
    // unload), then re-enable them once the new document is shown/loaded
    // again. Outside that transition, keep a small per-URL failure cooldown
    // so repeated local fetch failures also fall back to a tiny synthetic
    // Response, preserving host responsiveness without changing normal network
    // fetch behavior. Prior to this buggy widget fetches during reload() 
    // could cause the entire WebView to become unresponsive with the loop
    // of failed fetches freezing the desktop.

    var _nativeFetch =
        typeof window.fetch === 'function' ? window.fetch.bind(window) : null;
    var _localFetchFailures = new Map();
    var _localFetchBlocked = false;
    var LOCAL_FETCH_FAILURE_WINDOW_MS = 3000;
    var LOCAL_FETCH_FAILURE_MAX_BURST = 8;
    var LOCAL_FETCH_COOLDOWN_MS = 4000;

    function _normalizeLocalFetchUrl(input) {
        try {
            if (typeof input === 'string')
                return String(new URL(input, window.location.href));

            if (input && typeof input.url === 'string')
                return String(new URL(input.url, window.location.href));
        } catch (_e) {}

        return '';
    }

    function _getSyntheticFetchBody(url) {
        if (/\.svg(?:$|[?])/i.test(url))
            return '<svg xmlns="http://www.w3.org/2000/svg"></svg>';
        return '';
    }

    function _getSyntheticFetchMime(url) {
        if (/\.svg(?:$|[?])/i.test(url))
            return 'image/svg+xml';
        if (/\.css(?:$|[?])/i.test(url))
            return 'text/css';
        if (/\.js(?:$|[?])/i.test(url))
            return 'application/javascript';
        if (/\.html?(?:$|[?])/i.test(url))
            return 'text/html';
        return 'text/plain';
    }

    function _buildSyntheticFetchResponse(url) {
        var headers = new Headers({
            'Content-Type': _getSyntheticFetchMime(url),
        });

        return new Response(_getSyntheticFetchBody(url), {
            status: 200,
            headers,
        });
    }

    function _shouldCoolDownLocalFetch(url) {
        var now = Date.now();
        var guard = _localFetchFailures.get(url);
        if (!guard)
            return false;

        if (guard.cooldownUntil && now < guard.cooldownUntil)
            return true;

        if (guard.cooldownUntil && now >= guard.cooldownUntil) {
            _localFetchFailures.delete(url);
            return false;
        }

        return false;
    }

    function _recordLocalFetchFailure(url) {
        var now = Date.now();
        var guard = _localFetchFailures.get(url) || {
            windowStart: now,
            count: 0,
            cooldownUntil: 0,
        };

        if ((now - guard.windowStart) >= LOCAL_FETCH_FAILURE_WINDOW_MS) {
            guard.windowStart = now;
            guard.count = 0;
            guard.cooldownUntil = 0;
        }

        guard.count++;
        if (guard.count > LOCAL_FETCH_FAILURE_MAX_BURST)
            guard.cooldownUntil = now + LOCAL_FETCH_COOLDOWN_MS;

        _localFetchFailures.set(url, guard);
    }

    function _blockLocalFetches() {
        _localFetchBlocked = true;
    }

    function _allowLocalFetches() {
        _localFetchBlocked = false;
        _localFetchFailures.clear();
    }

    window.addEventListener('beforeunload', _blockLocalFetches, true);
    window.addEventListener('pagehide', _blockLocalFetches, true);
    window.addEventListener('unload', _blockLocalFetches, true);
    window.addEventListener('pageshow', _allowLocalFetches, true);
    window.addEventListener('load', _allowLocalFetches, true);

    if (_nativeFetch) {
        window.fetch = function(input, init) {
            var url = _normalizeLocalFetchUrl(input);
            if (!url || !url.startsWith('ding-widget://'))
                return _nativeFetch(input, init);

            if (_localFetchBlocked)
                return Promise.resolve(_buildSyntheticFetchResponse(url));

            if (_shouldCoolDownLocalFetch(url))
                return Promise.resolve(_buildSyntheticFetchResponse(url));

            return _nativeFetch(input, init).catch(function(error) {
                _recordLocalFetchFailure(url);

                if (_shouldCoolDownLocalFetch(url))
                    return _buildSyntheticFetchResponse(url);

                throw error;
            });
        };
    }

    // ---------------------------------------------------------------------
    // Upward channel: widget -> host (via WebKit messageHandler)
    // ---------------------------------------------------------------------

    function post(message) {
        try {
            if (!window.webkit ||
                !window.webkit.messageHandlers ||
                !window.webkit.messageHandlers.dingWidget)
                return;

            window.webkit.messageHandlers.dingWidget.postMessage(
                JSON.stringify(message)
            );
        } catch (e) {
            try {
                console.error('ding: post failed', e);
            } catch (_ignored) {
                // ignore logging failures
            }
        }
    }

    // ---------------------------------------------------------------------
    // Small helper: query parameter parsing
    // ---------------------------------------------------------------------

    function getQueryParam(qs, key) {
        if (!qs || qs.length <= 1)
            return null;

        if (qs.charAt(0) === '?')
            qs = qs.substring(1);

        var parts = qs.split('&');
        for (var i = 0; i < parts.length; i++) {
            var kv = parts[i].split('=');
            if (kv.length === 2 && kv[0] === key) {
                try {
                    return decodeURIComponent(kv[1]);
                } catch (e) {
                    return kv[1];
                }
            }
        }

        return null;
    }

    // ---------------------------------------------------------------------
    // Config cache + configChanged (downward push)
    // ---------------------------------------------------------------------

    var _configCache = {};
    var _configListeners = new Set();

    function _cloneObject(obj) {
        if (!obj || typeof obj !== 'object')
            return {};
        var out = {};
        for (var k in obj) {
            if (Object.prototype.hasOwnProperty.call(obj, k))
                out[k] = obj[k];
        }
        return out;
    }

    function _notifyConfigListeners(meta) {
        var snapshot = _cloneObject(_configCache);

        _configListeners.forEach(function(cb) {
            try {
                cb(snapshot, meta || null);
            } catch (e) {
                try {
                    console.error('ding: configChanged listener failed', e);
                } catch (_ignored) {}
            }
        });
    }

    function _setConfigCache(nextConfig, meta) {
        if (!nextConfig || typeof nextConfig !== 'object')
            nextConfig = {};

        _configCache = _cloneObject(nextConfig);
        _notifyConfigListeners(meta);
    }

    function getConfigCached() {
        return _cloneObject(_configCache);
    }

    // ---------------------------------------------------------------------
    // getConfig request/response plumbing
    // ---------------------------------------------------------------------

    var pending = new Map();
    var msgCounter = 1;
    var _backendPending = new Map();
    var _backendListeners = new Set();

    // Host responds to getConfig by running:
    //   window.postMessage({ _dingInternal: true, requestId, config }, '*');
    window.addEventListener('message', function(event) {
        var data = event.data;
        if (!data || data._dingInternal !== true)
            return;

        var type = data.type || null;

        // --- Backend first ---
        if (type === 'backendEvent') {
            _backendListeners.forEach(function(cb) {
                try {
                    cb(data.name, data.payload);
                } catch (_e) {}
            });
            return;
        }

        if (type === 'backendReply') {
            var requestId = data.requestId;
            if (requestId === undefined || requestId === null)
                return;

            var pendingReq = _backendPending.get(requestId);
            if (!pendingReq)
                return;

            _backendPending.delete(requestId);

            if (data.ok)
                pendingReq.resolve(data.result);
            else
                pendingReq.reject(data.error || {
                    code: 'E_BACKEND',
                    message: 'Backend request failed',
                });

            return;
        }

        // --- Config plumbing (only for config message types) ---
        var requestId = data.requestId || null;
        var config = data.config;

        if (config && typeof config === 'object') {
            _setConfigCache(config, {
                reason: type === 'configChanged'
                ? (data.reason || 'configChanged')
                : 'getConfigReply',
                sourceMode: data.sourceMode || null,
            });
        }

        if (type === 'configChanged')
            return;

        if (!requestId)
            return;

        var resolver = pending.get(requestId);
        if (!resolver)
            return;

        pending.delete(requestId);

        try {
            resolver(config || {});
        } catch (e) {
            try {
                console.error('ding: getConfig resolver failed', e);
            } catch (_ignored) {}
        }
    });

    // ---------------------------------------------------------------------
    // Host state (downward channel)
    // ---------------------------------------------------------------------

    var _hostState = {
        editMode: false,
        widgetEditMode: false,
        selected: false,
        pinned: false,
        hostChromeVisible: false,
        pinnable: false,
        theme: 'light',
        reducedMotion: false,
        direction: 'ltr',
        locale: (typeof navigator !== 'undefined' && navigator.language) ?
            navigator.language :
            'en_US',
    };

    var _hostStateListeners = new Set();

    // ---------------------------------------------------------------------
    // Page-side debug flag:
    // Host can flip this with:
    //   webView.evaluate_javascript("window.DING_DEBUG_HOST_STATE = true;", ...)
    // ---------------------------------------------------------------------
    window.DING_DEBUG_HOST_STATE = false;

    function _debugHostState(msg, data) {
        if (!window.DING_DEBUG_HOST_STATE)
            return;

        try {
            console.log('ding[host-state]', msg, data);
        } catch (_e) {
            // ignore
        }
    }

    function _applyHostStateToDom() {
        try {
            var docEl = document.documentElement;
            var body = document.body;

            if (!docEl || !body)
                return;

            // Direction
            docEl.dir = _hostState.direction || 'ltr';

            // Theme
            body.dataset.theme = _hostState.theme || 'light';

            // Edit mode & selection
            body.classList.toggle('ding-edit-mode', !!_hostState.editMode);
            body.classList.toggle(
                'ding-widget-edit-mode',
                !!_hostState.widgetEditMode
            );
            body.classList.toggle('ding-selected', !!_hostState.selected);
            body.classList.toggle('ding-pinned', !!_hostState.pinned);
            body.classList.toggle(
                'ding-host-chrome-visible',
                !!_hostState.hostChromeVisible
            );

            // Reduced motion:
            body.classList.toggle('ding-reduced-motion', !!_hostState.reducedMotion);
        } catch (_e) {
            // Don't let DOM sync failures break host-state updates
        }
    }

    function _cloneHostState() {
        var out = {};
        for (var k in _hostState) {
            if (Object.prototype.hasOwnProperty.call(_hostState, k))
                out[k] = _hostState[k];
        }
        return out;
    }

    function _notifyHostStateListeners() {
        var snapshot = _cloneHostState();
        _debugHostState('notify', snapshot);

        _applyHostStateToDom();

        _hostStateListeners.forEach(function(cb) {
            try {
                cb(snapshot);
            } catch (e) {
                try {
                    console.error('ding: hostState listener failed', e);
                } catch (_ignored) {}
            }
        });
    }

    function _setHostState(patch) {
        if (!patch || typeof patch !== 'object')
            return;

        _debugHostState('patch', patch);

        var changed = false;
        for (var key in patch) {
            if (!Object.prototype.hasOwnProperty.call(patch, key))
                continue;

            var value = patch[key];
            if (_hostState[key] !== value) {
                _hostState[key] = value;
                changed = true;
            }
        }

        if (changed)
            _notifyHostStateListeners();
    }

    // ---------------------------------------------------------------------
    // Instance ID derivation from URL query parameters
    // ---------------------------------------------------------------------

    var initialInstanceId = null;
    var initialMode = 'widget'; // default

    try {
        var search = (window.location && window.location.search) || '';

        var qmode = getQueryParam(search, 'dingMode');
        if (qmode === 'prefs' || qmode === 'widget')
            initialMode = qmode;

        var qid =
            getQueryParam(search, 'dingInstanceId') ||
            getQueryParam(search, 'widgetInstanceId') ||
            getQueryParam(search, 'instanceId');
        if (qid)
            initialInstanceId = qid;
    } catch (e) {
        // ignore
    }

    // ---------------------------------------------------------------------
    // Public API: window.ding
    // ---------------------------------------------------------------------

    window.ding = {
        apiVersion: 1,
        instanceId: initialInstanceId,
        mode: initialMode,

        // Expose raw post() if widgets want it
        post: post,

        getInstanceId: function() {
            return this.instanceId;
        },

        // -----------------------------
        // Widget -> host helpers
        // -----------------------------

        log: function(message) {
            post({
                type: 'log',
                instanceId: this.instanceId,
                message: String(message),
            });
        },

        saveConfig: function(config) {
            if (!this.instanceId)
                return;

            post({
                type: 'updateConfig',
                instanceId: this.instanceId,
                config: config || {},
            });
        },

        setPinned: function(pinned) {
            if (!this.instanceId)
                return;

            // Floating/pinned HTML widgets may be reparented between host
            // containers. Widget authors should keep important UI state in
            // config or other persistent state rather than in-memory only.
            post({
                type: 'setPinned',
                instanceId: this.instanceId,
                pinned: !!pinned,
            });
        },

        beginPinnedEdit: function(editing) {
            if (!this.instanceId)
                return;

            // Floating edit mode may move the widget to a different host layer.
            // Widgets that support pinning should tolerate a host-triggered
            // reload when that parent change occurs.
            post({
                type: 'beginPinnedEdit',
                instanceId: this.instanceId,
                editing: !!editing,
            });
        },

        beginPinnedWindowMove: function(position) {
            if (!this.instanceId)
                return;

            var x = Number(position && position.x);
            var y = Number(position && position.y);
            var button = Number(position && position.button);
            var timestamp = Number(position && position.timestamp);

            post({
                type: 'beginPinnedWindowMove',
                instanceId: this.instanceId,
                x: Number.isFinite(x) ? x : 0,
                y: Number.isFinite(y) ? y : 0,
                button: Number.isFinite(button) ? button : 1,
                timestamp: Number.isFinite(timestamp) ? timestamp : 0,
            });
        },

        setDraggableRegions: function(regions) {
            if (!this.instanceId)
                return;

            post({
                type: 'setDraggableRegions',
                instanceId: this.instanceId,
                regions: Array.isArray(regions) ? regions : [],
            });
        },

        getConfig: function() {
            if (!this.instanceId)
                return Promise.resolve(null);

            var requestId = msgCounter++;
            return new Promise(function(resolve) {
                pending.set(requestId, resolve);
                post({
                    type: 'getConfig',
                    instanceId: window.ding.instanceId,
                    requestId: requestId,
                });
            });
        },

        // Can return {} if the cache is not initialized yet
        getConfigSync: function() {
            return getConfigCached();
        },

        // -----------------------------
        // Host -> widget helpers
        // -----------------------------

        /**
         * Returns a shallow copy of the current host state:
         * {
         *   editMode, selected, pinned, theme,
         *   reducedMotion, direction, locale
         * }
         */
        getHostState: function() {
            return _cloneHostState();
        },

        /**
         * Subscribe to host state changes.
         * Returns an unsubscribe() function.
         */
        onHostStateChanged: function(cb) {
            if (typeof cb !== 'function')
                return function() {};

            _hostStateListeners.add(cb);

            // Immediately deliver current snapshot
            try {
                cb(_cloneHostState());
            } catch (e) {
                try {
                    console.error('ding: hostState listener failed (initial)', e);
                } catch (_ignored) {}
            }

            return function() {
                _hostStateListeners.delete(cb);
            };
        },

        onConfigChanged: function(cb) {
            if (typeof cb !== 'function')
                return function() {};

            _configListeners.add(cb);

            try {
                cb(
                    _cloneObject(_configCache),
                    { reason: 'initial', sourceMode: null }
                 );
            } catch (_e) {}

            // Return unsubscribe
            return function() {
                _configListeners.delete(cb);
            };
        },

        backendRequest: function(method, params) {
            if (!this.instanceId)
                return Promise.reject(new Error('No instanceId'));

            var requestId = msgCounter++;

            return new Promise(function(resolve, reject) {
                _backendPending.set(requestId, { resolve, reject });
                post({
                    type: 'backendRequest',
                    instanceId: window.ding.instanceId,
                    requestId: requestId,
                    method: method,
                    params: params || {},
                });
            });
        },

        backendSend: function(name, payload) {
            if (!this.instanceId)
                return;

            post({
                type: 'backendSend',
                instanceId: window.ding.instanceId,
                name: name,
                payload: payload || {},
            });
        },

        onBackendEvent: function(cb) {
            if (typeof cb !== 'function')
                return function() {};

            _backendListeners.add(cb);
            return function() {
                _backendListeners.delete(cb);
            };
        },

        // ---------------------------------------------------------------------
        // INTERNAL: host-only entrypoint. The GJS side calls this via
        // evaluate_javascript() to push patches into _hostState.
        // ---------------------------------------------------------------------

        _setHostState: _setHostState,
    };
        
    (function() {
        function doInitialDomSync() {
            try {
                _debugHostState('initial-dom-sync', _cloneHostState());
                _notifyHostStateListeners();
            } catch (_e) {
                // Ignore; we don't want this to break the widget
            }
        }

        if (document.readyState === 'loading') {
            // Body not ready yet; wait for DOMContentLoaded
            document.addEventListener('DOMContentLoaded', function onReady() {
                document.removeEventListener('DOMContentLoaded', onReady);
                doInitialDomSync();
            });
        } else {
            // DOM is already ready (e.g. script injected later)
            doInitialDomSync();
        }
    })();

    // Notify the host that the widget API is ready and has an instanceId
    try {
        post({
            type: 'hostReady',
            instanceId: initialInstanceId || null,
        });
    } catch (_e) {
        // ignore
    }
})();`;
