import { Transaction } from '../transaction/index.js';
import * as Utils from '../primitives/utils.js';
import LookupResolver from './LookupResolver.js';
import OverlayAdminTokenTemplate from './OverlayAdminTokenTemplate.js';
const MAX_SHIP_QUERY_TIMEOUT = 5000;
export class HTTPSOverlayBroadcastFacilitator {
    httpClient;
    allowHTTP;
    constructor(httpClient = fetch, allowHTTP = false) {
        this.httpClient = httpClient;
        this.allowHTTP = allowHTTP;
    }
    async send(url, taggedBEEF) {
        if (!url.startsWith('https:') && !this.allowHTTP) {
            throw new Error('HTTPS facilitator can only use URLs that start with "https:"');
        }
        const headers = {
            'Content-Type': 'application/octet-stream',
            'X-Topics': JSON.stringify(taggedBEEF.topics)
        };
        let body;
        if (Array.isArray(taggedBEEF.offChainValues)) {
            headers['x-includes-off-chain-values'] = 'true';
            const w = new Utils.Writer();
            w.writeVarIntNum(taggedBEEF.beef.length);
            w.write(taggedBEEF.beef);
            w.write(taggedBEEF.offChainValues);
            body = new Uint8Array(w.toArray());
        }
        else {
            body = new Uint8Array(taggedBEEF.beef);
        }
        const response = await fetch(`${url}/submit`, {
            method: 'POST',
            headers,
            body
        });
        if (response.ok) {
            return await response.json();
        }
        else {
            throw new Error('Failed to facilitate broadcast');
        }
    }
}
/**
 * Broadcasts transactions to one or more overlay topics.
 */
