// The one place in this tree that names `browser` or `chrome`.
//
// The two targets do not agree on the namespace, and they disagree about the
// call shape only in which one is native. Chrome MV3 exposes `chrome.*`,
// where tabs, windows and messaging take a trailing callback and report
// failure through the global `chrome.runtime.lastError`. Firefox MV2 exposes
// `browser.*`, where those same methods return promises — but, measured on
// Firefox 153.0.3, it ALSO honours a trailing Chrome-style callback, returns
// no promise when one is given, and populates `browser.runtime.lastError`.
// The callback code that predated this module therefore ran on both, and
// https://git.eeqj.de/sneak/AutistMask/issues/153 was filed on the belief
// that it did not. This module exists for uniformity, not for repair: the
// tree used to resolve the namespace with a ternary at six call sites and
// then mix promise-form storage with callback-form messaging.
//
// The strategy is promises out, everywhere: one namespace, one call shape,
// composing with the `async` handlers in the background. Callers `await`;
// nothing outside this file has to know which browser it is running on.
//
// Two deliberate asymmetries, because they are what the browsers actually do
// rather than what a uniform-looking shim would pretend:
//
//  - Storage is called in its PROMISE form on both namespaces.
//    `chrome.storage.local.get()` returns a promise on MV3 and the popup
//    already depends on that — src/shared/state.js has always awaited it.
//    Wrapping it in a callback here would be a change, not a fix.
//  - notify() sends without a callback. It is for a message whose answer
//    nobody reads; appending a callback would only manufacture a
//    lastError/rejection for a receiver that was never expected to reply.
//
// Everything is resolved on use rather than captured at module load. The MV3
// service worker is torn down and re-evaluated repeatedly, and the unit
// suite installs its stubs on `global.chrome` around a require().

// The extension API namespace, preferring `browser.*` where it exists.
//
// Whole-namespace, never per-method: mixing `browser.tabs` with
// `chrome.windows` would also mix promise and callback semantics inside a
// single call path, which is the bug this module exists to remove.
function extensionApi() {
    if (typeof browser !== "undefined" && browser) return browser;
    if (typeof chrome !== "undefined" && chrome) return chrome;
    return null;
}

// True when the resolved namespace is the promise-flavoured one.
//
// It doubles as "this is the Gecko/MV2 build", which is a second question
// with the same answer and one real caller: src/content/index.js has to
// inject the inpage provider itself there, because MV2 has no
// `"world": "MAIN"` for a manifest-declared content script.
function hasBrowserNamespace() {
    return typeof browser !== "undefined" && !!browser;
}

function namespaceMember(name) {
    const api = extensionApi();
    return (api && api[name]) || null;
}

function runtimeApi() {
    return namespaceMember("runtime");
}

function tabsApi() {
    return namespaceMember("tabs");
}

function windowsApi() {
    return namespaceMember("windows");
}

function alarmsApi() {
    return namespaceMember("alarms");
}

// The toolbar button. MV3 calls it `action`, MV2 calls it `browserAction`.
function actionApi() {
    const api = extensionApi();
    if (!api) return null;
    return api.action || api.browserAction || null;
}

// `storage.local`, or null in a context that has no storage permission.
//
// Null rather than a throw for the one caller that genuinely degrades:
// src/shared/phishingDomains.js falls back to its vendored blocklist and does
// its own null check. Everything that reads or writes the wallet goes through
// storageGet()/storageSet(), which reject instead — see there.
function storageLocal() {
    const storage = namespaceMember("storage");
    return (storage && storage.local) || null;
}

// The callback-path error channel. Read only from inside an appended
// callback, i.e. only on the `chrome.*` path, where it is the sole way a
// failure is reported. The background's three explicit lastError checks are
// gone because invoke() turns it into a rejection before any caller sees it.
function lastError() {
    const runtime = runtimeApi();
    return (runtime && runtime.lastError) || null;
}

// Call `owner[method](...args)` and return a promise for its result.
//
// On the promise namespace the method already returns one. On the callback
// namespace the callback is appended here and lastError becomes a rejection,
// because a caller holding a promise has nowhere to check a global flag.
function invoke(owner, method, ...args) {
    if (!owner || typeof owner[method] !== "function") {
        return Promise.reject(
            new Error(
                "extension API " +
                    method +
                    "() is not available in this context",
            ),
        );
    }
    if (hasBrowserNamespace()) {
        try {
            return Promise.resolve(owner[method](...args));
        } catch (e) {
            return Promise.reject(e);
        }
    }
    return new Promise((resolve, reject) => {
        owner[method](...args, (result) => {
            const err = lastError();
            if (err) reject(new Error(err.message || String(err)));
            else resolve(result);
        });
    });
}

