import { dnsLink } from '@helia/dnslink';
import { ipnsResolver } from '@helia/ipns';
import { isPeerId, isPublicKey } from '@libp2p/interface';
import { CID } from 'multiformats/cid';
import { CustomProgressEvent } from 'progress-events';
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string';
import { toString as uint8ArrayToString } from 'uint8arrays/to-string';
import { CarPlugin } from "./plugins/plugin-handle-car.js";
import { IpldPlugin } from "./plugins/plugin-handle-ipld.js";
import { IpnsRecordPlugin } from "./plugins/plugin-handle-ipns-record.js";
import { RawPlugin } from "./plugins/plugin-handle-raw.js";
import { TarPlugin } from "./plugins/plugin-handle-tar.js";
import { UnixFSPlugin } from "./plugins/plugin-handle-unixfs.js";
import { URLResolver } from "./url-resolver.js";
import { abbreviate, abbreviateAddress } from "./utils/abbreviate.js";
import { contentTypeParser } from "./utils/content-type-parser.js";
import { getContentType, getSupportedContentTypes, CONTENT_TYPE_OCTET_STREAM, MEDIA_TYPE_IPNS_RECORD, CONTENT_TYPE_IPNS } from "./utils/content-types.js";
import { errorToObject } from "./utils/error-to-object.js";
import { errorToResponse } from "./utils/error-to-response.js";
import { getETag, ifNoneMatches } from "./utils/get-e-tag.js";
import { getRangeHeader } from "./utils/get-range-header.js";
import { stringToIpfsUrl } from "./utils/parse-resource.js";
import { setCacheControlHeader } from "./utils/response-headers.js";
import { notAcceptableResponse, notImplementedResponse, notModifiedResponse } from "./utils/responses.js";
import { ServerTiming } from "./utils/server-timing.js";
/**
 * Retypes the `.signal` property of the options from
 * `AbortSignal | null | undefined` to `AbortSignal | undefined`.
 */
function convertOptions(options) {
    if (options == null) {
        return;
    }
    return {
        ...options,
        signal: options?.signal == null ? undefined : options?.signal
    };
}
/**
 * Returns true if the quest is only for an IPNS record
 */
