import retry from "retry";
import { BaseClientsManager } from "../clients/base-client-manager.js";
import { PKCClientsManager } from "../pkc/pkc-client-manager.js";
import { FailedToFetchCommunityFromGatewaysError, PKCError } from "../pkc-error.js";
import * as remeda from "remeda";
import { getCommunityNameFromWire } from "./community-wire.js";
import { getPKCAddressFromPublicKeySync } from "../signer/util.js";
import Logger from "../logger.js";
import { areEquivalentCommunityAddresses, fetchAndValidateIpnsRecordFromGateway, hideClassPrivateProps, ipnsNameToIpnsOverPubsubTopic, isIpfsPath, isIpnsPath, isStringDomain, pubsubTopicToDhtKey, throwIfAbortSignalAborted, timestamp } from "../util.js";
import pLimit from "p-limit";
import { parseCommunityIpfsSchemaPassthroughWithPKCErrorIfItFails, parseJsonWithPKCErrorIfFails } from "../schema/schema-util.js";
import { verifyCommunity } from "../signer/index.js";
import { LimitedSet } from "../general-util/limited-set.js";
import { CommunityKuboPubsubClient, CommunityKuboRpcClient, CommunityLibp2pJsClient, CommunityPKCRpcStateClient } from "./community-clients.js";
import { CID } from "kubo-rpc-client";
import { getAuthorNameFromRuntime } from "../publications/publication-author.js";
import { selectWinningGatewayCommunity } from "./community-gateway-selection.js";
export const MAX_FILE_SIZE_BYTES_FOR_COMMUNITY_IPFS = 1024 * 1024; // 1mb
export class CommunityClientsManager extends PKCClientsManager {
    constructor(community) {
        super(community._pkc);
        this._suppressUpdatingStateForNameResolution = 0;
        this._ipnsLoadingOperation = undefined;
        this._updateCidsAlreadyLoaded = new LimitedSet(30); // we will keep track of the last 50 community update cids that we loaded
        this._community = community;
        this._initPKCRpcClients();
        hideClassPrivateProps(this);
    }
    _initKuboRpcClients() {
        if (this._pkc.clients.kuboRpcClients)
            for (const ipfsUrl of remeda.keys.strict(this._pkc.clients.kuboRpcClients))
                this.clients.kuboRpcClients = { ...this.clients.kuboRpcClients, [ipfsUrl]: new CommunityKuboRpcClient("stopped") };
    }
    _initPubsubKuboRpcClients() {
        for (const pubsubUrl of remeda.keys.strict(this._pkc.clients.pubsubKuboRpcClients))
            this.clients.pubsubKuboRpcClients = {
                ...this.clients.pubsubKuboRpcClients,
                [pubsubUrl]: new CommunityKuboPubsubClient("stopped")
            };
    }
    _initLibp2pJsClients() {
        if (this._pkc.clients.libp2pJsClients)
            for (const libp2pJsClientUrl of remeda.keys.strict(this._pkc.clients.libp2pJsClients))
                this.clients.libp2pJsClients = {
                    ...this.clients.libp2pJsClients,
                    [libp2pJsClientUrl]: new CommunityLibp2pJsClient("stopped")
                };
    }
    _initPKCRpcClients() {
        for (const rpcUrl of remeda.keys.strict(this._pkc.clients.pkcRpcClients))
            this.clients.pkcRpcClients = {
                ...this.clients.pkcRpcClients,
                [rpcUrl]: new CommunityPKCRpcStateClient("stopped")
            };
    }
    updateKuboRpcState(newState, kuboRpcClientUrl) {
        super.updateKuboRpcState(newState, kuboRpcClientUrl);
    }
    updateKuboRpcPubsubState(newState, pubsubProvider) {
        super.updateKuboRpcPubsubState(newState, pubsubProvider);
    }
    updateGatewayState(newState, gateway) {
        super.updateGatewayState(newState, gateway);
    }
    updateLibp2pJsClientState(newState, libp2pJsClientUrl) {
        super.updateLibp2pJsClientState(newState, libp2pJsClientUrl);
    }
    emitError(e) {
        this._community.emit("error", e);
    }
    _getStatePriorToResolvingCommunityIpns() {
        return "fetching-ipns";
    }
    preResolveNameResolver(opts) {
        super.preResolveNameResolver(opts);
        if (this._suppressUpdatingStateForNameResolution > 0)
            return;
        if (opts.resolveType !== "community")
            return;
        this._community._setUpdatingStateWithEventEmissionIfNewState("resolving-name");
    }
    postResolveNameResolverSuccess(opts) {
        super.postResolveNameResolverSuccess(opts);
        if (!opts.resolvedValue && this._community.state === "updating") {
            throw new PKCError("ERR_DOMAIN_TXT_RECORD_NOT_FOUND", {
                name: opts.address
            });
        }
    }
    _getCommunityAddressFromInstance() {
        return this._community.address;
    }
    _areEquivalentCommunityAddresses(addressA, addressB) {
        return areEquivalentCommunityAddresses(addressA, addressB);
    }
    _deriveAddressFromWireRecord(communityJson) {
        // Old records have address in the wire format, new records use name || publicKey
        return (getCommunityNameFromWire(communityJson) ||
            getPKCAddressFromPublicKeySync(communityJson.signature.publicKey));
    }
    // functions for updatingCommunityInstance
    async _retryLoadingCommunityAddress(communityAddress) {
        const log = Logger("pkc-js:remote-community:update:_retryLoadingCommunityIpns");
        return new Promise((resolve) => {
            this._ipnsLoadingOperation.attempt(async (curAttempt) => {
                log.trace(`Retrying to load community ${communityAddress} for the ${curAttempt}th time`);
                try {
                    const update = await this.fetchNewUpdateForCommunity(communityAddress);
                    resolve(update);
                }
                catch (e) {
                    const error = e;
                    if (error.name === "AbortError")
                        return resolve({ aborted: true });
                    //@ts-expect-error
                    error.details = {
                        //@ts-expect-error
                        ...error.details,
                        ipnsPubsubTopic: this._community.ipnsPubsubTopic,
                        ipnsPubsubTopicRoutingCid: this._community.ipnsPubsubTopicRoutingCid
                    };
                    if (!this._community._isRetriableErrorWhenLoading(error)) {
                        // critical error that can't be retried
                        if (error instanceof PKCError)
                            error.details = { ...error.details, countOfLoadAttempts: curAttempt, retriableError: false };
                        resolve({ criticalError: error });
                    }
                    else {
                        // we encountered a retriable error, could be gateways failing to load
                        // does not include gateways returning an old record
                        if (error instanceof PKCError)
                            error.details = { ...error.details, countOfLoadAttempts: curAttempt, retriableError: true };
                        log.trace(`Failed to load Community ${this._community.address} record for the ${curAttempt}th attempt. We will retry`, error);
                        // Gateway signature errors are transient (e.g. stale IPNS cache) — log but don't emit error event
                        const isGatewaySignatureError = error instanceof FailedToFetchCommunityFromGatewaysError &&
                            Object.values(error.details.gatewayToError).every((gatewayError) => gatewayError instanceof PKCError && gatewayError.code === "ERR_COMMUNITY_SIGNATURE_IS_INVALID");
                        if (isGatewaySignatureError) {
                            log(`Community ${this._community.address} gateway returned invalid signature, silently retrying`);
                            this._community._setUpdatingStateWithEventEmissionIfNewState("waiting-retry");
                        }
                        else {
                            this._community._changeStateEmitEventEmitStateChangeEvent({
                                event: { name: "error", args: [error] },
                                newUpdatingState: "waiting-retry"
                            });
                        }
                        this._ipnsLoadingOperation.retry(e);
                    }
                }
            });
        });
    }
    async updateOnce() {
        const log = Logger("pkc-js:remote-community:update");
        this._ipnsLoadingOperation = retry.operation({ forever: true, factor: 2, maxTimeout: 30000 });
        const communityLoadingRes = await this._retryLoadingCommunityAddress(this._community.address); // will return undefined if no new community CID is found
        this._ipnsLoadingOperation.stop();
        if (communityLoadingRes && "aborted" in communityLoadingRes) {
            return;
        }
        else if (communityLoadingRes && "criticalError" in communityLoadingRes) {
            // Log individual gateway errors separately to avoid Node.js [Object] truncation
            if (communityLoadingRes.criticalError instanceof FailedToFetchCommunityFromGatewaysError) {
                for (const [gatewayUrl, gatewayError] of Object.entries(communityLoadingRes.criticalError.details.gatewayToError)) {
                    log.error(`Community ${this._community.address} gateway ${gatewayUrl} non-retriable error:`, gatewayError);
                }
            }
            log.error(`Community ${this._community.address} encountered a non retriable error while updating, will emit an error event and mark invalid cid to not be loaded again`, communityLoadingRes.criticalError);
            this._community._changeStateEmitEventEmitStateChangeEvent({
                event: { name: "error", args: [communityLoadingRes.criticalError] },
                newUpdatingState: "failed"
            });
        }
        else if (communityLoadingRes?.community &&
            (this._community.raw.communityIpfs?.updatedAt || 0) < communityLoadingRes.community.updatedAt) {
            this._community.initCommunityIpfsPropsNoMerge(communityLoadingRes.community);
            this._community.updateCid = communityLoadingRes.cid;
            // If we just discovered a name, trigger background resolution now (don't wait for next loop)
            if (!isStringDomain(this._community.address) &&
                this._community.name &&
                this._community.publicKey &&
                typeof this._community.nameResolved !== "boolean") {
                this._resolveNameInBackground(this._community.name);
            }
            log(`Remote Community`, this._community.address, `received a new update. Will emit an update event with updatedAt`, this._community.updatedAt, "that's", timestamp() - this._community.updatedAt, "seconds old");
            this._community._changeStateEmitEventEmitStateChangeEvent({
                event: { name: "update", args: [this._community] },
                newUpdatingState: "succeeded"
            });
            this._resolvePageAuthorNamesInBackground();
        }
        else if (communityLoadingRes === undefined) {
            // we loaded a community record that we already consumed
            // we will retry later
            this._community._setUpdatingStateWithEventEmissionIfNewState("waiting-retry");
        }
        else if (communityLoadingRes?.community) {
            this._community._setUpdatingStateWithEventEmissionIfNewState("succeeded");
        }
    }
    async startUpdatingLoop() {
        const log = Logger("pkc-js:remote-community:update");
        this._community._createStopAbortController();
        const areWeConnectedToKuboOrHelia = Object.keys(this._pkc.clients.kuboRpcClients).length > 0 || Object.keys(this._pkc.clients.libp2pJsClients).length > 0;
        const updateInterval = areWeConnectedToKuboOrHelia ? 1000 : this._pkc.updateInterval; // if we're on helia or kubo we should resolve IPNS every second
        while (this._community.state === "updating" && !this._community._getStopAbortSignal()?.aborted) {
            try {
                await this.updateOnce();
            }
            catch (e) {
                log.error(`Failed to update community ${this._community.address} for this iteration, will retry later`, e);
            }
            finally {
                await new Promise((resolve) => {
                    const stopSignal = this._community._getStopAbortSignal();
                    if (stopSignal?.aborted)
                        return resolve();
                    const timer = setTimeout(resolve, updateInterval);
                    stopSignal?.addEventListener("abort", () => {
                        clearTimeout(timer);
                        resolve();
                    }, { once: true });
                });
            }
        }
        this._community._clearStopAbortController();
        log("Community", this._community.address, "is no longer updating");
    }
    async stopUpdatingLoop() {
        this._ipnsLoadingOperation?.stop();
        this._updateCidsAlreadyLoaded.clear();
    }
    // fetching community ipns here
    async _resolveCommunityNameWithoutUpdatingState({ communityName, abortSignal, cache }) {
        this._suppressUpdatingStateForNameResolution++;
        try {
            return await this.resolveCommunityNameIfNeeded({ communityName, abortSignal, cache });
        }
        finally {
            this._suppressUpdatingStateForNameResolution--;
        }
    }
    _resolveNameInBackground(name) {
        const log = Logger("pkc-js:community-client-manager:_resolveNameInBackground");
        const setNameResolvedAndEmitUpdate = (newNameResolved) => {
            if (this._community.nameResolved === newNameResolved)
                return;
            this._community.nameResolved = newNameResolved;
            // Only emit update if the community has been loaded at least once —
            // otherwise we'd fire a premature "update" before the IPNS fetch completes.
            if (typeof this._community.updatedAt === "number") {
                this._community.emit("update", this._community);
            }
        };
        this._resolveCommunityNameWithoutUpdatingState({
            communityName: name,
            abortSignal: this._community._getStopAbortSignal(),
            // Background drift detection — 1h staleness window.
            cache: { maxAge: 3600 }
        })
            .then((resolved) => {
            if (resolved && resolved !== this._community.publicKey) {
                // Key change detected: name now points to a different key.
                // Most likely: cached publicKey is stale after community key migration.
                log("Key migration detected for", name, "old:", this._community.publicKey, "new:", resolved);
                const previousPublicKey = this._community.publicKey;
                const error = new PKCError("ERR_COMMUNITY_NAME_RESOLVES_TO_DIFFERENT_PUBLIC_KEY", {
                    communityName: name,
                    previousPublicKey,
                    newPublicKey: resolved
                });
                // Clear all data immediately (old data may be from compromised key)
                this._community._clearDataForKeyMigration(resolved);
                this._updateCidsAlreadyLoaded.clear();
                this._community.nameResolved = true;
                // Abort in-flight fetch (using old key) by aborting the stop controller,
                // then immediately create a new one so the update loop continues.
                this._community._abortStopOperations("Key migration: name resolved to different public key");
                this._community._createStopAbortController();
                // Emit update so UI drops stale data right away
                this._community.emit("update", this._community);
                this._community.emit("error", error);
            }
            else if (resolved) {
                setNameResolvedAndEmitUpdate(true);
            }
            // If resolved is null but community has a name, the name is not resolving
            if (!resolved && this._community.name) {
                setNameResolvedAndEmitUpdate(false);
            }
        })
            .catch((e) => {
            if (e instanceof PKCError && (e.code === "ERR_NO_RESOLVER_FOR_NAME" || e.code === "ERR_DOMAIN_TXT_RECORD_NOT_FOUND")) {
                // Definitive: either no resolver can handle this TLD, or the domain has no community TXT record.
                setNameResolvedAndEmitUpdate(false);
            }
            else {
                log.trace("Background name resolution failed for", name, e);
                // Transient failure -- leave nameResolved as undefined
            }
        });
    }
    _resolvePageAuthorNamesInBackground() {
        if (!this._pkc.resolveAuthorNames)
            return;
        const pages = this._community.posts?.pages;
        if (!pages)
            return;
        const authors = [];
        for (const page of Object.values(pages)) {
            if (!page)
                continue;
            for (const comment of page.comments) {
                const domain = getAuthorNameFromRuntime(comment.author);
                if (domain && typeof comment.author.nameResolved !== "boolean") {
                    authors.push({ authorName: domain, signaturePublicKey: comment.signature.publicKey });
                }
            }
        }
        if (authors.length === 0)
            return;
        this.resolveAuthorNamesInBackground({
            authors,
            onResolved: () => {
                // Silently re-apply cache to all pages — no update emission.
                // Only community.nameResolved changes should emit updates (handled by _resolveNameInBackground).
                for (const page of Object.values(this._community.posts?.pages || {})) {
                    if (page)
                        this._community.posts._applyNameResolvedCacheToPage(page);
                }
            },
            abortSignal: this._community._getStopAbortSignal()
        });
    }
    async fetchNewUpdateForCommunity(communityAddress) {
        return this._withInflightCommunityFetch(communityAddress, async () => {
            let ipnsName;
            const isDomain = isStringDomain(communityAddress);
            if (this._community.publicKey && (isDomain || (!isDomain && this._community.name && this._community.nameResolved === true))) {
                // Once a domain has been verified against a public key, keep fetching through the current public key
                // even if the immutable address on the instance is a raw IPNS key.
                ipnsName = this._community.publicKey;
                if (isDomain)
                    this._resolveNameInBackground(communityAddress);
            }
            else {
                // Name only or publicKey only: use existing resolution flow
                ipnsName = await this.resolveCommunityNameIfNeeded({
                    communityName: communityAddress,
                    abortSignal: this._community._getStopAbortSignal(),
                    // Subscribe-by-domain can ride the cache for up to 1h.
                    cache: { maxAge: 3600 }
                });
            }
            // When loaded by raw IPNS key, verify the record's name claim in background (once)
            if (!isDomain && this._community.name && this._community.publicKey && typeof this._community.nameResolved !== "boolean") {
                this._resolveNameInBackground(this._community.name);
            }
            if (!ipnsName)
                throw Error("Failed to resolve community address to an IPNS name");
            // If the community address is a domain, we need to update the ipnsName and ipns pubsub props
            // even if we fail to load the IPNS record, so that pubsub can work correctly
            if (this._community.ipnsName !== ipnsName) {
                this._community.ipnsName = ipnsName;
                // Default to a single-hop chain; the resolution step below replaces this with the
                // full chain ([anchor, ..., terminal]) once the IPNS record(s) are resolved.
                this._community.ipnsHops = [ipnsName];
                this._community.ipnsPubsubTopic = ipnsNameToIpnsOverPubsubTopic(ipnsName);
                this._community.ipnsPubsubTopicRoutingCid = pubsubTopicToDhtKey(this._community.ipnsPubsubTopic);
            }
            if (this._community.updateCid)
                this._updateCidsAlreadyLoaded.add(this._community.updateCid);
            // This function should fetch CommunityIpfs, parse it and verify its signature
            // Then return CommunityIpfs
            // only exception is if the ipnsRecord.value (ipfs path) has already been loaded and stored in this._updateCidsAlreadyLoaded
            // in that case no need to fetch the communityIpfs, we will return undefined
            this._community._setUpdatingStateWithEventEmissionIfNewState("fetching-ipns");
            let subRes;
            const areWeConnectedToKuboOrHelia = Object.keys(this._pkc.clients.kuboRpcClients).length > 0 || Object.keys(this._pkc.clients.libp2pJsClients).length > 0;
            if (areWeConnectedToKuboOrHelia) {
                const kuboRpcOrHelia = this.getDefaultKuboRpcClientOrHelia();
                // we're connected to kubo or helia
                try {
                    subRes = await this._fetchCommunityIpnsP2PAndVerify(ipnsName);
                }
                catch (e) {
                    //@ts-expect-error
                    e.details = {
                        //@ts-expect-error
                        ...e.details,
                        ipnsName,
                        communityAddress,
                        ipnsPubsubTopic: this._community.ipnsPubsubTopic,
                        ipnsPubsubTopicRoutingCid: this._community.ipnsPubsubTopicRoutingCid
                    };
                    throw e;
                }
                finally {
                    if ("_helia" in kuboRpcOrHelia)
                        this.updateLibp2pJsClientState("stopped", kuboRpcOrHelia._libp2pJsClientsOptions.key);
                    else
                        this.updateKuboRpcState("stopped", kuboRpcOrHelia.url);
                }
            }
            else
                subRes = await this._fetchCommunityFromGateways(ipnsName); // let's use gateways to fetch because we're not connected to kubo or helia
            // States of gateways should be updated by fetchFromMultipleGateways
            // Community records are verified within _fetchCommunityFromGateways
            if (subRes?.community) {
                // we found a new record that is verified.
                // Key the cache by the ANCHOR identity (domain or anchor IPNS name), not by the
                // record's signature key — for a delegated community the content is signed by the
                // terminal (minter) key, but the user-facing identity is the anchor. ipnsName here
                // is the anchor (ipnsHops[0]). For a non-delegated community the anchor equals the
                // signature-derived address, so this is unchanged.
                const anchorIdentityAddress = subRes.community.name || ipnsName;
                this._pkc._memCaches.communityForPublishing.set(anchorIdentityAddress, {
                    encryption: subRes.community.encryption,
                    pubsubTopic: subRes.community.pubsubTopic,
                    address: anchorIdentityAddress,
                    publicKey: ipnsName,
                    name: subRes.community.name
                });
            }
            return subRes;
        });
    }
    async _fetchCommunityIpnsP2PAndVerify(ipnsName) {
        const log = Logger("pkc-js:clients-manager:_fetchCommunityIpnsP2PAndVerify");
        const kuboRpcOrHelia = this.getDefaultKuboRpcClientOrHelia();
        if ("_helia" in kuboRpcOrHelia) {
            this.updateLibp2pJsClientState("fetching-ipns", kuboRpcOrHelia._libp2pJsClientsOptions.key);
        }
        else
            this.updateKuboRpcState("fetching-ipns", kuboRpcOrHelia.url);
        const { cid: latestCommunityCid, ipnsHops } = await this.resolveIpnsToCidP2P(ipnsName, {
            timeoutMs: this._pkc._timeouts["community-ipns"],
            abortSignal: this._community._getStopAbortSignal()
        });
        // ipnsHops[0] is the anchor (== ipnsName), ipnsHops.at(-1) is the terminal name whose
        // record points at the CID, i.e. the key that signs the community content. For a
        // non-delegated community the chain has a single element so terminal === anchor.
        this._community.ipnsHops = ipnsHops;
        const terminalIpnsName = ipnsHops[ipnsHops.length - 1];
        log.trace(`Resolved community IPNS`, ipnsName, `to CID`, latestCommunityCid, `via hops`, ipnsHops);
        if (this._updateCidsAlreadyLoaded.has(latestCommunityCid)) {
            log.trace("Resolved community IPNS", ipnsName, "to a cid that we already loaded before. No need to fetch its ipfs", latestCommunityCid);
            return undefined;
        }
        if ("_helia" in kuboRpcOrHelia)
            this.updateLibp2pJsClientState("fetching-ipfs", kuboRpcOrHelia._libp2pJsClientsOptions.key);
        else
            this.updateKuboRpcState("fetching-ipfs", kuboRpcOrHelia.url);
        this._community._setUpdatingStateWithEventEmissionIfNewState("fetching-ipfs");
        let rawCommunityJsonString;
        try {
            rawCommunityJsonString = await this._fetchCidP2P(latestCommunityCid, {
                maxFileSizeBytes: MAX_FILE_SIZE_BYTES_FOR_COMMUNITY_IPFS,
                timeoutMs: this._pkc._timeouts["community-ipfs"],
                abortSignal: this._community._getStopAbortSignal()
            });
        }
        catch (e) {
            //@ts-expect-error
            e.details = {
                //@ts-expect-error
                ...e.details,
                communityIpnsName: ipnsName,
                ipnsPubsubTopic: this._community.ipnsPubsubTopic,
                ipnsPubsubTopicRoutingCid: this._community.ipnsPubsubTopicRoutingCid,
                communityCid: latestCommunityCid
            };
            if (e instanceof PKCError && e.code === "ERR_OVER_DOWNLOAD_LIMIT")
                this._updateCidsAlreadyLoaded.add(latestCommunityCid);
            throw e;
        }
        this._updateCidsAlreadyLoaded.add(latestCommunityCid);
        try {
            const communityIpfs = parseCommunityIpfsSchemaPassthroughWithPKCErrorIfItFails(parseJsonWithPKCErrorIfFails(rawCommunityJsonString));
            const errInRecord = await this._findErrorInCommunityRecord({
                communityJson: communityIpfs,
                anchorIpnsName: ipnsName,
                terminalIpnsName,
                cidOfCommunityIpns: latestCommunityCid
            });
            if (errInRecord)
                throw errInRecord;
            return { community: communityIpfs, cid: latestCommunityCid };
        }
        catch (e) {
            // invalid community record
            e.details = {
                ...e.details,
                cidOfCommunityIpns: latestCommunityCid,
                ipnsPubsubTopic: this._community.ipnsPubsubTopic,
                ipnsPubsubTopicRoutingCid: this._community.ipnsPubsubTopicRoutingCid
            };
            throw e;
        }
    }
    async _fetchCommunityFromGateways(ipnsName) {
        const log = Logger("pkc-js:community:fetchCommunityFromGateways");
        const concurrencyLimit = 3;
        const timeoutMs = this._pkc._timeouts["community-ipns"];
        const queueLimit = pLimit(concurrencyLimit);
        // Only sort if we have more than 3 gateways
        const gatewaysSorted = remeda.keys.strict(this._pkc.clients.ipfsGateways).length <= concurrencyLimit
            ? remeda.keys.strict(this._pkc.clients.ipfsGateways)
            : await this._pkc._stats.sortGatewaysAccordingToScore("ipns");
        // need to handle
        // if all gateways returned the same community.updateCid
        const gatewayFetches = {};
        for (const gatewayUrl of gatewaysSorted) {
            const abortController = new AbortController();
            const throwIfGatewayRespondsWithInvalidCommunity = async (gatewayRes) => {
                if (typeof gatewayRes.resText !== "string")
                    throw Error("Gateway response has no body");
                // get ipfs cid of IPNS from header or calculate it
                const calculatedCommunityCidFromBody = await this.calculateIpfsCid(gatewayRes.resText); // cid v0
                if (this._updateCidsAlreadyLoaded.has(calculatedCommunityCidFromBody))
                    throw new PKCError("ERR_GATEWAY_ABORTING_LOADING_COMMUNITY_BECAUSE_WE_ALREADY_LOADED_THIS_RECORD", {
                        calculatedCommunityCidFromBody,
                        ipnsName,
                        ipnsPubsubTopic: this._community.ipnsPubsubTopic,
                        ipnsPubsubTopicRoutingCid: this._community.ipnsPubsubTopicRoutingCid,
                        gatewayRes,
                        gatewayUrl
                    });
                this._updateCidsAlreadyLoaded.add(calculatedCommunityCidFromBody);
                let communityIpfs;
                try {
                    communityIpfs = parseCommunityIpfsSchemaPassthroughWithPKCErrorIfItFails(parseJsonWithPKCErrorIfFails(gatewayRes.resText));
                }
                catch (e) {
                    e.details = {
                        ...e.details,
                        cidOfCommunityIpns: calculatedCommunityCidFromBody,
                        ipnsPubsubTopic: this._community.ipnsPubsubTopic,
                        ipnsPubsubTopicRoutingCid: this._community.ipnsPubsubTopicRoutingCid
                    };
                    throw e;
                }
                // Determine the terminal IPNS name (two-tier). For a normal (non-delegated) community
                // the content is signed by the anchor key itself, so no chain walk is needed and the
                // content signature alone secures the (untrusted) gateway response — Tier 1, a single
                // plain GET. Only when the record is signed by a DIFFERENT key (the hallmark of
                // delegation) do we escalate to Tier 2: independently follow & validate the IPNS record
                // chain (anchor -> ... -> terminal) via the same untrusted gateway's ?format=ipns-record
                // path, binding it to the anchor. A gateway cannot forge any hop's signature, so it
                // cannot substitute a different community. See docs/protocol/delegated-ipns.md.
                let terminalIpnsName = ipnsName;
                let ipnsHops = [ipnsName];
                const recordSignatureAddress = getPKCAddressFromPublicKeySync(communityIpfs.signature.publicKey);
                if (!this._areEquivalentCommunityAddresses(recordSignatureAddress, ipnsName)) {
                    const chain = await this._resolveIpnsChainViaGateway(gatewayUrl, ipnsName, abortController.signal);
                    if (chain.terminalCidV0 !== calculatedCommunityCidFromBody)
                        throw new PKCError("ERR_GATEWAY_IPNS_RECORD_CHAIN_INVALID", {
                            reason: "Terminal IPNS record CID does not match the community record served by the gateway",
                            terminalCidFromChain: chain.terminalCidV0,
                            calculatedCommunityCidFromBody,
                            ipnsHops: chain.ipnsHops,
                            ipnsName,
                            gatewayUrl
                        });
                    ipnsHops = chain.ipnsHops;
                    terminalIpnsName = chain.ipnsHops[chain.ipnsHops.length - 1];
                    // The content must be signed by the terminal key the chain ends at.
                    if (!this._areEquivalentCommunityAddresses(recordSignatureAddress, terminalIpnsName))
                        throw new PKCError("ERR_GATEWAY_IPNS_RECORD_CHAIN_INVALID", {
                            reason: "Community record signature key does not match the terminal IPNS name of the chain",
                            recordSignatureAddress,
                            terminalIpnsName,
                            ipnsHops,
                            ipnsName,
                            gatewayUrl
                        });
                }
                // Keep the resolved hops attached to THIS gateway's result; the winner (and its
                // matching hops) is chosen later in _findRecentCommunity. Mutating
                // this._community.ipnsHops here would let a slower/losing gateway overwrite it.
                gatewayFetches[gatewayUrl].ipnsHops = ipnsHops;
                const errorWithinRecord = await this._findErrorInCommunityRecord({
                    communityJson: communityIpfs,
                    anchorIpnsName: ipnsName,
                    terminalIpnsName,
                    cidOfCommunityIpns: calculatedCommunityCidFromBody
                });
                if (errorWithinRecord) {
                    delete errorWithinRecord["stack"];
                    if (errorWithinRecord.code === "ERR_COMMUNITY_SIGNATURE_IS_INVALID") {
                        const log = Logger("pkc-js:community-client-manager:throwIfGatewayRespondsWithInvalidCommunity");
                        const etag = gatewayRes?.res?.headers?.get("etag");
                        log.error(`Gateway ${gatewayUrl} returned community record with invalid signature. ` +
                            `Reason: ${errorWithinRecord.details?.signatureValidity?.reason}. ` +
                            `Client-computed CID: ${calculatedCommunityCidFromBody}. ` +
                            `Etag header (Kubo CID): ${etag}. ` +
                            `updatedAt: ${communityIpfs.updatedAt}. ` +
                            `Response body: ${gatewayRes.resText}`);
                    }
                    throw errorWithinRecord;
                }
                else {
                    gatewayFetches[gatewayUrl].communityRecord = communityIpfs;
                    gatewayFetches[gatewayUrl].cid = calculatedCommunityCidFromBody;
                    // Log the TTL from max-age header after successfully setting the community record
                    const cacheControl = gatewayRes?.res?.headers?.get("cache-control");
                    if (cacheControl) {
                        const maxAgeMatch = cacheControl.match(/max-age=(\d+)/);
                        if (maxAgeMatch && maxAgeMatch[1]) {
                            const ttl = parseInt(maxAgeMatch[1]);
                            gatewayFetches[gatewayUrl].ttl = ttl;
                        }
                    }
                }
            };
            const checkResponseHeadersIfOldCid = async (gatewayRes) => {
                const cidOfIpnsFromEtagHeader = gatewayRes?.headers?.get("etag")?.toString();
                // If etag is missing, skip early-abort optimization and let the body be fetched
                if (!cidOfIpnsFromEtagHeader) {
                    return; // Continue to fetch and validate the body normally
                }
                let parsedCid;
                try {
                    // clean up W/ prefix and quotes from the etag header
                    parsedCid = CID.parse(cidOfIpnsFromEtagHeader.replace(/^W\//, "").split('"').join("")).toV0().toString();
                }
                catch (e) {
                    // Malformed etag header - skip optimization and let body be fetched
                    return; // Continue to fetch and validate the body normally
                }
                if (this._updateCidsAlreadyLoaded.has(parsedCid)) {
                    abortController.abort("Aborting community IPNS request because we already loaded this record");
                    return new PKCError("ERR_GATEWAY_ABORTING_LOADING_COMMUNITY_BECAUSE_WE_ALREADY_LOADED_THIS_RECORD", {
                        cidOfIpnsFromEtagHeader,
                        ipnsName,
                        gatewayRes,
                        gatewayUrl
                    });
                }
            };
            const requestHeaders = this._updateCidsAlreadyLoaded.size > 0
                ? { "If-None-Match": '"' + Array.from(this._updateCidsAlreadyLoaded.values()).join(",") + '"' } // tell the gateway we already loaded these records
                : undefined;
            gatewayFetches[gatewayUrl] = {
                abortController,
                promise: queueLimit(() => this._fetchWithGateway(gatewayUrl, {
                    recordIpfsType: "ipns",
                    root: ipnsName,
                    recordPKCType: "community",
                    validateGatewayResponseFunc: throwIfGatewayRespondsWithInvalidCommunity,
                    abortRequestErrorBeforeLoadingBodyFunc: checkResponseHeadersIfOldCid,
                    abortController,
                    maxFileSizeBytes: MAX_FILE_SIZE_BYTES_FOR_COMMUNITY_IPFS,
                    timeoutMs: this._pkc._timeouts["community-ipns"],
                    log,
                    requestHeaders: requestHeaders
                })),
                timeoutId: setTimeout(() => abortController.abort("Aborting community IPNS request because it timed out after " + timeoutMs + "ms"), timeoutMs)
            };
        }
        const stopSignal = this._community._getStopAbortSignal();
        const onStopAbort = () => cleanUp();
        const cleanUp = () => {
            queueLimit.clearQueue();
            Object.values(gatewayFetches).forEach((gateway) => {
                if (!gateway.communityRecord && !gateway.error)
                    gateway.abortController.abort("Cleaning up requests for community");
                clearTimeout(gateway.timeoutId);
            });
            if (stopSignal)
                stopSignal.removeEventListener("abort", onStopAbort);
        };
        if (stopSignal) {
            throwIfAbortSignalAborted(stopSignal);
            stopSignal.addEventListener("abort", onStopAbort, { once: true });
        }
        const _findRecentCommunity = () => {
            // Try to find a very recent community
            // If not then go with the most recent community record after fetching from 3 gateways
            const winner = selectWinningGatewayCommunity({
                gatewayFetches,
                currentUpdatedAt: this._community.raw.communityIpfs?.updatedAt || 0,
                totalGateways: gatewaysSorted.length,
                fallbackIpnsName: ipnsName
            });
            if (!winner)
                return undefined;
            log(`Gateway (${winner.bestGatewayUrl}) was able to find a very recent community (${this._deriveAddressFromWireRecord(winner.community)}) whose IPNS is (${ipnsName}).  The record has updatedAt (${winner.community.updatedAt}) that's ${winner.recordAgeSeconds}s old with a TTL of ${gatewayFetches[winner.bestGatewayUrl].ttl} seconds`);
            // Bind ipnsHops to the gateway result we're actually keeping.
            this._community.ipnsHops = winner.ipnsHops;
            return { community: winner.community, cid: winner.cid };
        };
        const promisesToIterate = Object.values(gatewayFetches).map((gatewayFetch) => gatewayFetch.promise);
        let suitableCommunity;
        try {
            suitableCommunity = await new Promise((resolve, reject) => promisesToIterate.map((gatewayPromise, i) => gatewayPromise
                .then(async (res) => {
                if ("error" in res)
                    Object.values(gatewayFetches)[i].error = res.error;
                const gatewaysWithError = remeda.keys
                    .strict(gatewayFetches)
                    .filter((gatewayUrl) => gatewayFetches[gatewayUrl].error);
                if (gatewaysWithError.length === gatewaysSorted.length)
                    // All gateways failed
                    reject("All gateways failed to fetch community record " + ipnsName);
                const recentCommunity = _findRecentCommunity();
                if (recentCommunity) {
                    cleanUp();
                    resolve(recentCommunity);
                }
            })
                .catch((err) => reject("One of the gateway promise requests thrown an error, should not happens:" + err))));
        }
        catch {
            cleanUp();
            throwIfAbortSignalAborted(stopSignal);
            const gatewayToError = remeda.mapValues(gatewayFetches, (gatewayFetch) => gatewayFetch.error);
            const hasGatewayConfirmingCurrentRecord = Object.keys(gatewayFetches)
                .map((gatewayUrl) => gatewayFetches[gatewayUrl].error)
                .some((err) => err.details?.status === 304 ||
                err.code === "ERR_GATEWAY_ABORTING_LOADING_COMMUNITY_BECAUSE_WE_ALREADY_LOADED_THIS_RECORD");
            if (hasGatewayConfirmingCurrentRecord)
                return undefined; // any gateway confirmed we already have the latest consumed record
            const combinedError = new FailedToFetchCommunityFromGatewaysError({
                ipnsName,
                gatewayToError,
                communityAddress: this._community.address,
                ipnsPubsubTopic: this._community.ipnsPubsubTopic,
                ipnsPubsubTopicRoutingCid: this._community.ipnsPubsubTopicRoutingCid
            });
            delete combinedError.stack;
            throw combinedError;
        }
        // TODO add punishment for gateway that returns old ipns record
        // TODO add punishment for gateway that returns invalid community
        return suitableCommunity;
    }
    // Walks a (potentially delegated) IPNS chain via an UNTRUSTED gateway, validating each
    // record's signature against its name (?format=ipns-record). Returns the ordered chain of
    // IPNS names and the terminal /ipfs/ CID. Used only when a gateway-served community record
    // is signed by a key other than the anchor (i.e. a possible delegated chain) — for the
    // common non-delegated case the content signature against the anchor is sufficient and no
    // extra gateway round-trips are made. Mirrors the P2P resolver's hop cap
    // (BaseClientsManager.MAX_IPNS_HOPS): a chain longer than a single anchor -> minter hop is
    // rejected with ERR_IPNS_MAX_HOPS_EXCEEDED, consistent with the kubo RPC / helia paths.
    // See docs/protocol/delegated-ipns.md.
    async _resolveIpnsChainViaGateway(gatewayUrl, anchorIpnsName, abortSignal) {
        const ipnsHops = [anchorIpnsName];
        let currentName = anchorIpnsName;
        // The loop exits only via a return (we hit a terminal /ipfs/ value) or a throw (unsupported
        // value, or too many hops).
        while (true) {
            // Label the hop we're about to validate so any failure (forged/tampered record, bad value,
            // bad CID) names WHICH record was at fault. hop 0 is the anchor; with MAX_IPNS_HOPS === 1
            // the only other hop ever fetched is the minter (the cap below throws before a 3rd hop is
            // fetched), so role is unambiguous: "anchor" or "minter". See docs/protocol/delegated-ipns.md.
            const hopIndex = ipnsHops.length - 1;
            const hopRole = hopIndex === 0 ? "anchor" : "minter";
            const recordContext = { hopRole, hopIndex, anchorIpnsName };
            const record = await fetchAndValidateIpnsRecordFromGateway(gatewayUrl, currentName, { abortSignal, recordContext });
            const value = String(record.value);
            if (isIpnsPath(value)) {
                currentName = value.split("/")[2];
                ipnsHops.push(currentName);
                // ipnsHops.length - 1 is the number of /ipns/ -> /ipns/ hops followed so far.
                if (ipnsHops.length - 1 > BaseClientsManager.MAX_IPNS_HOPS)
                    throw new PKCError("ERR_IPNS_MAX_HOPS_EXCEEDED", {
                        ...recordContext,
                        ipnsHops,
                        maxHops: BaseClientsManager.MAX_IPNS_HOPS,
                        via: "gateway"
                    });
                continue;
            }
            if (isIpfsPath(value)) {
                let terminalCidV0;
                try {
                    terminalCidV0 = CID.parse(value.split("/")[2]).toV0().toString();
                }
                catch (e) {
                    throw new PKCError("ERR_GATEWAY_IPNS_RECORD_CHAIN_INVALID", {
                        reason: "Terminal IPNS record value is not a valid CID",
                        ...recordContext,
                        terminalValue: value,
                        ipnsHops
                    });
                }
                return { ipnsHops, terminalCidV0 };
            }
            throw new PKCError("ERR_GATEWAY_IPNS_RECORD_CHAIN_INVALID", {
                reason: "IPNS record value is neither an /ipfs/ nor an /ipns/ path",
                ...recordContext,
                unsupportedValue: value,
                ipnsHops
            });
        }
    }
    async _findErrorInCommunityRecord({ communityJson, anchorIpnsName, terminalIpnsName, cidOfCommunityIpns }) {
        const communityInstanceAddress = this._getCommunityAddressFromInstance();
        const recordAddress = this._deriveAddressFromWireRecord(communityJson);
        const addressMatchesInstance = this._areEquivalentCommunityAddresses(recordAddress, communityInstanceAddress);
        // When address is a domain but we loaded via publicKey fallback, the record's derived address
        // might be the publicKey (if the record has no name field) — also accept that as a match
        const addressMatchesPublicKey = this._community.publicKey
            ? this._areEquivalentCommunityAddresses(recordAddress, this._community.publicKey)
            : false;
        // Accept when user loaded by raw IPNS key and the record's signature key matches the
        // TERMINAL name of the resolved chain. For a non-delegated community terminal === anchor,
        // so this is the original behaviour. For a delegated community the content is signed by the
        // terminal (minter) key, and the anchor -> terminal binding is guaranteed by the
        // cryptographically-verified IPNS record chain (see docs/protocol/delegated-ipns.md).
        // Handles: {address: "12D3Koo..."} loads record with name: "plebbit.bso".
        // NOT applied for domain addresses (Scenario C stays rejected — a domain load must match
        // the record's name field, not merely its signature key).
        const instanceAddressIsDomain = isStringDomain(communityInstanceAddress);
        const signatureKeyMatchesTerminal = !instanceAddressIsDomain
            ? this._areEquivalentCommunityAddresses(getPKCAddressFromPublicKeySync(communityJson.signature.publicKey), terminalIpnsName)
            : false;
        if (!addressMatchesInstance && !addressMatchesPublicKey && !signatureKeyMatchesTerminal) {
            // Did the gateway supply us with a different community's ipns
            const error = new PKCError("ERR_THE_COMMUNITY_IPNS_RECORD_POINTS_TO_DIFFERENT_ADDRESS_THAN_WE_EXPECTED", {
                // The record's signer matches none of the identities we accept: the address we loaded,
                // the community publicKey (the anchor), or the terminal/minter of the resolved IPNS
                // chain. Either a different community's record was served, or a delegated chain's content
                // is signed by an unexpected key (anchor -> terminal binding broken). The booleans below
                // say which checks failed. For a delegated load the role that should have matched is the
                // "minter" (terminalIpnsName). See docs/protocol/delegated-ipns.md.
                reason: "Community record signer does not match the loaded address, the community publicKey (anchor), or the terminal (minter) IPNS name of the resolved chain",
                recordSignerAddress: recordAddress,
                expectedAnchorOrInstance: communityInstanceAddress,
                communityPublicKey: this._community.publicKey,
                expectedTerminalMinter: terminalIpnsName,
                matchChecks: { addressMatchesInstance, addressMatchesPublicKey, signatureKeyMatchesTerminal },
                isDelegatedChain: anchorIpnsName !== terminalIpnsName,
                addressFromCommunityInstance: communityInstanceAddress,
                ipnsName: anchorIpnsName,
                terminalIpnsName,
                addressFromGateway: recordAddress,
                communityIpnsFromGateway: communityJson,
                ipnsPubsubTopic: this._community.ipnsPubsubTopic,
                ipnsPubsubTopicRoutingCid: this._community.ipnsPubsubTopicRoutingCid,
                cidOfCommunityIpns
            });
            return error;
        }
        // Verify the content signature against the TERMINAL name (the minter key in a delegated
        // chain, or the anchor itself when not delegated). verifyCommunity hard-checks that
        // communityIpnsName matches signature.publicKey, so it must receive the terminal name.
        const verificationOpts = {
            community: communityJson,
            communityIpnsName: terminalIpnsName,
            resolveAuthorNames: this._pkc.resolveAuthorNames,
            clientsManager: this,
            validatePages: this._pkc.validatePages,
            abortSignal: this._community._getStopAbortSignal()
        };
        const updateValidity = await verifyCommunity(verificationOpts);
        if (!updateValidity.valid) {
            const error = new PKCError("ERR_COMMUNITY_SIGNATURE_IS_INVALID", {
                signatureValidity: updateValidity,
                ipnsPubsubTopic: this._community.ipnsPubsubTopic,
                ipnsPubsubTopicRoutingCid: this._community.ipnsPubsubTopicRoutingCid,
                verificationOpts,
                cidOfCommunityIpns
            });
            return error;
        }
    }
}
//# sourceMappingURL=community-client-manager.js.map