/**
 * Send a message to the extension's own contexts and resolve with the reply.
 *
 * Rejects when nothing is listening, on both browsers. A caller that does not
 * care must say so — see notify().
 *
 * @param {Object} message
 * @returns {Promise<*>} the receiver's response.
 */
function sendMessage(message) {
    return invoke(runtimeApi(), "sendMessage", message);
}

/**
 * Send a message nobody is expected to answer, and swallow the fact that
 * nobody did.
 *
 * @param {Object} message
 * @returns {void}
 */
function notify(message) {
    const runtime = runtimeApi();
    if (!runtime || typeof runtime.sendMessage !== "function") return;
    const result = runtime.sendMessage(message);
    // MV3 hands back a promise for a one-argument send, and it rejects when
    // the background is not listening. Unhandled, that surfaces as an error
    // the e2e suites fail the run on.
    if (result && typeof result.catch === "function") result.catch(() => {});
}

// These two carry the wallet. A missing `storage.local` has to reject and not
// default: resolving {} would make an existing wallet read back as no wallet,
// and resolving a no-op write would discard the user's state with nothing
// logged. A caller that wants to degrade takes storageLocal() directly.
function storageUnavailable(method) {
    return Promise.reject(
        new Error("extension storage.local is not available: " + method),
    );
}

/**
 * @param {string|string[]|Object} keys
 * @returns {Promise<Object>} the stored items.
 * @throws rejects where `storage.local` is absent.
 */
function storageGet(keys) {
    const storage = storageLocal();
    if (!storage) return storageUnavailable("get");
    return Promise.resolve(storage.get(keys));
}

/**
 * @param {Object} items
 * @returns {Promise<void>}
 * @throws rejects where `storage.local` is absent.
 */
function storageSet(items) {
    const storage = storageLocal();
    if (!storage) return storageUnavailable("set");
    return Promise.resolve(storage.set(items));
}

/**
 * Erase stored keys. The one caller is the destructive reset on the recovery
 * screen (src/popup/views/stateRecovery.js), which is the only way out of a
 * profile no build can read; it rejects rather than defaulting for the same
 * reason the two above do — a reset that silently did nothing would leave the
 * user in the dead end they were promised an exit from.
 *
 * @param {string|string[]} keys
 * @returns {Promise<void>}
 * @throws rejects where `storage.local` is absent.
 */
function storageRemove(keys) {
    const storage = storageLocal();
    if (!storage) return storageUnavailable("remove");
    return Promise.resolve(storage.remove(keys));
}

/**
 * @param {Object} queryInfo
 * @returns {Promise<Array>} the matching tabs.
 */
function tabsQuery(queryInfo) {
    return invoke(tabsApi(), "query", queryInfo);
}

/**
 * Send a message to one tab's content script.
 *
 * Rejects for a tab that has no receiver, which is most of them. That
 * rejection is the promise-shaped replacement for the runtime.lastError
 * checks the broadcast helpers used to make, and callers ignore it the same
 * way.
 *
 * @param {number} tabId
 * @param {Object} message
 * @returns {Promise<*>}
 */
function tabsSendMessage(tabId, message) {
    return invoke(tabsApi(), "sendMessage", tabId, message);
}

/**
 * @param {Object} createData
 * @returns {Promise<Object>} the created window.
 */
function windowsCreate(createData) {
    return invoke(windowsApi(), "create", createData);
}

/**
 * @returns {Promise<Object>} the last focused window.
 */
function windowsGetLastFocused() {
    return invoke(windowsApi(), "getLastFocused");
}

/**
 * @param {number} windowId
 * @returns {Promise<void>}
 */
function windowsRemove(windowId) {
    return invoke(windowsApi(), "remove", windowId);
}

module.exports = {
    actionApi,
    alarmsApi,
    extensionApi,
    hasBrowserNamespace,
    notify,
    runtimeApi,
    sendMessage,
    storageGet,
    storageLocal,
    storageRemove,
    storageSet,
    tabsApi,
    tabsQuery,
    tabsSendMessage,
    windowsApi,
    windowsCreate,
    windowsGetLastFocused,
    windowsRemove,
};