function isIPNSRecordRequest(headers) {
    const acceptHeaders = headers.get('accept')?.split(',') ?? [];
    if (acceptHeaders.length !== 1) {
        return false;
    }
    const mediaType = acceptHeaders[0].split(';')[0];
    return mediaType === MEDIA_TYPE_IPNS_RECORD;
}
function truncate(obj) {
    return `${obj}`.substring(0, 10);
}
export class VerifiedFetch {
    helia;
    ipnsResolver;
    dnsLink;
    log;
    contentTypeParser;
    withServerTiming;
    plugins = [];
    urlResolver;
    constructor(helia, init = {}) {
        this.helia = helia;
        this.log = helia.logger.forComponent('helia:verified-fetch');
        this.ipnsResolver = init.ipnsResolver ?? ipnsResolver(helia);
        this.dnsLink = init.dnsLink ?? dnsLink(helia);
        this.contentTypeParser = init.contentTypeParser ?? contentTypeParser;
        this.withServerTiming = init?.withServerTiming ?? false;
        this.urlResolver = new URLResolver({
            ipnsResolver: this.ipnsResolver,
            dnsLink: this.dnsLink,
            helia: this.helia
        }, init);
        const pluginOptions = {
            ...init,
            logger: helia.logger.forComponent('verified-fetch'),
            helia,
            contentTypeParser: this.contentTypeParser,
            ipnsResolver: this.ipnsResolver
        };
        const defaultPlugins = [
            new UnixFSPlugin(pluginOptions),
            new IpldPlugin(pluginOptions),
            new CarPlugin(pluginOptions),
            new TarPlugin(pluginOptions),
            new IpnsRecordPlugin(pluginOptions),
            new RawPlugin(pluginOptions)
        ];
        const customPlugins = init.plugins?.map((pluginFactory) => pluginFactory(pluginOptions)) ?? [];
        if (customPlugins.length > 0) {
            // allow custom plugins to replace default plugins
            const defaultPluginMap = new Map(defaultPlugins.map(plugin => [plugin.id, plugin]));
            const customPluginMap = new Map(customPlugins.map(plugin => [plugin.id, plugin]));
            this.plugins = defaultPlugins.map(plugin => customPluginMap.get(plugin.id) ?? plugin);
            // add any custom plugins that don't replace default ones with a higher
            // priority than anything built-in
            this.plugins.unshift(...customPlugins.filter(plugin => !defaultPluginMap.has(plugin.id)));
        }
        else {
            this.plugins = defaultPlugins;
        }
    }
    /**
     * Load a resource from the IPFS network and ensure the retrieved data is the
     * data that was expected to be loaded.
     *
     * Like [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch)
     * but verified.
     */
    async fetch(resource, opts) {
        this.log('fetch %s %s', opts?.method ?? 'GET', resource);
        // Create serverTiming outside try block so it's accessible in catch
        const serverTiming = new ServerTiming();
        const withServerTiming = Boolean(opts?.withServerTiming) || Boolean(this.withServerTiming);
        try {
            opts?.signal?.throwIfAborted();
            if (opts?.method === 'OPTIONS') {
                return this.handleFinalResponse(new Response(null, {
                    status: 200
                }), withServerTiming, {
                    serverTiming
                });
            }
            const options = convertOptions(opts);
            const headers = new Headers(options?.headers);
            if (options != null) {
                options.offline ??= headers.get('cache-control') === 'only-if-cached';
            }
            options?.onProgress?.(new CustomProgressEvent('verified-fetch:request:start', { resource }));
            const range = getRangeHeader(resource, headers);
            if (range instanceof Response) {
                // invalid range request
                return this.handleFinalResponse(range, withServerTiming, {
                    serverTiming
                });
            }
            const url = this.parseResource(resource);
            if (url.protocol === 'ipfs:' && url.pathname === '') {
                // if we don't need to resolve an IPNS names or traverse a DAG, we can
                // check the if-none-match header and maybe return a 304 without needing
                // to load any blocks
                if (ifNoneMatches(`"${url.hostname}"`, headers)) {
                    return this.handleFinalResponse(notModifiedResponse(resource, new Headers({
                        etag: `"${url.hostname}"`,
                        'cache-control': 'public, max-age=29030400, immutable'
                    })), withServerTiming, {
                        serverTiming
                    });
                }
            }
            const requestedMimeTypes = getRequestedMimeTypes(url, headers.get('accept'));
            // if a raw IPNS record has been requested, don't try to load the block
            // the record points to or do any recursive IPNS resolving
            if (isIPNSRecordRequest(headers)) {
                if (url.protocol !== 'ipns:') {
                    return notAcceptableResponse(url, requestedMimeTypes, [
                        CONTENT_TYPE_IPNS
                    ]);
                }
                // @ts-expect-error ipnsRecordPlugin may not be of type IpnsRecordPlugin
                const ipnsRecordPlugin = this.plugins.find(plugin => plugin.id === 'ipns-record-plugin');
                if (ipnsRecordPlugin == null) {
                    // IPNS record was requested but no IPNS Record plugin is configured?!
                    return notAcceptableResponse(resource, requestedMimeTypes, []);
                }
                return this.handleFinalResponse(await ipnsRecordPlugin.handle({
                    ...options,
                    range,
                    url,
                    resource,
                    redirected: false
                }), withServerTiming, {
                    serverTiming
                });
            }
            const resolveResult = await this.urlResolver.resolve(url, serverTiming, options);
            if (resolveResult instanceof Response) {
                return this.handleFinalResponse(resolveResult, withServerTiming, {
                    serverTiming
                });
            }
            options?.onProgress?.(new CustomProgressEvent('verified-fetch:request:resolve', {
                cid: resolveResult.terminalElement.cid,
                path: resolveResult.url.pathname
            }));
            const accept = this.getAcceptHeader(resolveResult.url, requestedMimeTypes, resolveResult.terminalElement.cid);
            if (accept instanceof Response) {
                this.log('allowed media types for requested CID did not contain anything the client can understand');
                // invalid accept header
                return this.handleFinalResponse(accept, withServerTiming, {
                    serverTiming
                });
            }
            const routingTimers = {};
            const connectTimers = {};
            const blockTimers = {};
            const context = {
                ...options,
                ...resolveResult,
                resource,
                accept,
                range,
                serverTiming,
                headers,
                requestedMimeTypes,
                onProgress: (evt) => {
                    options?.onProgress?.(evt);
                    if (evt.type === 'helia:routing:find-providers:start') {
                        routingTimers[evt.detail.routing] = {
                            start: performance.now(),
                            found: 0
                        };
                    }
                    else if (evt.type === 'helia:routing:find-providers:provider') {
                        if (routingTimers[evt.detail.routing] == null) {
                            return;
                        }
                        routingTimers[evt.detail.routing].found++;
                        serverTiming.add(abbreviate('found-provider'), `${abbreviate(evt.detail.routing)},${truncate(evt.detail.provider.id)}`, performance.now() - routingTimers[evt.detail.routing].start);
                    }
                    else if (evt.type === 'helia:routing:find-providers:end') {
                        const routing = routingTimers[evt.detail.routing];
                        if (routing == null) {
                            return;
                        }
                        serverTiming.add(abbreviate('find-providers'), `${abbreviate(evt.detail.routing)},${routing.found}`, performance.now() - routing.start);
                    }
                    else if (evt.type === 'helia:block-broker:connect') {
                        connectTimers[`connect-${evt.detail.broker}-${evt.detail.provider}`] = {
                            start: performance.now(),
                            transport: ''
                        };
                    }
                    else if (evt.type === 'helia:block-broker:connected') {
                        const start = connectTimers[`connect-${evt.detail.broker}-${evt.detail.provider}`];
                        if (start == null) {
                            return;
                        }
                        serverTiming.add(abbreviate('connect'), `${abbreviate(evt.detail.broker)},${truncate(evt.detail.provider)},${abbreviateAddress(evt.detail.address)}`, performance.now() - start.start);
                    }
                    else if (evt.type === 'helia:block-broker:request-block') {
                        blockTimers[`block-${evt.detail.broker}-${evt.detail.cid}-${evt.detail.provider}`] = performance.now();
                    }
                    else if (evt.type === 'helia:block-broker:receive-block') {
                        const start = blockTimers[`block-${evt.detail.broker}-${evt.detail.cid}-${evt.detail.provider}`];
                        if (start == null) {
                            return;
                        }
                        serverTiming.add(abbreviate('block'), `${abbreviate(evt.detail.broker)},${truncate(evt.detail.provider)},${truncate(evt.detail.cid)}`, performance.now() - start);
                    }
                }
            };
            this.log.trace('finding handler for cid code "0x%s" and response content types %s', resolveResult.terminalElement.cid.code.toString(16), accept.map(header => header.contentType.mediaType).join(', '));
            const response = await this.runPluginPipeline(context);
            options?.onProgress?.(new CustomProgressEvent('verified-fetch:request:end', {
                cid: resolveResult.terminalElement.cid,
                path: resolveResult.url.pathname
            }));
            if (response == null) {
                this.log.error('no plugin could handle request for %s', resource);
            }
            return this.handleFinalResponse(response, withServerTiming, context);
        }
        catch (err) {
            if (err?.name === 'AbortError') {
                err.serverTiming = serverTiming.getHeader();
            }
            this.log.error('error fetching resource %s - %e', resource, err);
            try {
                return this.handleFinalResponse(errorToResponse(resource, err, opts), withServerTiming, {
                    serverTiming
                });
            }
            catch (rethrowErr) {
                // Attach server timing to rethrown errors (e.g., AbortError)
                // so callers can access timing data even when the request was aborted
                const timingHeader = serverTiming.getHeader();
                if (timingHeader !== '') {
                    rethrowErr.serverTiming = timingHeader;
                }
                throw rethrowErr;
            }
        }
    }
    /**
     * Returns a prioritized list of acceptable content types for the response
     * based on the CID and a passed `Accept` header
     */
    getAcceptHeader(url, requestedMimeTypes, cid) {
        const supportedContentTypes = getSupportedContentTypes(url.protocol, cid);
        const acceptable = [];
        for (const headerFormat of requestedMimeTypes) {
            const [headerFormatType, headerFormatSubType] = headerFormat.mediaType.split('/');
            for (const contentType of supportedContentTypes) {
                const [contentTypeType, contentTypeSubType] = contentType.mediaType.split('/');
                if (headerFormat.mediaType.includes(contentType.mediaType)) {
                    acceptable.push({
                        contentType,
                        options: headerFormat.options
                    });
                }
                if (headerFormat.mediaType === '*/*') {
                    acceptable.push({
                        contentType,
                        options: headerFormat.options
                    });
                }
                if (headerFormat.mediaType.startsWith('*/') && contentTypeSubType === headerFormatSubType) {
                    acceptable.push({
                        contentType,
                        options: headerFormat.options
                    });
                }
                if (headerFormat.mediaType.endsWith('/*') && contentTypeType === headerFormatType) {
                    acceptable.push({
                        contentType,
                        options: headerFormat.options
                    });
                }
            }
        }
        if (acceptable.length === 0) {
            this.log('requested %o', requestedMimeTypes.map(({ mediaType }) => mediaType));
            this.log('supported %o', supportedContentTypes.map(({ mediaType }) => mediaType));
            return notAcceptableResponse(url, requestedMimeTypes, supportedContentTypes);
        }
        return acceptable;
    }
    parseResource(resource) {
        if (isPeerId(resource) || isPublicKey(resource)) {
            resource = `/ipns/${resource}`;
        }
        if (CID.asCID(resource) === resource || resource instanceof CID) {
            resource = `/ipfs/${resource}`;
        }
        return new URL(stringToIpfsUrl(resource.toString()));
    }
    /**
     * The last place a Response touches in verified-fetch before being returned
     * to the user. This is where we add the Server-Timing header to the response
     * if it has been collected. It should be used for any final processing of the
     * response before it is returned to the user.
     */
    handleFinalResponse(response, withServerTiming, context) {
        const contentType = getContentType(response.headers.get('content-type')) ?? CONTENT_TYPE_OCTET_STREAM;
        if (withServerTiming === true && context?.serverTiming != null) {
            const timingHeader = context?.serverTiming.getHeader();
            if (timingHeader !== '') {
                response.headers.set('server-timing', timingHeader);
            }
        }
        if (context?.url?.protocol != null && context.ttl != null) {
            setCacheControlHeader({
                response,
                ttl: context.ttl,
                protocol: context.url.protocol
            });
        }
        if (context?.terminalElement?.cid != null && context?.url != null) {
            // headers can ony contain extended ASCII but IPFS paths can be unicode
            const decodedPath = context.url.pathname.split('/')
                .map(component => decodeURIComponent(component))
                .join('/')
                .trim();
            const path = uint8ArrayToString(uint8ArrayFromString(decodedPath, 'ascii'), 'ascii');
            response.headers.set('x-ipfs-path', `/${context.url.protocol === 'ipfs:' ? 'ipfs' : 'ipns'}/${context?.url.hostname}${path === '/' ? '' : path}`);
        }
        // set CORS headers. If hosting your own gateway with verified-fetch behind
        // the scenes, you can alter these before you send the response to the
        // client.
        //
        // n.b. the values here must align with the conformance testing suite:
        // @see https://github.com/ipfs/gateway-conformance/blob/5106287edf36898b77fa5fac23f555638a16d778/tests/path_gateway_cors_test.go#L22-L34
        response.headers.set('access-control-allow-origin', '*');
        response.headers.set('access-control-allow-methods', 'GET, HEAD, OPTIONS');
        response.headers.set('access-control-allow-headers', 'Content-Type, Range, User-Agent, X-Requested-With');
        response.headers.set('access-control-expose-headers', 'Content-Range, Content-Length, X-Ipfs-Path, X-Ipfs-Roots, X-Chunked-Output, X-Stream-Output');
        if (context?.terminalElement?.cid != null && response.headers.get('etag') == null) {
            const etag = getETag({
                cid: context.terminalElement.cid,
                contentType,
                ranges: context?.range?.ranges
            });
            response.headers.set('etag', etag);
            if (ifNoneMatches(etag, context?.headers)) {
                return notModifiedResponse(response.url, response.headers);
            }
        }
        if (context?.method === 'HEAD') {
            // don't send the body for HEAD requests
            return new Response(null, {
                status: 200,
                headers: response.headers
            });
        }
        // make sure users are not expected to "download" error responses
        if (response.status > 399) {
            response.headers.delete('content-disposition');
        }
        return response;
    }
    async runPluginPipeline(context) {
        let finalResponse;
        const pluginsUsed = new Set();
        this.log.trace('checking which plugins can handle %c%s with accept %s', context.terminalElement.cid, context.url.pathname, context.accept.map(contentType => contentType.contentType.mediaType).join(', '));
        const plugins = this.plugins.filter(p => !pluginsUsed.has(p.id)).filter(p => p.canHandle(context));
        if (plugins.length === 0) {
            this.log.trace('no plugins found that can handle request; exiting pipeline');
            return notImplementedResponse(context.resource);
        }
        this.log.trace('plugins ready to handle request: %s', plugins.map(p => p.id).join(', '));
        // track if any plugin changed the context or returned a response
        const contextChanged = false;
        let pluginHandled = false;
        for (const plugin of plugins) {
            // try {
            this.log('invoking plugin: %s', plugin.id);
            pluginsUsed.add(plugin.id);
            const maybeResponse = await plugin.handle(context);
            this.log('plugin response %s %o', plugin.id, maybeResponse);
            if (maybeResponse != null) {
                // if a plugin returns a final Response, short-circuit
                finalResponse = maybeResponse;
                pluginHandled = true;
                break;
            }
            if (finalResponse != null) {
                this.log.trace('plugin %s produced final response', plugin.id);
                break;
            }
            if (pluginHandled && finalResponse != null) {
                break;
            }
            if (!contextChanged) {
                this.log.trace('no context changes and no final response; exiting pipeline.');
                break;
            }
        }
        return finalResponse ?? notImplementedResponse(context.resource, JSON.stringify({
            error: errorToObject(new Error('No verified fetch plugin could handle the request'))
        }), {
            headers: {
                'content-type': 'application/json'
            }
        });
    }
    /**
     * Start the Helia instance
     */
    async start() {
        await this.helia.start();
    }
    /**
     * Shut down the Helia instance
     */
    async stop() {
        await this.helia.stop();
    }
}
function getRequestedMimeTypes(url, accept) {
    if (accept == null || accept === '') {
        // yolo content-type
        accept = '*/*';
    }
    return accept
        .split(',')
        .map(s => {
        const parts = s.trim().split(';');
        const options = {
            q: '1'
        };
        for (let i = 1; i < parts.length; i++) {
            const [key, value] = parts[i].split('=').map(s => s.trim());
            options[key] = value;
        }
        return {
            mediaType: `${parts[0]}`.trim(),
            options
        };
    })
        .sort((a, b) => {
        if (a.options.q === b.options.q) {
            return 0;
        }
        if (a.options.q > b.options.q) {
            return -1;
        }
        return 1;
    });
}
//# sourceMappingURL=verified-fetch.js.map