import { EventEmitter } from "tseep";

import type { NDKEventId, NostrEvent, NDKSignedEvent } from "../events/index.js";
import { NDKEvent, createSignedEvent } from "../events/index.js";
import type { NDKKind } from "../events/kinds/index.js";
import { verifiedSignatures } from "../events/validation.js";
import { wrapEvent } from "../events/wrap.js";
import type { NDK } from "../ndk/index.js";
import type { NDKRelay } from "../relay";
import type { NDKPool } from "../relay/pool/index.js";
import { calculateRelaySetsFromFilters } from "../relay/sets/calculate";
import { NDKRelaySet } from "../relay/sets/index.js";
import { queryFullyFilled } from "./utils.js";

export type NDKSubscriptionInternalId = string;

export type NDKSubscriptionDelayedType = "at-least" | "at-most";

export type NDKFilter<K extends number = NDKKind> = {
    ids?: string[];
    kinds?: K[];
    authors?: string[];
    since?: number;
    until?: number;
    limit?: number;
    search?: string;
    [key: `#${string}`]: string[] | undefined;
};

export enum NDKSubscriptionCacheUsage {
    // Only use cache, don't subscribe to relays
    ONLY_CACHE = "ONLY_CACHE",

    // Use cache, if no matches, use relays
    CACHE_FIRST = "CACHE_FIRST",

    // Use cache in addition to relays
    PARALLEL = "PARALLEL",

    // Skip cache, don't query it
    ONLY_RELAY = "ONLY_RELAY",
}

export interface NDKSubscriptionOptions {
    /**
     * Whether to close the subscription when all relays have reached the end of the event stream.
     * @default false
     */
    closeOnEose?: boolean;
    cacheUsage?: NDKSubscriptionCacheUsage;

    /**
     * Whether to skip caching events coming from this subscription
     **/
    dontSaveToCache?: boolean;

    /**
     * Groupable subscriptions are created with a slight time
     * delayed to allow similar filters to be grouped together.
     */
    groupable?: boolean;

    /**
     * The delay to use when grouping subscriptions, specified in milliseconds.
     * @default 100
     * @example
     * const sub1 = ndk.subscribe({ kinds: [1], authors: ["alice"] }, { groupableDelay: 100 });
     * const sub2 = ndk.subscribe({ kinds: [0], authors: ["alice"] }, { groupableDelay: 1000 });
     * // sub1 and sub2 will be grouped together and executed 100ms after sub1 was created
     */
    groupableDelay?: number;

    /**
     * Specifies how this delay should be interpreted.
     * "at-least" means "wait at least this long before sending the subscription"
     * "at-most" means "wait at most this long before sending the subscription"
     * @default "at-most"
     * @example
     * const sub1 = ndk.subscribe({ kinds: [1], authors: ["alice"] }, { groupableDelay: 100, groupableDelayType: "at-least" }); // 3 args
     * const sub2 = ndk.subscribe({ kinds: [0], authors: ["alice"] }, { groupableDelay: 1000, groupableDelayType: "at-most" }); // 3 args
     * // sub1 and sub2 will be grouped together and executed 1000ms after sub1 was created
     */
    groupableDelayType?: NDKSubscriptionDelayedType;

    /**
     * The subscription ID to use for the subscription.
     */
    subId?: string;

    /**
     * Pool to use
     */
    pool?: NDKPool;

    /**
     * Skip signature verification
     * @default false
     */
    skipVerification?: boolean;

    /**
     * Skip event validation. Event validation, checks whether received
     * kinds conform to what the expected schema of that kind should look like.rtwle
     * @default false
     */
    skipValidation?: boolean;

    /**
     * Skip emitting on events before they are received from a relay. (skip optimistic publish)
     * @default false
     */
    skipOptimisticPublishEvent?: boolean;

