import { Transaction } from '../transaction/index.js';
import OverlayAdminTokenTemplate from './OverlayAdminTokenTemplate.js';
import * as Utils from '../primitives/utils.js';
import { getOverlayHostReputationTracker, HostReputationTracker } from './HostReputationTracker.js';
const defaultFetch = typeof globalThis !== 'undefined' && typeof globalThis.fetch === 'function'
    ? globalThis.fetch.bind(globalThis)
    : fetch;
/** Default SLAP trackers */
export const DEFAULT_SLAP_TRACKERS = [
    // BSVA clusters
    'https://overlay-us-1.bsvb.tech',
    'https://overlay-eu-1.bsvb.tech',
    'https://overlay-ap-1.bsvb.tech',
    // Babbage primary overlay service
    'https://users.bapp.dev'
    // NOTE: Other entities may submit pull requests to the library if they maintain SLAP overlay services.
    // Additional trackers run by different entities contribute to greater network resiliency.
    // It also generally doesn't hurt to have more trackers in this list.
    // DISCLAIMER:
    // Trackers known to host invalid or illegal records will be removed at the discretion of the BSV Association.
];
/** Default testnet SLAP trackers */
export const DEFAULT_TESTNET_SLAP_TRACKERS = [
    // Babbage primary testnet overlay service
    'https://testnet-users.bapp.dev'
];
const MAX_TRACKER_WAIT_TIME = 5000;
export class HTTPSOverlayLookupFacilitator {
    fetchClient;
    allowHTTP;
    constructor(httpClient = defaultFetch, allowHTTP = false) {
        if (typeof httpClient !== 'function') {
            throw new TypeError('HTTPSOverlayLookupFacilitator requires a fetch implementation. ' +
                'In environments without fetch, provide a polyfill or custom implementation.');
        }
        this.fetchClient = httpClient;
        this.allowHTTP = allowHTTP;
    }
    async lookup(url, question, timeout = 2000) {
        if (!url.startsWith('https:') && !this.allowHTTP) {
            throw new Error('HTTPS facilitator can only use URLs that start with "https:"');
        }
        const controller = typeof AbortController === 'undefined' ? undefined : new AbortController();
        const timer = setTimeout(() => {
            try {
                controller?.abort();
            }
            catch { /* noop */ }
        }, timeout);
        try {
            const fco = {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'X-Aggregation': 'yes'
                },
                body: JSON.stringify({ service: question.service, query: question.query }),
                signal: controller?.signal
            };
            const response = await this.fetchClient(`${url}/lookup`, fco);
            if (!response.ok)
                throw new Error(`Failed to facilitate lookup (HTTP ${response.status})`);
            if (response.headers.get('content-type') === 'application/octet-stream') {
                const payload = await response.arrayBuffer();
                const r = new Utils.Reader([...new Uint8Array(payload)]);
                const nOutpoints = r.readVarIntNum();
                const outpoints = [];
                for (let i = 0; i < nOutpoints; i++) {
                    const txid = Utils.toHex(r.read(32));
                    const outputIndex = r.readVarIntNum();
                    const contextLength = r.readVarIntNum();
                    let context;
                    if (contextLength > 0) {
                        context = r.read(contextLength);
                    }
                    outpoints.push({
                        txid,
                        outputIndex,
                        context
                    });
                }
                const beef = r.read();
                return {
                    type: 'output-list',
                    outputs: outpoints.map(x => ({
                        outputIndex: x.outputIndex,
                        context: x.context,
                        beef: Transaction.fromBEEF(beef, x.txid).toBEEF()
                    }))
                };
            }
            else {
                return await response.json();
            }
        }
        catch (e) {
            // Normalize timeouts to a consistent error message
            if (e?.name === 'AbortError')
                throw new Error('Request timed out');
            throw e;
        }
        finally {
            clearTimeout(timer);
        }
    }
}
/**
 * Represents a Lookup Resolver.
 */
