// ==UserScript==
// @name        Mastodon - Open bsky.app links via Bridgy Fed
// @namespace   https://github.com/tesaguri
// @grant       GM.getValue
// @grant       GM_addValueChangeListener
// @version     1.0.0
// @updateURL   https://github.com/tesaguri/userscripts/raw/main/mastodon-detect-bridge/index.user.js
// @author      Daiki "tesaguri" Mizukami
// @license     GPL-3.0-only; https://www.gnu.org/licenses/gpl-3.0.txt
// @description Open bsky.app links via Bridgy Fed (or optionally via corresponding PDS)
// ==/UserScript==

/**
 * Optional configurations to be stored in the script storage.
 * @typedef {object} Config
 * @property {AtprotoConfig} [atproto]
 */
/**
 * Configurations specific to AT Protocol.
 * @typedef {object} AtprotoConfig
 * @property {AtprotoFallbackBehavior} [fallbackBehavior]
 */
/**
 * Fallback behavior when an atproto resource isn't bridged via Bridgy Fed.
 * - `openPds` - Open the resource via its corresponding PDS endpoint.
 * - `default` - Open the original AppView URL.
 * @typedef {'openPds' | 'default'} AtprotoFallbackBehavior
 */

/**
 * @template T
 * @typedef {(T | null)[] | T?} LdOptional
 */
/**
 * @template T
 * @typedef {[...(T | null)[], T, ...(T | null)[]] | T} LdRequired
 */
/**
 * @typedef {string | { '@id': string } | { id: string }} LdId
 * @typedef {{ '@type': string[] | string } | { type: string[] | string }} HasLdType // `@type` cannot have `null`.
 * @typedef {`did:${string}`} DidString
 * @typedef {HasLdType & { serviceEndpoint: LdRequired<LdId> }} Service
 * @typedef {LdId & { service?: LdOptional<Service> }} DidDocument
 */