    /**
     * Remove filter constraints when querying the cache.
     *
     * This allows setting more aggressive filters that will be removed when hitting the cache.
     *
     * Useful uses of this include removing `since` or `until` constraints or `limit` filters.
     *
     * @example
     * ndk.subscribe({ kinds: [1], since: 1710000000, limit: 10 }, { cacheUnconstrainFilter: ['since', 'limit'] }); // 3 args
     *
     * This will hit relays with the since and limit constraints, while loading from the cache without them.
     */
    cacheUnconstrainFilter?: (keyof NDKFilter)[];

    /**
     * Whether to wrap events in kind-specific classes when possible.
     * @default false
     */
    wrap?: boolean;

    /**
     * Explicit relay set to use for this subscription instead of calculating it.
     * If `relayUrls` is also provided in the options, this `relaySet` takes precedence.
     * @since 2.13.0 Moved from `ndk.subscribe` parameter to options.
     */
    relaySet?: NDKRelaySet;

    /**
     * Explicit relay URLs to use for this subscription instead of calculating the relay set.
     * An `NDKRelaySet` will be created internally from these URLs.
     * If `relaySet` is also provided in the options, the explicit `relaySet` takes precedence over these URLs.
     * @since 2.13.0
     */
    relayUrls?: string[];

    /**
     * When set, the cache will be queried first, and, when hitting relays,
     * a `since` filter will be added to the subscription that is one second
     * after the last event received from the cache.
     *
     * This option implies cacheUsage: CACHE_FIRST.
     */
    addSinceFromCache?: boolean;
}

/**
 * Default subscription options.
 */
export const defaultOpts: NDKSubscriptionOptions = {
    closeOnEose: false,
    cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST,
    dontSaveToCache: false,
    groupable: true,
    groupableDelay: 100,
    groupableDelayType: "at-most",
    cacheUnconstrainFilter: ["limit", "since", "until"],
};

/**
 * Represents a subscription to an NDK event stream.
 *
 * @emits event
 * Emitted when an event is received by the subscription.
 * * ({NDKEvent} event - The event received by the subscription,
 * * {NDKRelay} relay - The relay that received the event,
 * * {NDKSubscription} subscription - The subscription that received the event.)
 *
 * @emits event:dup
 * Emitted when a duplicate event is received by the subscription.
 * * {NDKEvent} event - The duplicate event received by the subscription.
 * * {NDKRelay} relay - The relay that received the event.
 * * {number} timeSinceFirstSeen - The time elapsed since the first time the event was seen.
 * * {NDKSubscription} subscription - The subscription that received the event.
 *
 * @emits cacheEose - Emitted when the cache adapter has reached the end of the events it had.
 *
 * @emits eose - Emitted when all relays have reached the end of the event stream.
 * * {NDKSubscription} subscription - The subscription that received EOSE.
 *
 * @emits close - Emitted when the subscription is closed.
 * * {NDKSubscription} subscription - The subscription that was closed.
 *
 * @example
 * const sub = ndk.subscribe({ kinds: [1] }); // Get all kind:1s
 * sub.on("event", (event) => console.log(event.content); // Show the content
 * sub.on("eose", () => console.log("All relays have reached the end of the event stream"));
 * sub.on("close", () => console.log("Subscription closed"));
 * setTimeout(() => sub.stop(), 10000); // Stop the subscription after 10 seconds
 *
 * @description
 * Subscriptions are created using {@link NDK.subscribe}.
 *
 * # Event validation
 * By defaults, subscriptions will validate events to comply with the minimal requirement
 * of each known NIP.
 * This can be disabled by setting the `skipValidation` option to `true`.
 *
 * @example
 * const sub = ndk.subscribe({ kinds: [1] }, { skipValidation: false });
 * sub.on("event", (event) => console.log(event.content); // Only valid events will be received
 */
export class NDKSubscription extends EventEmitter<{
    cacheEose: () => void;
    eose: (sub: NDKSubscription) => void;
    close: (sub: NDKSubscription) => void;