export default class TopicBroadcaster {
    topics;
    facilitator;
    resolver;
    requireAcknowledgmentFromAllHostsForTopics;
    requireAcknowledgmentFromAnyHostForTopics;
    requireAcknowledgmentFromSpecificHostsForTopics;
    networkPreset;
    // Cache for findInterestedHosts to avoid repeated SHIP tracker lookups
    interestedHostsCache = null;
    interestedHostsInFlight = null;
    interestedHostsTtlMs;
    /**
     * Constructs an instance of the SHIP broadcaster.
     *
     * @param {string[]} topics - The list of SHIP topic names where transactions are to be sent.
     * @param {SHIPBroadcasterConfig} config - Configuration options for the SHIP broadcaster.
     */
    constructor(topics, config = {}) {
        if (topics.length === 0) {
            throw new Error('At least one topic is required for broadcast.');
        }
        if (topics.some((x) => !x.startsWith('tm_'))) {
            throw new Error('Every topic must start with "tm_".');
        }
        this.topics = topics;
        this.networkPreset = config.networkPreset ?? 'mainnet';
        this.facilitator = config.facilitator ?? new HTTPSOverlayBroadcastFacilitator(undefined, this.networkPreset === 'local');
        this.resolver = config.resolver ?? new LookupResolver({ networkPreset: this.networkPreset });
        this.requireAcknowledgmentFromAllHostsForTopics =
            config.requireAcknowledgmentFromAllHostsForTopics ?? [];
        this.requireAcknowledgmentFromAnyHostForTopics =
            config.requireAcknowledgmentFromAnyHostForTopics ?? 'all';
        this.requireAcknowledgmentFromSpecificHostsForTopics =
            config.requireAcknowledgmentFromSpecificHostsForTopics ?? {};
        this.interestedHostsTtlMs = 5 * 60 * 1000; // 5 minutes
    }
    /**
     * Broadcasts a transaction to Overlay Services via SHIP.
     *
     * @param {Transaction} tx - The transaction to be sent.
     * @returns {Promise<BroadcastResponse | BroadcastFailure>} A promise that resolves to either a success or failure response.
     */
    async broadcast(tx) {
        let beef;
        const offChainValues = tx.metadata.get('OffChainValues');
        try {
            beef = tx.toBEEF();
        }
        catch (error) {
            throw new Error('Transactions sent via SHIP to Overlay Services must be serializable to BEEF format.');
        }
        const interestedHosts = await this.findInterestedHosts();
        if (Object.keys(interestedHosts).length === 0) {
            return {
                status: 'error',
                code: 'ERR_NO_HOSTS_INTERESTED',
                description: `No ${this.networkPreset} hosts are interested in receiving this transaction.`
            };
        }
        const hostPromises = Object.entries(interestedHosts).map(async ([host, topics]) => {
            try {
                const steak = await this.facilitator.send(host, {
                    beef,
                    offChainValues,
                    topics: [...topics]
                });
                if (steak == null || Object.keys(steak).length === 0) {
                    throw new Error('Steak has no topics.');
                }
                return { host, success: true, steak };
            }
            catch (error) {
                console.error(error);
                // Log error if needed
                return { host, success: false, error };
            }
        });
        const results = await Promise.all(hostPromises);
        const successfulHosts = results.filter((result) => result.success);
        if (successfulHosts.length === 0) {
            return {
                status: 'error',
                code: 'ERR_ALL_HOSTS_REJECTED',
                description: `All ${this.networkPreset} topical hosts have rejected the transaction.`
            };
        }
        // Collect host acknowledgments
        const hostAcknowledgments = {};
        for (const result of successfulHosts) {
            const host = result.host;
            const steak = result.steak;
            const acknowledgedTopics = new Set();
            for (const [topic, instructions] of Object.entries(steak)) {
                const outputsToAdmit = instructions.outputsToAdmit;
                const coinsToRetain = instructions.coinsToRetain;
                const coinsRemoved = instructions.coinsRemoved;
                if (outputsToAdmit?.length > 0 ||
                    coinsToRetain?.length > 0 ||
                    coinsRemoved?.length > 0) {
                    acknowledgedTopics.add(topic);
                }
            }
            hostAcknowledgments[host] = acknowledgedTopics;
        }
        // Now, perform the checks
        const allHostsError = this.checkAllHostsRequirement(hostAcknowledgments);
        if (allHostsError != null)
            return allHostsError;
        const anyHostError = this.checkAnyHostRequirement(hostAcknowledgments);
        if (anyHostError != null)
            return anyHostError;
        const specificHostsError = this.checkSpecificHostsRequirement(hostAcknowledgments);
        if (specificHostsError != null)
            return specificHostsError;
        // If all checks pass, return success
        return {
            status: 'success',
            txid: tx.id('hex'),
            message: `Sent to ${successfulHosts.length} Overlay Services ${successfulHosts.length === 1 ? 'host' : 'hosts'}.`
        };
    }
    /** Resolves the (requiredTopics, require) pair for requireAcknowledgmentFromAllHostsForTopics. */
    resolveAllHostsRequirement() {
        const r = this.requireAcknowledgmentFromAllHostsForTopics;
        if (r === 'any')
            return { requiredTopics: this.topics, require: 'any' };
        if (Array.isArray(r))
            return { requiredTopics: r, require: 'all' };
        // Default 'all' or unknown: all topics, all requirement
        return { requiredTopics: this.topics, require: 'all' };
    }
    checkAllHostsRequirement(hostAcknowledgments) {
        const { requiredTopics, require } = this.resolveAllHostsRequirement();
        if (requiredTopics.length === 0)
            return null;
        if (!this.checkAcknowledgmentFromAllHosts(hostAcknowledgments, requiredTopics, require)) {
            return { status: 'error', code: 'ERR_REQUIRE_ACK_FROM_ALL_HOSTS_FAILED', description: 'Not all hosts acknowledged the required topics.' };
        }
        return null;
    }
    /** Resolves the (requiredTopics, require) pair for requireAcknowledgmentFromAnyHostForTopics. */
    resolveAnyHostRequirement() {
        const r = this.requireAcknowledgmentFromAnyHostForTopics;
        if (r === 'all')
            return { requiredTopics: this.topics, require: 'all' };
        if (r === 'any')
            return { requiredTopics: this.topics, require: 'any' };
        if (Array.isArray(r))
            return { requiredTopics: r, require: 'all' };
        return { requiredTopics: [], require: 'all' };
    }
    checkAnyHostRequirement(hostAcknowledgments) {
        const { requiredTopics, require } = this.resolveAnyHostRequirement();
        if (requiredTopics.length === 0)
            return null;
        if (!this.checkAcknowledgmentFromAnyHost(hostAcknowledgments, requiredTopics, require)) {
            return { status: 'error', code: 'ERR_REQUIRE_ACK_FROM_ANY_HOST_FAILED', description: 'No host acknowledged the required topics.' };
        }
        return null;
    }
    checkSpecificHostsRequirement(hostAcknowledgments) {
        if (Object.keys(this.requireAcknowledgmentFromSpecificHostsForTopics).length === 0)
            return null;
        if (!this.checkAcknowledgmentFromSpecificHosts(hostAcknowledgments, this.requireAcknowledgmentFromSpecificHostsForTopics)) {
            return { status: 'error', code: 'ERR_REQUIRE_ACK_FROM_SPECIFIC_HOSTS_FAILED', description: 'Specific hosts did not acknowledge the required topics.' };
        }
        return null;
    }
    /**
     * Returns true if `acknowledgedTopics` satisfies the given requirement against `requiredTopics`.
     */
    topicsMatchRequirement(acknowledgedTopics, requiredTopics, require) {
        if (require === 'all') {
            return requiredTopics.every(t => acknowledgedTopics.has(t));
        }
        return requiredTopics.some(t => acknowledgedTopics.has(t));
    }
    checkAcknowledgmentFromAllHosts(hostAcknowledgments, requiredTopics, require) {
        return Object.values(hostAcknowledgments).every(acknowledged => this.topicsMatchRequirement(acknowledged, requiredTopics, require));
    }
    checkAcknowledgmentFromAnyHost(hostAcknowledgments, requiredTopics, require) {
        return Object.values(hostAcknowledgments).some(acknowledged => this.topicsMatchRequirement(acknowledged, requiredTopics, require));
    }
    checkAcknowledgmentFromSpecificHosts(hostAcknowledgments, requirements) {
        for (const [host, requiredTopicsOrAllAny] of Object.entries(requirements)) {
            const acknowledgedTopics = hostAcknowledgments[host];
            if (acknowledgedTopics == null) {
                // Host did not respond successfully
                return false;
            }
            let requiredTopics;
            let require;
            if (requiredTopicsOrAllAny === 'all' || requiredTopicsOrAllAny === 'any') {
                require = requiredTopicsOrAllAny;
                requiredTopics = this.topics;
            }
            else if (Array.isArray(requiredTopicsOrAllAny)) {
                requiredTopics = requiredTopicsOrAllAny;
                require = 'all';
            }
            else {
                // Invalid configuration
                continue;
            }
            if (!this.topicsMatchRequirement(acknowledgedTopics, requiredTopics, require)) {
                return false;
            }
        }
        return true;
    }
    /**
     * Finds which hosts are interested in transactions tagged with the given set of topics.
     *
     * @returns A mapping of URLs for hosts interested in this transaction. Keys are URLs, values are which of our topics the specific host cares about.
     */
    async findInterestedHosts() {
        // Handle the local network preset
        if (this.networkPreset === 'local') {
            const resultSet = new Set();
            for (const topic of this.topics) {
                resultSet.add(topic);
            }
            return { 'http://localhost:8080': resultSet };
        }
        // Return cached result if still valid
        const now = Date.now();
        if (this.interestedHostsCache != null && this.interestedHostsCache.expiresAt > now) {
            return this.interestedHostsCache.hosts;
        }
        // Deduplicate concurrent requests
        if (this.interestedHostsInFlight != null) {
            return await this.interestedHostsInFlight;
        }
        this.interestedHostsInFlight = this.fetchInterestedHosts();
        try {
            const hosts = await this.interestedHostsInFlight;
            this.interestedHostsCache = { hosts, expiresAt: Date.now() + this.interestedHostsTtlMs };
            return hosts;
        }
        finally {
            this.interestedHostsInFlight = null;
        }
    }
    /**
     * Performs the actual SHIP lookup to discover interested hosts.
     * @private
     */
    async fetchInterestedHosts() {
        // Find all SHIP advertisements for the topics we care about
        const results = {};
        const answer = await this.resolver.query({
            service: 'ls_ship',
            query: {
                topics: this.topics
            }
        }, MAX_SHIP_QUERY_TIMEOUT);
        if (answer.type !== 'output-list') {
            throw new Error('SHIP answer is not an output list.');
        }
        for (const output of answer.outputs) {
            try {
                const tx = Transaction.fromBEEF(output.beef);
                const script = tx.outputs[output.outputIndex].lockingScript;
                const parsed = OverlayAdminTokenTemplate.decode(script);
                if (!this.topics.includes(parsed.topicOrService) ||
                    parsed.protocol !== 'SHIP') {
                    continue;
                }
                results[parsed.domain] ??= new Set();
                results[parsed.domain].add(parsed.topicOrService);
            }
            catch (_notShipOutput) {
                // Output could not be decoded as an overlay admin token — not a SHIP advertisement; skip
                continue;
            }
        }
        return results;
    }
}
//# sourceMappingURL=SHIPBroadcaster.js.map