(() => {
    // INIT

    addEventListener('click', clickEventListener);
    /**
     * @param {MouseEvent} e
     * @returns {void}
     */
    function clickEventListener(e) {
        if (!(e.target instanceof HTMLElement)) {
            return;
        }
        let target = e.target;
        while (true) {
            if (
                target instanceof HTMLAnchorElement
                && target.classList.contains('unhandled-link')
                && target.href.startsWith('https://bsky.app/profile/')
                && !target.classList.contains('status-url-link')
            ) {
                break;
            }
            if (!target.parentElement) {
                return;
            }
            target = target.parentElement;
        }

        const atUri = AtUri.fromBskyUrl(target.href);
        if (!atUri) {
            return;
        }

        // XXX: We've checked that `e.target` is an `HTMLElement`, but still need to convince
        // `tsc`.
        /** @type {typeof e & { readonly target: HTMLElement }} */
        const event = /** @type {any} */ (e);

        if (!('collection' in atUri) || atUri.collection === 'app.bsky.feed.post') {
            // Speculatively preventing the default action because `preventDefault` would have no
            // effect in the async callback called after checking the bridge status.
            // Instead, we'll retry the click event in the fallback procedure where appropriate.
            e.preventDefault();

            resolveBridgeUri(atUri)
                .then(async bridgeUri => {
                    if (bridgeUri !== undefined) {
                        submitSearch(bridgeUri);
                        return;
                    }
                    await atprotoFallback(event, atUri);
                }).catch(async e => {
                    console.error(e);
                    await atprotoFallback(event, atUri);
                });
        } else {
            atprotoFallback(event, atUri);
        }
    }

    /** @type {Config} */
    let config = Object.create(null);
    const initFallbackBehavior = GM.getValue('atproto').then(setAtprotoConfig);
    GM_addValueChangeListener('atproto', (_name, _oldValue, value) => {
        setAtprotoConfig(value);
    });

    // UTILITIES - Generic

    /**
     * @param {unknown} value
     * @returns {void}
     */
    function setAtprotoConfig(value) {
        if (typeof value === 'object' && value) {
            /** @type {AtprotoConfig} */
            config.atproto = config.atproto || Object.create(null);
            if ('fallbackBehavior' in value) {
                if (typeof value.fallbackBehavior !== 'string') {
                    console.warn(`${GM.info.script.name}: \`config.fallbackBehavior.atproto\` must be a string`);
                    delete config.atproto.fallbackBehavior;
                } else if (value.fallbackBehavior !== 'openPds' && value.fallbackBehavior !== 'default') {
                    console.warn(`${GM.info.script.name}: unknown value for \`config.atproto.fallbackBehavior\`: ${value.fallbackBehavior}`);
                    delete config.atproto.fallbackBehavior;
                } else {
                    config.atproto.fallbackBehavior = value.fallbackBehavior;
                }
            }
        } else {
            console.warn(`${GM.info.script.name}: \`config.fallbackBehavior\` must be an object`);
            delete config.atproto;
        }
    }

    /**
     * @param {string | URL} [url]
     * @param {string} [target]
     * @param {string} [windowFeatures]
     * @returns {ReturnType<typeof open>}
     */
    function safeOpen(url, target, windowFeatures) {
        const defaultWindowFeatures = 'noreferrer';
        return open(url, target, windowFeatures ? `${defaultWindowFeatures},${windowFeatures}` : defaultWindowFeatures);
    }

    /**
     * @param {Response} response
     * @returns {asserts response is { ok: true }}
     */
    function assertResponseIsOk(response) {
        if (!response.ok) {
            throw Error(`HTTP ${response.status}: ${response.url}`);
        }
    }

    // UTILITIES - Mastodon

    /**
     * @param {string} query
     * @param {number} [retryCount]
     * @returns {void}
     */
    function submitSearch(query, retryCount) {
        /** @type {HTMLInputElement | null} */
        let input = document.querySelector('input.search__input');
        if (!input) {
            if ((retryCount ?? 0) > 10) {
                console.error('Cannot find the search input');
                return;
            }
            // Click the "search tab" button to display the search input.
            if (!location.pathname.startsWith('/web/search')) {
                /** @type {HTMLAnchorElement | null} */
                const searchTab = document.querySelector('a[data-preview-title-id="tabs_bar.search"]');
                if (!searchTab) {
                    console.error('Cannot find the search tab button');
                    return;
                }
                searchTab.click();
                input = document.querySelector('input.search__input');
            }
            if (!input) {
                // HACK: Retry until the input appears.
                requestAnimationFrame(() => submitSearch(query, (retryCount ?? 0) + 1));
                return;
            }
        }

        input.focus();

        // <https://hustle.bizongo.in/simulate-react-on-change-on-controlled-components-baa336920e04>
        const valueProperty =
            /** @type {NonNullable<ReturnType<typeof Object.getOwnPropertyDescriptor>>} */
            (Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'));
        const setValue =
            /** @type {NonNullable<typeof valueProperty.set>} */
            (valueProperty.set);
        setValue.call(input, query);
        input.dispatchEvent(new Event('input', { bubbles: true }));
        input.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', bubbles: true }));
    }

    // UTILITIES - DID/JSON-LD

    /**
     * @param {string} s
     * @returns {s is DidString}
     */
    function isDidString(s) {
        return s.startsWith('did:');
    }

    const acceptDidHeaders = new Headers([['accept', 'application/did+ld+json']]);
    /**
     * @typedef {object} ResolveDidInit
     * @property {AbortSignal} [signal]
     */
    /**
     * @param {DidString} did
     * @param {ResolveDidInit} [options]
     * @returns {Promise<DidDocument>}
     */
    async function resolveDid(did, options) {
        let url;
        if (did.startsWith('did:plc:')) {
            url = `https://plc.directory/${did}`;
        } else if (did.startsWith('did:web:')) {
            url = `https://${did.slice(8)}/.well-known/did.json`;
        } else {
            throw new Error(`Unrecognized DID: ${did}`);
        }

        const res = await fetch(url, {
            ...options,
            headers: acceptDidHeaders,
            referrer: '',
        });

        assertResponseIsOk(res);

        const ret = await res.json();
        assertIsDidDocument(ret);
        return ret;
    }

    /**
     * @param {any} x
     * @returns {asserts x is LdId}
     */
    function assertIsLdId(x) {
        if (typeof x !== 'string' && (
            ('@id' in x && typeof x['@id'] !== 'string')
            || ('id' in x && typeof x.id !== 'string')
        )) {
            throw TypeError('Argument is not an `@id`');
        }
    }

    /**
     * @param {any} x
     * @returns {asserts x is HasLdType}
     */
    function assertHasLdType(x) {
        if (
            ('@type' in x && !isLdTypeValue(x['@type']))
            || ('type' in x && !isLdTypeValue(x.type))
        ) {
            throw TypeError('@type must be a string or an array of strings');
        }
    }

    /**
     * @param {any} t
     * @returns {t is string[] | string}
     */
    function isLdTypeValue(t) {
        if (Array.isArray(t)) {
            return t.every(x => typeof x === 'string');
        } else {
            return typeof t === 'string';
        }
    }

    /**
     * @param {any} x
     * @returns {asserts x is DidDocument}
     */
    function assertIsDidDocument(x) {
        assertIsLdId(x);
        // @ts-expect-error // implicitly asserting that `x` is an object.
        'service' in x
            && (x.service === null || asArray(x.service).forEach(assertIsService));
    }

    /**
     * @param {any} x
     * @returns {asserts x is Service}
     */
    function assertIsService(x) {
        assertHasLdType(x);
        if ('serviceEndpoint' in x) {
            for (const serviceEndpoint of asArray(x.serviceEndpoint)) {
                assertIsLdId(serviceEndpoint);
            }
        }
    }

    /**
     * @param {LdId} node
     * @returns {string}
     */
    function ldIdOf(node) {
        if (typeof node === 'string') {
            return node;
        } else if ('@id' in node) {
            return node['@id'];
        } else {
            return node.id;
        }
    }

    /**
     * @param {HasLdType} node
     * @returns {string[]}
     */
    function ldTypeOf(node) {
        if ('@type' in node) {
            return asArray(node['@type']);
        } else {
            return asArray(node.type);
        }
    }

    /**
     * @template T
     * @overload
     * @param {LdRequired<T>} value
     * @returns {T[]}
     */
    /**
     * @template T
     * @param {LdOptional<T> | undefined} value
     * @returns {(T | null)[]}
     */
    /**
     * @template T
     * @param {LdOptional<T> | undefined} value
     * @returns {(T | null)[]}
     */
    function asArray(value) {
        if (Array.isArray(value)) {
            return value;
        } else if (value === null || value === undefined) {
            return [];
        } else {
            return [value];
        }
    }

    /**
     * @template T
     * @overload
     * @param {LdRequired<T>} set
     * @returns {T}
     */
    /**
     * @template T
     * @overload
     * @param {LdOptional<T> | undefined} set
     * @returns {(T | undefined)?}
     */
    /**
     * @template T
     * @param {LdOptional<T> | undefined} set
     * @returns {(T | undefined)?}
     */
    function firstOfSet(set) {
        if (Array.isArray(set)) {
            return set.find(x => x !== null);
        } else {
            return set;
        }
    }

    // UTILITIES - AT Protocol

    class AtUri {
        /**
         * @typedef {AtUriAuthority | AtUriCollection | AtUriRecord} AtUriComponents
         * @typedef {{ authority: string }} AtUriAuthority
         * @typedef {AtUriAuthority & { collection: string }} AtUriCollection
         * @typedef {AtUriCollection & { rkey: string }} AtUriRecord
         * @typedef {AtUri & { components: AtUriComponents & { authority: DidString } }} AtUriWithDid
         */

        /** @type {AtUriComponents} */
        components;

        /**
         * @overload
         * @param {string} authority
         */
        /**
         * @overload
         * @param {string} authority
         * @param {string} collection
         */
        /**
         * @overload
         * @param {string} authority
         * @param {string} collection
         * @param {string} rkey
         */
        /**
         * @overload
         * @param {AtUriComponents} components
         */
        /**
         * @param {string | AtUriComponents} authorityOrComponents
         * @param {string} [collection]
         * @param {string} [rkey]
         */
        constructor(authorityOrComponents, collection, rkey) {
            if (typeof authorityOrComponents === 'object') {
                this.components = authorityOrComponents;
            } else {
                /** @type {Partial<AtUriRecord> & AtUriAuthority} */
                const components = { authority: authorityOrComponents };
                if (collection !== undefined) {
                    components.collection = collection;
                    if (rkey !== undefined) {
                        components.rkey = rkey;
                    }
                }
                this.components = components;
            }
        }

        /**
         * @param {string} url
         * @returns {AtUri | void}
         */
        static fromBskyUrl(url) {
            const segments = url.split('/');
            const authority = segments[4];
            if (authority === undefined) {
                return;
            }
            const bskyCollection = segments[5];
            if (bskyCollection === undefined) {
                return new this(authority);
            }
            const rkey = segments[6];
            let collection;
            if (rkey !== undefined) {
                switch (bskyCollection) {
                    case 'post':
                        collection = 'app.bsky.feed.post';
                        break;
                    case 'feed':
                        collection = 'app.bsky.feed.generator';
                        break;
                }
            }
            if (collection !== undefined) {
                if (rkey !== undefined) {
                    return new this(authority, collection, rkey);
                }
                return new this(authority, collection);
            }
        }

        /** @returns {typeof this.components.authority} */
        get authority() {
            return this.components.authority;
        }

        /** @returns {this is AtUriWithDid} */
        authorityIsDidString() {
            return isDidString(this.components.authority);
        }

        /**
         * @typedef {object} WithDidAuthorityInit
         * @property {AbortSignal} [signal]
         */
        /**
         * @param {WithDidAuthorityInit} [options]
         * @returns {Promise<AtUriWithDid>}
         */
        async withDidAuthority(options) {
            if (this.authorityIsDidString()) {
                return this;
            } else {
                const did = await resolveAtprotoHandle(this.authority, options);
                if (!did) {
                    throw Error(`Unable to resolve handle ${this.pickAuthority()}`);
                }
                /** @type {AtUriComponents & { authority: DidString }} */
                const components = { ...this.components, authority: did };
                return /** @type {AtUriWithDid} */ (new AtUri(components));
            }
        }

        pickAuthority() {
            if ('collection' in this) {
                return new AtUri(this.authority);
            } else {
                return this;
            }
        }

        toString() {
            let ret = `at://${this.authority}`;
            if ('collection' in this.components) {
                ret += `/${this.components.collection}`;
                if ('rkey' in this.components) {
                    ret += `/${this.components.rkey}`;
                }
            }
            return ret;
        }
    }

    /**
     * @param {Event & { readonly target: HTMLElement }} event
     * @param {AtUri} uri
     * @returns {Promise<void>}
     */
    async function atprotoFallback(event, uri) {
        await initFallbackBehavior;
        switch (config.atproto?.fallbackBehavior) {
            case 'openPds':
                try {
                    event.preventDefault();
                    const uriWithDid = await uri.withDidAuthority();
                    const url = await pdsXrpcUrlForAtUri(uriWithDid);
                    if (url !== undefined) {
                        safeOpen(url);
                        break;
                    }
                    console.warn(`Missing PDS for ${uri.authority}${uri.authorityIsDidString() ? '' : ` (${uriWithDid.authority})`}`);
                } catch (e) {
                    console.error(e);
                }
                // Fall-through
            default:
                if (event.defaultPrevented) {
                    try {
                        removeEventListener('click', clickEventListener);
                        // Using `click()` because `event.target.dispatchEvent(event)` won't open the link.
                        event.target.click();
                    } finally {
                        addEventListener('click', clickEventListener);
                    }
                }
        }
    }

    const acceptDnsJsonHeaders = new Headers([['accept', 'application/dns-json']]);
    /** @type {Record<string, DidString>} */
    const resolvedHandles = Object.create(null);
    /**
     * @typedef {object} ResolveAtprotoHandleInit
     * @property {AbortSignal} [signal]
     */
    /**
     * @param {string} handle
     * @param {ResolveAtprotoHandleInit} [options]
     * @returns {Promise<DidString | void>}
     */
    async function resolveAtprotoHandle(handle, options) {
        handle = handle.toLowerCase();
        if (handle in resolvedHandles) {
            return resolvedHandles[handle];
        }
        const ret = await resolveAtprotoHandleInner(handle, options);
        if (ret) {
            resolvedHandles[handle] = ret;
            return ret;
        }
    }

    /**
     * @param {string} handle
     * @param {ResolveAtprotoHandleInit} [options]
     * @returns {Promise<DidString | void>}
     */
    async function resolveAtprotoHandleInner(handle, options) {
        try {
            const res = await fetch(`https://cloudflare-dns.com/dns-query?name=_atproto.${handle}&type=TXT`, {
                ...options,
                headers: acceptDnsJsonHeaders,
                referrer: '',
            });
            if (res.ok) {
                // We are intentionally loose about the `any` type here because a `TypeError` would
                // be caught by the `try` block just fine.
                const answers = /** @type {any} */ (await res.json()).Answer;
                if (Array.isArray(answers)) {
                    const expectedName = `_atproto.${handle}`;
                    for (const answer of answers) {
                        /** @type {any} */
                        const ans = answer;
                        if (
                            ans.name === expectedName
                            && ans.type === 16
                            && ans.data?.startsWith('"did=did:')
                            && ans.data.endsWith('"')
                        ) {
                            return ans.data.slice(5, -1);
                        }
                    }
                }
            }
        } catch {
            // Fall back on well-known
        }
        try {
            const res = await fetch(`https://${handle}/.well-known/atproto-did`, {
                referrer: '',
            });
            if (res.ok) {
                const body = (await res.text()).trim();
                if (isDidString(body)) {
                    return body;
                }
            }
        } catch {
            // noop
        }
    }

    /**
     * @param {DidDocument} doc
     * @returns {string | void}
     */
    function pdsFromDidDoc(doc) {
        const service = asArray(doc.service)
            .find(service => !!service && ldTypeOf(service).includes('AtprotoPersonalDataServer'));
        if (service) {
            return ldIdOf(firstOfSet(service.serviceEndpoint));
        }
    }

    /**
     * @param {AtUriWithDid} uri
     * @returns {Promise<string | void>}
     */
    async function pdsXrpcUrlForAtUri(uri) {
        const pds = pdsFromDidDoc(await resolveDid(uri.components.authority));
        if (pds === undefined) {
            return;
        }

        if ('collection' in uri.components) {
            if ('rkey' in uri.components) {
                return getComAtprotoRepoGetRecord(pds, {
                    repo: uri.components.authority,
                    collection: uri.components.collection,
                    rkey: uri.components.rkey,
                });
            } else {
                return getComAtprotoRepoListRecords(pds, {
                    repo: uri.components.authority,
                    collection: uri.components.collection,
                });
            }
        } else {
            return getComAtprotoRepoDescribeRepo(pds, {
                repo: uri.components.authority,
            });
        }
    }

    // UTILITIES - XRPC

    /**
     * @typedef {object} GetComAtprotoRepoDescribeRepoInit
     * @property {DidString} repo
     */
    /**
     * @param {string} pds
     * @param {GetComAtprotoRepoDescribeRepoInit} params
     */
    function getComAtprotoRepoDescribeRepo(pds, { repo }) {
        return `${pds}/xrpc/com.atproto.repo.describeRepo?repo=${repo}`;
    }

    /**
     * @typedef {object} GetComAtprotoRepoGetRecordInit
     * @property {DidString} repo
     * @property {string} collection
     * @property {string} rkey
     */
    /**
     * @param {string} pds
     * @param {GetComAtprotoRepoGetRecordInit} params
     */
    function getComAtprotoRepoGetRecord(pds, { repo, collection, rkey }) {
        return `${pds}/xrpc/com.atproto.repo.getRecord?repo=${repo}&collection=${collection}&rkey=${rkey}`;
    }

    /**
     * @template {object} [T=JSONObject]
     * @typedef {object} GetComAtprotoRepoGetRecordResponse<T>
     * @property {T} value
     */
    /**
     * @param {JSONValue} x
     * @returns {asserts x is GetComAtprotoRepoGetRecordResponse}
     */
    function assertIsGetComAtprotoRepoGetRecordResponse(x) {
        // @ts-expect-error
        if (typeof x.value !== 'object') {
            throw TypeError('`value` must be an object');
        }
    }

    /**
     * @typedef {object} GetComAtprotoRepoListRecordsInit
     * @property {DidString} repo
     * @property {string} collection
     */
    /**
     * @param {string} pds
     * @param {GetComAtprotoRepoListRecordsInit} params
     */
    function getComAtprotoRepoListRecords(pds, { repo, collection }) {
        return `${pds}/xrpc/com.atproto.repo.listRecords?repo=${repo}&collection=${collection}`;
    }

    // UTILITIES - Bridgy Fed

    /**
     * Represents an AT Protocol resource bridged to the Fediverse or bridged from the
     * Fediverse/Web.
     */
    class BridgedAtprotoResource {
        /**
         * Returns the corresponding Activity Streams resource URI.
         * @abstract
         * @returns {string}
         */
        as2Url() {
            throw Error('Not implemented');
        }
    }

    class BridgeFromBsky extends BridgedAtprotoResource {
        /**
         * @param {AtUri} uri
         */
        constructor(uri) {
            super();
            this.uri = uri;
        }

        /**
         * @param {AtUri} uri
         * @returns {string}
         */
        static bridgeUriFromAtUri(uri) {
            if ('collection' in uri.components) {
                return `https://bsky.brid.gy/convert/ap/${uri}`;
            } else {
                return `https://bsky.brid.gy/ap/${uri.authority}`;
            }
        }

        /** @override */
        as2Url() {
            return BridgeFromBsky.bridgeUriFromAtUri(this.uri);
        }
    }

    class BridgeFromAnotherProtocol extends BridgedAtprotoResource {
        /**
         * @param {string} originalUrl
         */
        constructor(originalUrl) {
            super();
            this.originalUrl = originalUrl;
        }
    }

    class BridgeFromActivityPub extends BridgeFromAnotherProtocol {
        /** @override */
        as2Url() {
            return this.originalUrl;
        }
    }

    class BridgeFromWeb extends BridgeFromAnotherProtocol {
        /**
         * @param {string} url
         * @returns {string}
         */
        static bridgeUriFromWebUrl(url) {
            return `https://web.brid.gy/r/${url}`;
        }

        /** @override */
        as2Url() {
            return BridgeFromWeb.bridgeUriFromWebUrl(this.originalUrl);
        }
    }

    const bridgeNotFound = Symbol('Bridge not found');
    /** @type {Record<string, BridgedAtprotoResource>} */
    const bridgedResources = Object.create(null);
    /**
     * Checks if an AT Protocol resource is bridged via Bridgy Fed (both *from Bluesky* to the
     * Fediverse or from the Fediverse/Web *to Bluesky*) and returns the corresponding Activity
     * Streams resource URI if it's bridged.
     * @param {AtUri} uri
     * @returns {Promise<string | void>}
     */
    async function resolveBridgeUri(uri) {
        const cached = bridgedResources[uri.toString()];
        if (cached) {
            return cached.as2Url();
        }

        const authorityQueries = [uri.pickAuthority().toString()];
        const cachedDid = resolvedHandles[uri.authority];
        if (cachedDid) {
            authorityQueries.push(new AtUri(cachedDid).toString());
        }
        for (const authority of authorityQueries) {
            const cachedAuthority = bridgedResources[authority];
            if (cachedAuthority) {
                if (cachedAuthority instanceof BridgeFromBsky) {
                    return BridgeFromBsky.bridgeUriFromAtUri(uri);
                } else if (!('collection' in uri.components)) {
                    return cachedAuthority.as2Url();
                }
            }
        }

        // The resource may either be bridged *from Bluesky* to the Fediverse or from the
        // Fediverse/Web *to Bluesky* (or not bridged at all), both of which case require different
        // methods to determine the corresponding Activity Streams resource URI. So we'll run both
        // the resolvers concurrently.
        const controller = new AbortController();
        const promises = [
            resolveBskyBridgeUri(uri, controller.signal),
            resolveBridgeUriFromAtprotoRecord(uri, controller.signal),
        ].map(p => p.then(ret => {
            if (ret === undefined) {
                // Throw a value to prevent short-circuiting.
                throw bridgeNotFound;
            }
            return ret;
        }));
        try {
            return await Promise.any(promises);
        } catch (e) {
            if (e instanceof AggregateError) {
                const errors = e.errors.filter(e => e !== bridgeNotFound);
                if (errors.length === 0) {
                    return;
                } else if (errors.length === 1) {
                    e = errors[0];
                } else {
                    e.errors = errors;
                }
            }
            throw e;
        } finally {
            controller.abort();
        }
    }

    const acceptAs2Headers = new Headers([
        ['accept', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'],
    ]);
    /**
     * Checks if an AT Protocol resource is bridged to the Fediverse via Bridgy Fed and returns the
     * bridged Activity Stream resource URI if it's bridged.
     * @param {AtUri} uri
     * @param {AbortSignal} signal
     * @returns {Promise<string | void>}
     */
    async function resolveBskyBridgeUri(uri, signal) {
        const bskyBridgeUri = BridgeFromBsky.bridgeUriFromAtUri(uri);
        const res = await fetch(bskyBridgeUri, {
            method: 'HEAD',
            headers: acceptAs2Headers,
            referrer: '',
            signal,
        });
        if (res.ok) {
            const authority = uri.pickAuthority();
            bridgedResources[authority.toString()] = new BridgeFromBsky(authority);
            return bskyBridgeUri;
        }
    }

    /**
     * Checks if an AT Protocol resource is bridged from the Fediverse or the Web via Bridgy Fed and
     * returns the original/bridged Activity Streams resource URI if it's bridged.
     * @param {AtUri} uri
     * @param {AbortSignal} signal
     * @returns {Promise<string | void>}
     */
    async function resolveBridgeUriFromAtprotoRecord(uri, signal) {
        if ('collection' in uri.components && !('rkey' in uri.components)) {
            // Not sure how to map a colleciton-only URI to a bridge URL.
            return;
        }

        const uriWithDid = await uri.withDidAuthority({ signal });
        const didDoc = await resolveDid(uriWithDid.components.authority, { signal });
        const pds = pdsFromDidDoc(didDoc);
        if (pds === undefined) {
            return;
        }

        const profilePromise = resolveBskyProfile(pds, uriWithDid, signal);
        if (
            !('collection' in uri.components)
            || (
                uri.components.collection === 'app.bsky.actor.profile'
                && uri.components.rkey === 'self'
            )
        ) {
            return (await profilePromise)?.as2Url();
        }

        // XXX: Checking again to make `tsc` happy.
        if (!('rkey' in uri.components)) {
            return;
        }
        // Storing properties as local variables as the result of control-flow analysis doesn't seem
        // to propagate to inside closures, which is fair.
        const { collection, rkey } = uri.components;

        // `profilePromise` may have resolved immediately with `void` (in the quite common case of
        // non-bridged cached profiles), in which case we don't want to even initiate the HTTP
        // request for `recordPromise`, so we delay the request until the next event cycle, and
        // schedule the abort signal to abort before then if `profilePromise` has fulfilled with
        // `void`.
        const controller = new AbortController();
        if (signal.aborted) {
            controller.abort();
        } else {
            signal.addEventListener('abort', () => controller.abort());
        }
        profilePromise.then(profile => {
            if (!profile) {
                controller.abort();
            }
        }).catch(() => controller.abort());

        const originalUrlPromise = new Promise(resolve => setTimeout(resolve)).then(async () => {
            const res = await fetch(getComAtprotoRepoGetRecord(pds, {
                repo: uriWithDid.components.authority,
                collection,
                rkey,
            }), {
                referrer: '',
                signal: controller.signal,
            });
            assertResponseIsOk(res);

            const json = await res.json();
            assertIsGetComAtprotoRepoGetRecordResponse(json);

            const originalUrl = json.value.bridgyOriginalUrl;
            if (typeof originalUrl === 'string') {
                return originalUrl;
            }

            return;
        });

        /** @type {Promise<Awaited<profilePromise | originalUrlPromise>>[]} */
        const promises = [profilePromise, originalUrlPromise];
        const mapped = promises.map(p => p.then(x => {
            if (x === undefined) {
                // Throw a value to trigger short-circuiting.
                throw bridgeNotFound;
            }
            return x;
        }));
        /** @type {NonNullable<Awaited<typeof profilePromise>>} */
        let profile;
        /** @type {NonNullable<Awaited<typeof originalUrlPromise>>} */
        let originalUrl;
        try {
            [profile, originalUrl] =
                /** @type {[typeof profile, typeof originalUrl]} */
                (await Promise.all(mapped));
        } catch (e) {
            if (e === bridgeNotFound) {
                return;
            }
            throw e;
        }

        /** @type {BridgeFromAnotherProtocol} */
        // @ts-expect-error
        const meta = new profile.constructor(originalUrl);
        bridgedResources[uri.toString()] = meta;
        if (uriWithDid !== uri) {
            bridgedResources[uri.toString()] = meta;
        }

        return meta.as2Url();
    }

    /**
     * @param {string} pds
     * @param {AtUriWithDid} uri
     * @param {AbortSignal} signal
     * @returns {Promise<BridgeFromAnotherProtocol | void>}
     */
    async function resolveBskyProfile(pds, uri, signal) {
        const authorityUri = uri.pickAuthority().toString();
        const cached = bridgedResources[authorityUri];
        if (cached) {
            if (cached instanceof BridgeFromAnotherProtocol) {
                return cached;
            } else {
                return;
            }
        }

        const res = await fetch(getComAtprotoRepoGetRecord(pds, {
            repo: uri.components.authority,
            collection: 'app.bsky.actor.profile',
            rkey: 'self',
        }), {
            referrer: '',
            signal,
        });
        assertResponseIsOk(res);
        const json = await res.json();
        assertIsGetComAtprotoRepoGetRecordResponse(json);

        const originalUrl = json.value.bridgyOriginalUrl;
        if (typeof originalUrl !== 'string') {
            return;
        }

        let ret;
        switch (originProtocolFromBskyProfileRecordValue(json.value)) {
            case 'activitypub':
                ret = new BridgeFromActivityPub(originalUrl);
                break;
            case 'web':
                ret = new BridgeFromWeb(originalUrl);
                break;
        }
        if (!ret) {
            return;
        }

        bridgedResources[authorityUri] = ret;
        return ret;
    }

    /**
     * @param {JSONObject} value
     * @returns {'activitypub' | 'web' | void}
     */
    function originProtocolFromBskyProfileRecordValue(value) {
        if (typeof value.labels !== 'object' || Array.isArray(value.labels) || !Array.isArray(value.labels?.values)) {
            return;
        }
        for (const label of value.labels.values) {
            if (typeof label !== 'object' || Array.isArray(label)) {
                continue;
            }
            switch (label?.val) {
                case 'bridged-from-bridgy-fed-activitypub':
                    return 'activitypub';
                case 'bridged-from-bridgy-fed-web':
                    return 'web';
            }
        }
    }
})();