    /**
     * Emitted when a duplicate event is received by the subscription.
     * @param event - The duplicate event received by the subscription.
     * @param relay - The relay that received the event.
     * @param timeSinceFirstSeen - The time elapsed since the first time the event was seen.
     * @param sub - The subscription that received the event.
     */
    "event:dup": (
        event: NDKEvent | NostrEvent,
        relay: NDKRelay | undefined,
        timeSinceFirstSeen: number,
        sub: NDKSubscription,
        fromCache: boolean,
        optimisticPublish: boolean
    ) => void;

    /**
     * Emitted when an event is received by the subscription.
     * @param event - The event received by the subscription.
     * @param relay - The relay that received the event.
     * @param sub - The subscription that received the event.
     * @param fromCache - Whether the event was received from the cache.
     * @param optimisticPublish - Whether the event was received from an optimistic publish.
     */
    event: (
        event: NDKSignedEvent,
        relay: NDKRelay | undefined,
        sub: NDKSubscription,
        fromCache: boolean,
        optimisticPublish: boolean
    ) => void;

    /**
     * Emitted when a relay unilaterally closes the subscription.
     * @param relay
     * @param reason
     * @returns
     */
    closed: (relay: NDKRelay, reason: string) => void;
}> {
    readonly subId?: string;
    readonly filters: NDKFilter[];
    readonly opts: NDKSubscriptionOptions;
    readonly pool: NDKPool;
    readonly skipVerification: boolean = false;
    readonly skipValidation: boolean = false;

    /**
     * Tracks the filters as they are executed on each relay
     */
    public relayFilters?: Map<WebSocket["url"], NDKFilter[]>;
    public relaySet?: NDKRelaySet;
    public ndk: NDK;
    public debug: debug.Debugger;

    /**
     * Events that have been seen by the subscription, with the time they were first seen.
     */
    public eventFirstSeen = new Map<NDKEventId, number>();

    /**
     * Relays that have sent an EOSE.
     */
    public eosesSeen = new Set<NDKRelay>();

    /**
     * The time the last event was received by the subscription.
     * This is used to calculate when EOSE should be emitted.
     */
    private lastEventReceivedAt: number | undefined;

    /**
     * The most recent event timestamp from cache results.
     * This is used for addSinceFromCache functionality.
     */
    private mostRecentCacheEventTimestamp?: number;

    public internalId: NDKSubscriptionInternalId;

    /**
     * Whether the subscription should close when all relays have reached the end of the event stream.
     */
    public closeOnEose: boolean;

    /**
     * Pool monitor callback
     */
    private poolMonitor: ((relay: NDKRelay) => void) | undefined;

    public skipOptimisticPublishEvent = false;

    /**
     * Filters to remove when querying the cache.
     */
    public cacheUnconstrainFilter?: Array<keyof NDKFilter>;

    public constructor(
        ndk: NDK,
        filters: NDKFilter | NDKFilter[],
        opts?: NDKSubscriptionOptions,
        subId?: string
    ) {
        super();
        this.ndk = ndk;
        this.opts = { ...defaultOpts, ...(opts || {}) };
        this.pool = this.opts.pool || ndk.pool;
        this.filters = Array.isArray(filters) ? filters : [filters];
        this.subId = subId || this.opts.subId;
        this.internalId = Math.random().toString(36).substring(7);
        this.debug = ndk.debug.extend(`subscription[${this.opts.subId ?? this.internalId}]`);

        // Handle relaySet and relayUrls options
        if (this.opts.relaySet) {
            this.relaySet = this.opts.relaySet;
        } else if (this.opts.relayUrls) {
            this.relaySet = NDKRelaySet.fromRelayUrls(this.opts.relayUrls, this.ndk);
        }

        this.skipVerification = this.opts.skipVerification || false;
        this.skipValidation = this.opts.skipValidation || false;
        this.closeOnEose = this.opts.closeOnEose || false;
        this.skipOptimisticPublishEvent = this.opts.skipOptimisticPublishEvent || false;
        this.cacheUnconstrainFilter = this.opts.cacheUnconstrainFilter;
    }

    /**
     * Returns the relays that have not yet sent an EOSE.
     */
    public relaysMissingEose(): WebSocket["url"][] {
        if (!this.relayFilters) return [];

        const relaysMissingEose = Array.from(this.relayFilters?.keys()).filter(
            (url) => !this.eosesSeen.has(this.pool.getRelay(url, false, false))
        );

        return relaysMissingEose;
    }

    /**
     * Provides access to the first filter of the subscription for
     * backwards compatibility.
     */
    get filter(): NDKFilter {
        return this.filters[0];
    }

    get groupableDelay(): number | undefined {
        if (!this.isGroupable()) return undefined;
        return this.opts?.groupableDelay;
    }

    get groupableDelayType(): NDKSubscriptionDelayedType {
        return this.opts?.groupableDelayType || "at-most";
    }

    public isGroupable(): boolean {
        return this.opts?.groupable || false;
    }

    private shouldQueryCache(): boolean {
        if (this.opts.addSinceFromCache) return true;

        // explicitly told to not query the cache
        if (this.opts?.cacheUsage === NDKSubscriptionCacheUsage.ONLY_RELAY) return false;

        const hasNonEphemeralKind = this.filters.some((f) =>
            f.kinds?.some((k) => kindIsEphemeral(k))
        );
        if (hasNonEphemeralKind) return true;

        return true;
    }

    private shouldQueryRelays(): boolean {
        return this.opts?.cacheUsage !== NDKSubscriptionCacheUsage.ONLY_CACHE;
    }

    private shouldWaitForCache(): boolean {
        if (this.opts.addSinceFromCache) return true;

        return (
            // Must want to close on EOSE; subscriptions
            // that want to receive further updates must
            // always hit the relay
            !!this.opts.closeOnEose &&
            // Cache adapter must claim to be fast
            !!this.ndk.cacheAdapter?.locking &&
            // If explicitly told to run in parallel, then
            // we should not wait for the cache
            this.opts.cacheUsage !== NDKSubscriptionCacheUsage.PARALLEL
        );
    }

    /**
     * Start the subscription. This is the main method that should be called
     * after creating a subscription.
     *
     * @param emitCachedEvents - Whether to emit events coming from a synchronous cache
     *
     * When using a synchronous cache, the events will be returned immediately
     * by this function. If you will use those returned events, you should
     * set emitCachedEvents to false to prevent seeing them as duplicate events.
     */
    public start(emitCachedEvents = true): NDKEvent[] | null {
        let cacheResult: NDKEvent[] | Promise<NDKEvent[]>;

        const updateStateFromCacheResults = (events: NDKEvent[]) => {
            if (emitCachedEvents) {
                for (const event of events) {
                    if (
                        event.created_at &&
                        (!this.mostRecentCacheEventTimestamp ||
                            event.created_at > this.mostRecentCacheEventTimestamp)
                    ) {
                        this.mostRecentCacheEventTimestamp = event.created_at;
                    }
                    this.eventReceived(event, undefined, true, false);
                }
            } else {
                cacheResult = [];
                for (const event of events) {
                    if (
                        event.created_at &&
                        (!this.mostRecentCacheEventTimestamp ||
                            event.created_at > this.mostRecentCacheEventTimestamp)
                    ) {
                        this.mostRecentCacheEventTimestamp = event.created_at;
                    }

                    event.ndk = this.ndk;
                    const e = this.opts.wrap ? wrapEvent(event) : event;
                    if (!e) break;
                    if (e instanceof Promise) {
                        // if we get a promise, we emit it
                        e.then((wrappedEvent) => {
                            this.emitEvent(false, wrappedEvent, undefined, true, false);
                        });
                        break;
                    }
                    this.eventFirstSeen.set(e.id, Date.now());
                    (cacheResult as NDKEvent[]).push(e);
                }
            }
        };

        const loadFromRelays = () => {
            if (this.shouldQueryRelays()) {
                this.startWithRelays();
                this.startPoolMonitor();
            } else {
                this.emit("eose", this);
            }
        };

        if (this.shouldQueryCache()) {
            cacheResult = this.startWithCache();

            if (cacheResult instanceof Promise) {
                // The cache is asynchronous
                if (this.shouldWaitForCache()) {
                    // If we need to wait for it
                    cacheResult.then((events) => {
                        // load the results into the subscription state
                        updateStateFromCacheResults(events);
                        // if the cache has a hit, return early
                        if (queryFullyFilled(this)) {
                            this.emit("eose", this);
                            return;
                        }
                        loadFromRelays();
                    });
                    return null;
                }
                cacheResult.then((events) => {
                    updateStateFromCacheResults(events);
                });

                loadFromRelays();

                return null;
            }
            updateStateFromCacheResults(cacheResult);

            if (queryFullyFilled(this)) {
                this.emit("eose", this);
            } else {
                loadFromRelays();
            }

            return cacheResult;
        }
        loadFromRelays();
        return null;
    }

    /**
     * We want to monitor for new relays that are coming online, in case
     * they should be part of this subscription.
     */
    private startPoolMonitor(): void {
        const _d = this.debug.extend("pool-monitor");

        this.poolMonitor = (relay: NDKRelay) => {
            // check if the pool monitor is already in the relayFilters
            if (this.relayFilters?.has(relay.url)) return;

            const calc = calculateRelaySetsFromFilters(this.ndk, this.filters, this.pool);

            // check if the new relay is included
            if (calc.get(relay.url)) {
                // add it to the relayFilters
                this.relayFilters?.set(relay.url, this.filters);

                // d("New relay connected -- adding to subscription", relay.url);
                relay.subscribe(this, this.filters);
            }
        };

        this.pool.on("relay:connect", this.poolMonitor);
    }

    public onStopped?: () => void;

    public stop(): void {
        this.emit("close", this);
        this.poolMonitor && this.pool.off("relay:connect", this.poolMonitor);
        this.onStopped?.();
    }

    /**
     * @returns Whether the subscription has an authors filter.
     */
    public hasAuthorsFilter(): boolean {
        return this.filters.some((f) => f.authors?.length);
    }

    private startWithCache(): NDKEvent[] | Promise<NDKEvent[]> {
        if (this.ndk.cacheAdapter?.query) {
            return this.ndk.cacheAdapter.query(this);
        }
        return [];
    }

    /**
     * Find available relays that should be part of this subscription and execute in them.
     *
     * Note that this is executed in addition to using the pool monitor, so even if the relay set
     * that is computed (i.e. we don't have any relays available), when relays come online, we will
     * check if we need to execute in them.
     */
    private startWithRelays(): void {
        // Create a copy of filters to potentially modify for addSinceFromCache
        let filters = this.filters;

        // If addSinceFromCache is enabled and we have a timestamp from cache results,
        // modify the filters to add a 'since' filter that's one second after the most recent event
        if (this.opts.addSinceFromCache && this.mostRecentCacheEventTimestamp) {
            const sinceTimestamp = this.mostRecentCacheEventTimestamp + 1;
            filters = filters.map((filter) => ({
                ...filter,
                since: Math.max(filter.since || 0, sinceTimestamp),
            }));
        }

        if (!this.relaySet || this.relaySet.relays.size === 0) {
            this.relayFilters = calculateRelaySetsFromFilters(this.ndk, filters, this.pool);
        } else {
            this.relayFilters = new Map();
            for (const relay of this.relaySet.relays) {
                this.relayFilters.set(relay.url, filters);
            }
        }

        // iterate through the this.relayFilters
        for (const [relayUrl, filters] of this.relayFilters) {
            const relay = this.pool.getRelay(relayUrl, true, true, filters);
            relay.subscribe(this, filters);
        }
    }

    // EVENT handling

    /**
     * Called when an event is received from a relay or the cache
     * @param event
     * @param relay
     * @param fromCache Whether the event was received from the cache
     * @param optimisticPublish Whether this event is coming from an optimistic publish
     */
    public eventReceived(
        event: NDKEvent | NostrEvent,
        relay: NDKRelay | undefined,
        fromCache = false,
        optimisticPublish = false
    ) {
        const eventId = event.id! as NDKEventId;

        const eventAlreadySeen = this.eventFirstSeen.has(eventId);
        let ndkEvent: NDKEvent;

        if (event instanceof NDKEvent) ndkEvent = event;

        if (!eventAlreadySeen) {
            // generate the ndkEvent
            ndkEvent ??= new NDKEvent(this.ndk, event);
            ndkEvent.ndk = this.ndk;
            ndkEvent.relay = relay;

            // we don't want to validate/verify events that are either
            // coming from the cache or have been published by us from within
            // the client
            if (!fromCache && !optimisticPublish) {
                // validate it
                if (!this.skipValidation) {
                    if (!ndkEvent.isValid) {
                        this.debug("Event failed validation %s from relay %s", eventId, relay?.url);
                        return;
                    }
                }

                // verify it
                if (relay) {
                    // Check if we need to verify this event based on sampling
                    const shouldVerify = relay.shouldValidateEvent();

                    if (shouldVerify && !this.skipVerification) {
                        // Set the relay on the event for async verification
                        ndkEvent.relay = relay;

                        // Attempt verification
                        if (!this.ndk.asyncSigVerification) {
                            if (!ndkEvent.verifySignature(true)) {
                                this.debug("Event failed signature validation", event);
                                // Report the invalid signature with relay information through the centralized method
                                this.ndk.reportInvalidSignature(ndkEvent, relay);
                                return;
                            }

                            // Track successful validation
                            relay.addValidatedEvent();
                        }
                    } else {
                        // We skipped verification for this event
                        relay.addNonValidatedEvent();
                    }
                }

                if (this.ndk.cacheAdapter && !this.opts.dontSaveToCache) {
                    this.ndk.cacheAdapter.setEvent(ndkEvent, this.filters, relay);
                }
            }

            // emit it
            if (!optimisticPublish || this.skipOptimisticPublishEvent !== true) {
                this.emitEvent(
                    this.opts?.wrap ?? false,
                    ndkEvent,
                    relay,
                    fromCache,
                    optimisticPublish
                );
                // mark the eventId as seen
                this.eventFirstSeen.set(eventId, Date.now());
            }
        } else {
            const timeSinceFirstSeen = Date.now() - (this.eventFirstSeen.get(eventId) || 0);
            this.emit(
                "event:dup",
                event,
                relay,
                timeSinceFirstSeen,
                this,
                fromCache,
                optimisticPublish
            );

            if (relay) {
                // Check if we've already verified this event id's signature
                const signature = verifiedSignatures.get(eventId);
                if (signature && typeof signature === "string") {
                    // If signatures match, we count it as validated
                    if (event.sig === signature) {
                        relay.addValidatedEvent();
                    } else {
                        // Signatures don't match - this is a malicious relay!
                        // One invalid signature means the relay is considered evil
                        const eventToReport =
                            event instanceof NDKEvent ? event : new NDKEvent(this.ndk, event);
                        this.ndk.reportInvalidSignature(eventToReport, relay);
                    }
                }
            }
        }

        this.lastEventReceivedAt = Date.now();
    }

    /**
     * Optionally wraps, sync or async, and emits the event (if one comes back from the wrapper)
     */
    private emitEvent(
        wrap: boolean,
        evt: NDKEvent,
        relay: NDKRelay | undefined,
        fromCache: boolean,
        optimisticPublish: boolean
    ) {
        const wrapped = wrap ? wrapEvent(evt) : evt;
        if (wrapped instanceof Promise) {
            wrapped.then((e) => this.emitEvent(false, e, relay, fromCache, optimisticPublish));
        } else if (wrapped) {
            // Events from subscriptions are expected to be signed after validation
            // We cast to NDKSignedEvent for type safety
            this.emit(
                "event",
                wrapped as NDKSignedEvent,
                relay,
                this,
                fromCache,
                optimisticPublish
            );
        }
    }

    public closedReceived(relay: NDKRelay, reason: string): void {
        this.emit("closed", relay, reason);
    }

    // EOSE handling
    private eoseTimeout: ReturnType<typeof setTimeout> | undefined;
    private eosed = false;

    public eoseReceived(relay: NDKRelay): void {
        this.debug("EOSE received from %s", relay.url);
        this.eosesSeen.add(relay);

        let lastEventSeen = this.lastEventReceivedAt
            ? Date.now() - this.lastEventReceivedAt
            : undefined;

        const hasSeenAllEoses = this.eosesSeen.size === this.relayFilters?.size;
        const queryFilled = queryFullyFilled(this);

        const performEose = (reason: string) => {
            this.debug("Performing EOSE: %s %d", reason, this.eosed);
            if (this.eosed) return;
            if (this.eoseTimeout) clearTimeout(this.eoseTimeout);
            this.emit("eose", this);
            this.eosed = true;

            if (this.opts?.closeOnEose) this.stop();
        };

        if (queryFilled || hasSeenAllEoses) {
            performEose("query filled or seen all");
        } else if (this.relayFilters) {
            let timeToWaitForNextEose = 1000;

            const connectedRelays = new Set(this.pool.connectedRelays().map((r) => r.url));

            const connectedRelaysWithFilters = Array.from(this.relayFilters.keys()).filter((url) =>
                connectedRelays.has(url)
            );

            // if we have no connected relays, wait for all relays to connect
            if (connectedRelaysWithFilters.length === 0) {
                this.debug(
                    "No connected relays, waiting for all relays to connect",
                    Array.from(this.relayFilters.keys()).join(", ")
                );
                return;
            }

            // Reduce the number of ms to wait based on the percentage of relays
            // that have already sent an EOSE, the more
            // relays that have sent an EOSE, the less time we should wait
            // for the next one
            const percentageOfRelaysThatHaveSentEose =
                this.eosesSeen.size / connectedRelaysWithFilters.length;

            this.debug("Percentage of relays that have sent EOSE", {
                subId: this.subId,
                percentageOfRelaysThatHaveSentEose,
                seen: this.eosesSeen.size,
                total: connectedRelaysWithFilters.length,
            });

            // If less than 2 and 50% of relays have EOSEd don't add a timeout yet
            if (this.eosesSeen.size >= 2 && percentageOfRelaysThatHaveSentEose >= 0.5) {
                timeToWaitForNextEose =
                    timeToWaitForNextEose * (1 - percentageOfRelaysThatHaveSentEose);

                if (timeToWaitForNextEose === 0) {
                    performEose("time to wait was 0");
                    return;
                }

                if (this.eoseTimeout) clearTimeout(this.eoseTimeout);

                const sendEoseTimeout = () => {
                    lastEventSeen = this.lastEventReceivedAt
                        ? Date.now() - this.lastEventReceivedAt
                        : undefined;

                    // If we have seen an event in the past 20ms don't emit an EOSE due to a timeout, events
                    // are still being received
                    if (lastEventSeen !== undefined && lastEventSeen < 20) {
                        this.eoseTimeout = setTimeout(sendEoseTimeout, timeToWaitForNextEose);
                    } else {
                        performEose(`send eose timeout: ${timeToWaitForNextEose}`);
                    }
                };

                this.eoseTimeout = setTimeout(sendEoseTimeout, timeToWaitForNextEose);
            }
        }
    }
}

const kindIsEphemeral = (kind: NDKKind) => kind >= 20000 && kind < 30000;