export default class LookupResolver {
    facilitator;
    slapTrackers;
    hostOverrides;
    additionalHosts;
    networkPreset;
    hostReputation;
    // ---- Caches / memoization ----
    hostsCache;
    hostsInFlight;
    hostsTtlMs;
    hostsMaxEntries;
    txMemo;
    txMemoTtlMs;
    constructor(config = {}) {
        this.networkPreset = config.networkPreset ?? 'mainnet';
        this.facilitator = config.facilitator ?? new HTTPSOverlayLookupFacilitator(undefined, this.networkPreset === 'local');
        this.slapTrackers = config.slapTrackers ?? (this.networkPreset === 'mainnet' ? DEFAULT_SLAP_TRACKERS : DEFAULT_TESTNET_SLAP_TRACKERS);
        const hostOverrides = config.hostOverrides ?? {};
        this.assertValidOverrideServices(hostOverrides);
        this.hostOverrides = hostOverrides;
        this.additionalHosts = config.additionalHosts ?? {};
        const rs = config.reputationStorage;
        if (rs === 'localStorage') {
            this.hostReputation = new HostReputationTracker();
        }
        else if (typeof rs === 'object' && rs !== null && typeof rs.get === 'function' && typeof rs.set === 'function') {
            this.hostReputation = new HostReputationTracker(rs);
        }
        else {
            this.hostReputation = getOverlayHostReputationTracker();
        }
        // cache tuning
        this.hostsTtlMs = config.cache?.hostsTtlMs ?? 5 * 60 * 1000; // 5 min
        this.hostsMaxEntries = config.cache?.hostsMaxEntries ?? 128;
        this.txMemoTtlMs = config.cache?.txMemoTtlMs ?? 10 * 60 * 1000; // 10 min
        this.hostsCache = new Map();
        this.hostsInFlight = new Map();
        this.txMemo = new Map();
    }
    /**
     * Given a LookupQuestion, returns a LookupAnswer. Aggregates across multiple services and supports resiliency.
     *
     * Optional `options.graceMs` overrides the per-call grace window (default 80 ms).
     * Optional `options.softTimeoutMs` resolves the query early with whatever has arrived once any host has
     * answered (or with an empty result if no host has answered by `softTimeoutMs`).
     */
    async query(question, timeout, options) {
        let last = null;
        // Existing fast-but-narrow contract: return at the first cumulative emission
        // (the post-grace aggregate, or the final emission when every host settles
        // before the grace window). Callers wanting progressive enrichment use query$().
        for await (const partial of this.query$(question, timeout, options)) {
            last = partial;
            break;
        }
        return {
            type: 'output-list',
            outputs: last?.outputs ?? []
        };
    }
    /**
     * Iterable form of {@link query}. Emits partial results as hosts answer.
     *
     * Emission order:
     *  - First emission: after the grace window expires (or as soon as the soft timeout elapses), containing
     *    every output gathered from hosts that answered by then.
     *  - Subsequent emissions: re-emitted whenever a late host returns extra outputs that weren't in earlier
     *    emissions. Each emission contains the cumulative `outputs` set.
     *  - Final emission: `isFinal: true` once all in-flight hosts have settled (success / fail / timeout). The
     *    caller can `break` early; outstanding work is bounded by the per-host timeout.
     *
     * No host work runs past its per-host `timeout` — there is no leak risk on early break.
     */
    async *query$(question, timeout, options) {
        let competentHosts = [];
        if (question.service === 'ls_slap') {
            competentHosts = this.networkPreset === 'local' ? ['http://localhost:8080'] : this.slapTrackers;
        }
        else if (this.hostOverrides[question.service] != null) {
            competentHosts = this.hostOverrides[question.service];
        }
        else if (this.networkPreset === 'local') {
            competentHosts = ['http://localhost:8080'];
        }
        else {
            competentHosts = await this.getCompetentHostsCached(question.service);
        }
        if (this.additionalHosts[question.service]?.length > 0) {
            const extra = this.additionalHosts[question.service];
            const seen = new Set(competentHosts);
            for (const h of extra)
                if (!seen.has(h))
                    competentHosts.push(h);
        }
        if (competentHosts.length < 1) {
            throw new Error(`No competent ${this.networkPreset} hosts found by the SLAP trackers for lookup service: ${question.service}`);
        }
        const rankedHosts = this.prepareHostsForQuery(competentHosts, `lookup service ${question.service}`);
        if (rankedHosts.length < 1) {
            throw new Error(`All competent hosts for ${question.service} are temporarily unavailable due to backoff.`);
        }
        const graceMs = options?.graceMs ?? 80;
        const softTimeoutMs = options?.softTimeoutMs;
        const hostCount = rankedHosts.length;
        const outputsMap = new Map();
        const txIds = [];
        let completedHosts = 0;
        let firstResponseAt = null;
        const queue = [];
        let waiter = null;
        const push = (e) => {
            queue.push(e);
            if (waiter !== null) {
                const w = waiter;
                waiter = null;
                w();
            }
        };
        for (const host of rankedHosts) {
            this.lookupHostWithTracking(host, question, timeout)
                .then((answer) => {
                if (answer?.type === 'output-list' && Array.isArray(answer.outputs) && answer.outputs.length > 0) {
                    push({ kind: 'answer', answer });
                }
            })
                .catch(() => { })
                .finally(() => {
                completedHosts++;
                push({ kind: 'done' });
            });
        }
        let softTimer = null;
        if (typeof softTimeoutMs === 'number' && softTimeoutMs >= 0) {
            softTimer = setTimeout(() => push({ kind: 'soft' }), softTimeoutMs);
        }
        let graceTimer = null;
        let graceFired = false;
        let emittedOnce = false;
        const beefKey = (beef) => {
            if (typeof beef !== 'object' || beef == null)
                return '';
            return beef.join(',');
        };
        const mergeAnswer = (answer) => {
            let added = false;
            for (const output of answer.outputs) {
                const keyForBeef = beefKey(output.beef);
                let memo = this.txMemo.get(keyForBeef);
                const now = Date.now();
                if (typeof memo !== 'object' || memo === null || memo.expiresAt <= now) {
                    try {
                        const txId = Transaction.fromBEEF(output.beef).id('hex');
                        memo = { txId, expiresAt: now + this.txMemoTtlMs };
                        if (this.txMemo.size > 4096)
                            this.evictOldest(this.txMemo);
                        this.txMemo.set(keyForBeef, memo);
                    }
                    catch {
                        continue;
                    }
                }
                const uniqKey = `${memo.txId}.${output.outputIndex}`;
                if (!outputsMap.has(uniqKey)) {
                    outputsMap.set(uniqKey, output);
                    txIds.push(memo.txId);
                    added = true;
                }
            }
            return added;
        };
        const snapshot = (isFinal) => ({
            type: 'output-list',
            outputs: Array.from(outputsMap.values()),
            txIds: txIds.slice(),
            isFinal,
            hostCount,
            completedHosts
        });
        try {
            while (completedHosts < hostCount) {
                if (queue.length === 0) {
                    await new Promise((resolve) => { waiter = resolve; });
                }
                const e = queue.shift();
                if (e.kind === 'answer') {
                    const added = mergeAnswer(e.answer);
                    if (firstResponseAt === null) {
                        firstResponseAt = Date.now();
                        if (!graceFired && graceMs > 0) {
                            graceTimer = setTimeout(() => {
                                graceFired = true;
                                push({ kind: 'soft' });
                            }, graceMs);
                        }
                        else {
                            graceFired = true;
                        }
                    }
                    if (graceFired && added) {
                        emittedOnce = true;
                        yield snapshot(false);
                    }
                }
                else if (e.kind === 'soft') {
                    if (!emittedOnce) {
                        graceFired = true;
                        emittedOnce = true;
                        yield snapshot(false);
                    }
                    if (typeof softTimeoutMs === 'number' && firstResponseAt !== null) {
                        // Soft timeout: caller asked to bail out once any answer is in. Yield final.
                        break;
                    }
                }
                else if (e.kind === 'done') {
                    // continue loop; final emission happens after the loop
                }
            }
            yield snapshot(true);
        }
        finally {
            if (graceTimer !== null)
                clearTimeout(graceTimer);
            if (softTimer !== null)
                clearTimeout(softTimer);
        }
    }
    /**
     * Cached wrapper for competent host discovery with stale-while-revalidate.
     */
    async getCompetentHostsCached(service) {
        const now = Date.now();
        const cached = this.hostsCache.get(service);
        // if fresh, return immediately
        if (typeof cached === 'object' && cached.expiresAt > now) {
            return cached.hosts.slice();
        }
        // if stale but present, kick off a refresh if not already in-flight and return stale
        if (typeof cached === 'object' && cached.expiresAt <= now) {
            if (!this.hostsInFlight.has(service)) {
                this.hostsInFlight.set(service, this.refreshHosts(service).finally(() => {
                    this.hostsInFlight.delete(service);
                }));
            }
            return cached.hosts.slice();
        }
        // no cache: coalesce concurrent requests
        if (this.hostsInFlight.has(service)) {
            try {
                const hosts = await this.hostsInFlight.get(service);
                if (typeof hosts !== 'object') {
                    throw new TypeError('Hosts is not defined.');
                }
                return hosts.slice();
            }
            catch {
                // fall through to a fresh attempt below
            }
        }
        const promise = this.refreshHosts(service).finally(() => {
            this.hostsInFlight.delete(service);
        });
        this.hostsInFlight.set(service, promise);
        const hosts = await promise;
        return hosts.slice();
    }
    /**
     * Actually resolves competent hosts from SLAP trackers and updates cache.
     */
    async refreshHosts(service) {
        const hosts = await this.findCompetentHosts(service);
        const expiresAt = Date.now() + this.hostsTtlMs;
        // bounded cache with simple FIFO eviction
        if (!this.hostsCache.has(service) && this.hostsCache.size >= this.hostsMaxEntries) {
            const oldestKey = this.hostsCache.keys().next().value;
            if (oldestKey !== undefined)
                this.hostsCache.delete(oldestKey);
        }
        this.hostsCache.set(service, { hosts, expiresAt });
        return hosts;
    }
    /**
     * Extracts competent host domains from a SLAP tracker response.
     */
    extractHostsFromAnswer(answer, service) {
        const hosts = [];
        if (answer.type !== 'output-list')
            return hosts;
        for (const output of answer.outputs) {
            try {
                const tx = Transaction.fromBEEF(output.beef);
                const script = tx.outputs[output.outputIndex]?.lockingScript;
                if (typeof script !== 'object' || script === null)
                    continue;
                const parsed = OverlayAdminTokenTemplate.decode(script);
                if (parsed.topicOrService !== service || parsed.protocol !== 'SLAP')
                    continue;
                if (typeof parsed.domain === 'string' && parsed.domain.length > 0) {
                    hosts.push(parsed.domain);
                }
            }
            catch {
                continue;
            }
        }
        return hosts;
    }
    /**
     * Returns a list of competent hosts for a given lookup service.
     * Resolves as soon as the first SLAP tracker responds with valid hosts.
     * Remaining trackers continue in the background for reputation tracking.
     * @param service Service for which competent hosts are to be returned
     * @returns Array of hosts competent for resolving queries
     */
    async findCompetentHosts(service) {
        const query = {
            service: 'ls_slap',
            query: { service }
        };
        const trackerHosts = this.prepareHostsForQuery(this.slapTrackers, 'SLAP trackers');
        if (trackerHosts.length === 0)
            return [];
        // Fire all trackers, resolve as soon as any returns valid hosts.
        // Remaining trackers continue in the background for reputation tracking.
        return await new Promise((resolve) => {
            const allHosts = new Set();
            let resolved = false;
            let pending = trackerHosts.length;
            for (const tracker of trackerHosts) {
                this.lookupHostWithTracking(tracker, query, MAX_TRACKER_WAIT_TIME)
                    .then((answer) => {
                    const hosts = this.extractHostsFromAnswer(answer, service);
                    for (const h of hosts)
                        allHosts.add(h);
                    if (!resolved && allHosts.size > 0) {
                        resolved = true;
                        resolve([...allHosts]);
                    }
                })
                    .catch(() => { })
                    .finally(() => {
                    pending--;
                    if (pending === 0 && !resolved) {
                        resolved = true;
                        resolve([...allHosts]);
                    }
                });
            }
        });
    }
    /** Evict an arbitrary “oldest” entry from a Map (iteration order). */
    evictOldest(m) {
        const firstKey = m.keys().next().value;
        if (firstKey !== undefined)
            m.delete(firstKey);
    }
    assertValidOverrideServices(overrides) {
        for (const service of Object.keys(overrides)) {
            if (!service.startsWith('ls_')) {
                throw new Error(`Host override service names must start with "ls_": ${service}`);
            }
        }
    }
    prepareHostsForQuery(hosts, context) {
        if (hosts.length === 0)
            return [];
        const now = Date.now();
        const ranked = this.hostReputation.rankHosts(hosts, now);
        const available = ranked.filter((h) => h.backoffUntil <= now).map((h) => h.host);
        if (available.length > 0)
            return available;
        const soonest = Math.min(...ranked.map((h) => h.backoffUntil));
        const waitMs = Math.max(soonest - now, 0);
        throw new Error(`All ${context} hosts are backing off for approximately ${waitMs}ms due to repeated failures.`);
    }
    async lookupHostWithTracking(host, question, timeout) {
        const startedAt = Date.now();
        try {
            const answer = await this.facilitator.lookup(host, question, timeout);
            const latency = Date.now() - startedAt;
            const isValid = typeof answer === 'object' &&
                answer !== null &&
                answer.type === 'output-list' &&
                Array.isArray((answer).outputs);
            if (isValid) {
                this.hostReputation.recordSuccess(host, latency);
            }
            else {
                this.hostReputation.recordFailure(host, 'Invalid lookup response');
            }
            return answer;
        }
        catch (err) {
            this.hostReputation.recordFailure(host, err);
            throw err;
        }
    }
}
//# sourceMappingURL=LookupResolver.js.map