import Transaction from '../transaction/Transaction.js';
import * as Utils from '../primitives/utils.js';
import { TopicBroadcaster, LookupResolver, withDoubleSpendRetry } from '../overlay-tools/index.js';
import { PushDrop } from '../script/index.js';
import WalletClient from '../wallet/WalletClient.js';
import { Beef } from '../transaction/Beef.js';
import { Historian } from '../overlay-tools/Historian.js';
import { kvStoreInterpreter } from './kvStoreInterpreter.js';
import { ProtoWallet } from '../wallet/ProtoWallet.js';
import { kvProtocol } from './types.js';
/**
 * Default configuration values for GlobalKVStore operations.
 * Provides sensible defaults for overlay connection and protocol settings.
 */
const DEFAULT_CONFIG = {
    protocolID: [1, 'kvstore'],
    serviceName: 'ls_kvstore',
    tokenAmount: 1,
    topics: ['tm_kvstore'],
    networkPreset: 'mainnet',
    acceptDelayedBroadcast: false,
    overlayBroadcast: false, // Use overlay broadcasting to prevent UTXO spending on broadcast rejection.
    tokenSetDescription: '', // Will be set dynamically
    tokenUpdateDescription: '', // Will be set dynamically
    tokenRemovalDescription: '' // Will be set dynamically
};
/**
 * Implements a global key-value storage system which uses an overlay service to track key-value pairs.
 * Each key-value pair is represented by a PushDrop token output.
 * Allows getting, setting, and removing key-value pairs with optional fetching by protocolID and history tracking.
 */
export class GlobalKVStore {
    /**
     * The wallet interface used to create transactions and perform cryptographic operations.
     * @readonly
     */
    wallet;
    /**
     * Configuration for the KVStore instance containing all runtime options.
     * @private
     * @readonly
     */
    config;
    /**
     * Historian instance used to extract history from transaction outputs.
     * @private
     */
    historian;
    /**
     * Lookup resolver used to query the overlay for transaction outputs.
     * @private
     */
    lookupResolver;
    /**
     * Topic broadcaster used to broadcast transactions to the overlay.
     * @private
     */
    topicBroadcaster;
    /**
     * A map to store locks for each key to ensure atomic updates.
     * @private
     */
    keyLocks = new Map();
    /**
     * Cached user identity key
     * @private
     */
    cachedIdentityKey = null;
    /**
     * Creates an instance of the GlobalKVStore.
     *
     * @param {KVStoreConfig} [config={}] - Configuration options for the KVStore. Defaults to empty object.
     * @param {WalletInterface} [config.wallet] - Wallet to use for operations. Defaults to WalletClient.
     * @throws {Error} If the configuration contains invalid parameters.
     */
    constructor(config = {}) {
        // Merge with defaults to create a fully resolved config
        this.config = { ...DEFAULT_CONFIG, ...config };
        this.wallet = config.wallet ?? new WalletClient();
        this.historian = new Historian(kvStoreInterpreter);
        this.lookupResolver = new LookupResolver({
            networkPreset: this.config.networkPreset
        });
        this.topicBroadcaster = new TopicBroadcaster(this.config.topics, {
            networkPreset: this.config.networkPreset
        });
    }
    /**
     * Retrieves data from the KVStore.
     * Can query by key+controller (single result), protocolID, controller, or key (multiple results).
     *
     * @param {KVStoreQuery} query - Query parameters sent to overlay
     * @param {KVStoreGetOptions} [options={}] - Configuration options for the get operation
     * @returns {Promise<KVStoreEntry | KVStoreEntry[] | undefined>} Single entry for key+controller queries, array for all other queries
     */
    async get(query, options = {}) {
        this.validateQuerySelectors(query);
        if (query.key != null && query.controller != null) {
            // Specific key+controller query - return single entry
            const entries = await this.queryOverlay(query, options);
            return entries.length > 0 ? entries[0] : undefined;
        }
        return await this.queryOverlay(query, options);
    }
    /**
     * Ensures lookup pagination and ordering options are only used with a real KV selector.
     *
     * @param {KVStoreQuery} query - Query parameters sent to overlay.
     * @throws {Error} If the query does not include a valid selector.
     */
    validateQuerySelectors(query) {
        const hasSelector = (typeof query.key === 'string' && query.key.length > 0) ||
            (typeof query.controller === 'string' && query.controller.length > 0) ||
            (Array.isArray(query.protocolID) && query.protocolID.length === 2) ||
            (Array.isArray(query.tags) && query.tags.length > 0);
        if (!hasSelector) {
            throw new Error('Must specify at least one selector: key, controller, protocolID, or tags');
        }
    }
    /**
     * Sets a key-value pair. The current user (wallet identity) becomes the controller.
     *
     * @param {string} key - The key to set (user computes this however they want)
     * @param {string} value - The value to store
     * @param {KVStoreSetOptions} [options={}] - Configuration options for the set operation
     * @returns {Promise<OutpointString>} The outpoint of the created token
     */
    async set(key, value, options = {}) {
        if (typeof key !== 'string' || key.length === 0) {
            throw new Error('Key must be a non-empty string.');
        }
        if (typeof value !== 'string') {
            throw new TypeError('Value must be a string.');
        }
        const controller = await this.getIdentityKey();
        const lockQueue = await this.queueOperationOnKey(key);
        const protocolID = options.protocolID ?? this.config.protocolID;
        const tokenSetDescription = (options.tokenSetDescription != null && options.tokenSetDescription !== '') ? options.tokenSetDescription : `Create KVStore value for ${key}`;
        const tokenUpdateDescription = (options.tokenUpdateDescription != null && options.tokenUpdateDescription !== '') ? options.tokenUpdateDescription : `Update KVStore value for ${key}`;
        const tokenAmount = options.tokenAmount ?? this.config.tokenAmount;
        const tags = options.tags ?? [];
        try {
            // Create PushDrop locking script (reusable across retries)
            const pushdrop = new PushDrop(this.wallet, this.config.originator);
            const lockingScriptFields = [
                Utils.toArray(JSON.stringify(protocolID), 'utf8'),
                Utils.toArray(key, 'utf8'),
                Utils.toArray(value, 'utf8'),
                Utils.toArray(controller, 'hex')
            ];
            // Add tags as optional 5th field for backwards compatibility
            if (tags.length > 0) {
                lockingScriptFields.push(Utils.toArray(JSON.stringify(tags), 'utf8'));
            }
            const lockingScript = await pushdrop.lock(lockingScriptFields, protocolID ?? this.config.protocolID, Utils.toUTF8(Utils.toArray(key, 'utf8')), 'anyone', true);
            // Wrap entire operation in double-spend retry, including overlay query
            const outpoint = await withDoubleSpendRetry(async () => {
                // Re-query overlay on each attempt to get fresh token state
                const existingEntries = await this.queryOverlay({ key, controller }, { includeToken: true });
                const existingToken = existingEntries.length > 0 ? existingEntries[0].token : undefined;
                if (existingToken == null) {
                    // Create new token
                    const { tx } = await this.wallet.createAction({
                        description: tokenSetDescription,
                        outputs: [{
                                satoshis: tokenAmount ?? this.config.tokenAmount,
                                lockingScript: lockingScript.toHex(),
                                outputDescription: 'KVStore token'
                            }],
                        options: {
                            acceptDelayedBroadcast: this.config.acceptDelayedBroadcast,
                            noSend: this.config.overlayBroadcast,
                            randomizeOutputs: false
                        }
                    }, this.config.originator);
                    if (tx == null) {
                        throw new Error('Failed to create transaction');
                    }
                    const transaction = Transaction.fromAtomicBEEF(tx);
                    await this.submitToOverlay(transaction);
                    return `${transaction.id('hex')}.0`;
                }
                else {
                    // Update existing token
                    const inputs = [{
                            outpoint: `${existingToken.txid}.${existingToken.outputIndex}`,
                            unlockingScriptLength: 74,
                            inputDescription: 'Previous KVStore token'
                        }];
                    const inputBEEF = existingToken.beef;
                    const { signableTransaction } = await this.wallet.createAction({
                        description: tokenUpdateDescription,
                        inputBEEF: inputBEEF.toBinary(),
                        inputs,
                        outputs: [{
                                satoshis: tokenAmount ?? this.config.tokenAmount,
                                lockingScript: lockingScript.toHex(),
                                outputDescription: 'KVStore token'
                            }],
                        options: {
                            acceptDelayedBroadcast: this.config.acceptDelayedBroadcast,
                            noSend: this.config.overlayBroadcast,
                            randomizeOutputs: false
                        }
                    }, this.config.originator);
                    if (signableTransaction == null) {
                        throw new Error('Unable to create update transaction');
                    }
                    const tx = Transaction.fromAtomicBEEF(signableTransaction.tx);
                    const unlocker = pushdrop.unlock(this.config.protocolID, key, 'anyone');
                    const unlockingScript = await unlocker.sign(tx, 0);
                    const { tx: finalTx } = await this.wallet.signAction({
                        reference: signableTransaction.reference,
                        spends: { 0: { unlockingScript: unlockingScript.toHex() } },
                        options: {
                            acceptDelayedBroadcast: this.config.acceptDelayedBroadcast,
                            noSend: this.config.overlayBroadcast
                        }
                    }, this.config.originator);
                    if (finalTx == null) {
                        throw new Error('Unable to finalize update transaction');
                    }
                    const transaction = Transaction.fromAtomicBEEF(finalTx);
                    await this.submitToOverlay(transaction);
                    return `${transaction.id('hex')}.0`;
                }
            }, this.topicBroadcaster);
            return outpoint;
        }
        finally {
            if (lockQueue.length > 0) {
                this.finishOperationOnKey(key, lockQueue);
            }
        }
    }
    /**
     * Removes the key-value pair associated with the given key from the overlay service.
     *
     * @param {string} key - The key to remove.
     * @param {CreateActionOutput[] | undefined} [outputs=undefined] - Additional outputs to include in the removal transaction.
     * @param {KVStoreRemoveOptions} [options=undefined] - Optional parameters for the removal operation.
     * @returns {Promise<HexString>} A promise that resolves to the txid of the removal transaction if successful.
     * @throws {Error} If the key is invalid.
     * @throws {Error} If the key does not exist in the store.
     * @throws {Error} If the overlay service is unreachable or the transaction fails.
     * @throws {Error} If there are existing tokens that cannot be unlocked.
     */
    async remove(key, outputs, options = {}) {
        if (typeof key !== 'string' || key.length === 0) {
            throw new Error('Key must be a non-empty string.');
        }
        const controller = await this.getIdentityKey();
        const lockQueue = await this.queueOperationOnKey(key);
        const protocolID = options.protocolID ?? this.config.protocolID;
        const tokenRemovalDescription = (options.tokenRemovalDescription != null && options.tokenRemovalDescription !== '') ? options.tokenRemovalDescription : `Remove KVStore value for ${key}`;
        try {
            const pushdrop = new PushDrop(this.wallet, this.config.originator);
            // Remove token with double-spend retry
            const txid = await withDoubleSpendRetry(async () => {
                // Re-query overlay on each attempt to get fresh token state
                const existingEntries = await this.queryOverlay({ key, controller }, { includeToken: true });
                if (existingEntries.length === 0 || existingEntries[0].token == null) {
                    throw new Error('The item did not exist, no item was deleted.');
                }
                const existingToken = existingEntries[0].token;
                const inputs = [{
                        outpoint: `${existingToken.txid}.${existingToken.outputIndex}`,
                        unlockingScriptLength: 74,
                        inputDescription: 'KVStore token to remove'
                    }];
                const { signableTransaction } = await this.wallet.createAction({
                    description: tokenRemovalDescription,
                    inputBEEF: existingToken.beef.toBinary(),
                    inputs,
                    outputs,
                    options: {
                        acceptDelayedBroadcast: this.config.acceptDelayedBroadcast,
                        randomizeOutputs: false,
                        noSend: this.config.overlayBroadcast
                    }
                }, this.config.originator);
                if (signableTransaction == null) {
                    throw new Error('Unable to create removal transaction');
                }
                const tx = Transaction.fromAtomicBEEF(signableTransaction.tx);
                const unlocker = pushdrop.unlock(protocolID ?? this.config.protocolID, key, 'anyone');
                const unlockingScript = await unlocker.sign(tx, 0);
                const { tx: finalTx } = await this.wallet.signAction({
                    reference: signableTransaction.reference,
                    spends: { 0: { unlockingScript: unlockingScript.toHex() } },
                    options: {
                        acceptDelayedBroadcast: this.config.acceptDelayedBroadcast,
                        noSend: this.config.overlayBroadcast
                    }
                }, this.config.originator);
                if (finalTx == null) {
                    throw new Error('Unable to finalize removal transaction');
                }
                const transaction = Transaction.fromAtomicBEEF(finalTx);
                await this.submitToOverlay(transaction);
                return transaction.id('hex');
            }, this.topicBroadcaster);
            return txid;
        }
        finally {
            if (lockQueue.length > 0) {
                this.finishOperationOnKey(key, lockQueue);
            }
        }
    }
    /**
     * Queues an operation on a specific key to ensure atomic updates.
     * Prevents concurrent operations on the same key from interfering with each other.
     *
     * @param {string} key - The key to queue an operation for.
     * @returns {Promise<Array<(value: void | PromiseLike<void>) => void>>} The lock queue for cleanup.
     * @private
     */
    async queueOperationOnKey(key) {
        // Check if a lock exists for this key and wait for it to resolve
        let lockQueue = this.keyLocks.get(key);
        if (lockQueue == null) {
            lockQueue = [];
            this.keyLocks.set(key, lockQueue);
        }
        let resolveNewLock = () => { };
        const newLock = new Promise((resolve) => {
            resolveNewLock = resolve;
            if (lockQueue != null) {
                lockQueue.push(resolve);
            }
        });
        // If we are the only request, resolve the lock immediately, queue remains at 1 item until request ends.
        if (lockQueue.length === 1) {
            resolveNewLock();
        }
        await newLock;
        return lockQueue;
    }
    /**
     * Finishes an operation on a key and resolves the next waiting operation.
     *
     * @param {string} key - The key to finish the operation for.
     * @param {Array<(value: void | PromiseLike<void>) => void>} lockQueue - The lock queue from queueOperationOnKey.
     * @private
     */
    finishOperationOnKey(key, lockQueue) {
        lockQueue.shift(); // Remove the current lock from the queue
        if (lockQueue.length > 0) {
            // If there are more locks waiting, resolve the next one
            lockQueue[0]();
        }
        else {
            // Clean up empty queue to prevent memory leak
            this.keyLocks.delete(key);
        }
    }
    /**
     * Helper function to fetch and cache user identity key
     *
     * @returns {Promise<PubKeyHex>} The identity key of the current user
     * @private
     */
    async getIdentityKey() {
        this.cachedIdentityKey ??= (await this.wallet.getPublicKey({ identityKey: true }, this.config.originator)).publicKey;
        return this.cachedIdentityKey;
    }
    /**
     * Queries the overlay service for KV entries.
     *
     * @param {KVStoreQuery} query - Query parameters sent to overlay
     * @param {KVStoreGetOptions} options - Configuration options for the query
     * @returns {Promise<KVStoreEntry[]>} Array of matching KV entries
     * @private
     */
    async queryOverlay(query, options = {}) {
        const answer = await this.lookupResolver.query({
            service: options.serviceName ?? this.config.serviceName,
            query
        });
        if (answer.type !== 'output-list' || answer.outputs.length === 0) {
            return [];
        }
        const entries = [];
        for (const result of answer.outputs) {
            try {
                const tx = Transaction.fromBEEF(result.beef);
                const output = tx.outputs[result.outputIndex];
                const decoded = PushDrop.decode(output.lockingScript);
                // Support backwards compatibility: old format without tags, new format with tags
                const expectedFieldCount = Object.keys(kvProtocol).length;
                const hasTagsField = decoded.fields.length === expectedFieldCount;
                const isOldFormat = decoded.fields.length === expectedFieldCount - 1;
                if (!isOldFormat && !hasTagsField) {
                    continue;
                }
                // Verify signature
                const anyoneWallet = new ProtoWallet('anyone');
                const signature = decoded.fields.pop();
                try {
                    await anyoneWallet.verifySignature({
                        data: decoded.fields.flat(),
                        signature,
                        counterparty: Utils.toHex(decoded.fields[kvProtocol.controller]),
                        protocolID: JSON.parse(Utils.toUTF8(decoded.fields[kvProtocol.protocolID])),
                        keyID: Utils.toUTF8(decoded.fields[kvProtocol.key])
                    });
                }
                catch (_signatureVerificationError) {
                    // Skip all outputs that fail signature verification
                    continue;
                }
                // Extract tags if present (backwards compatible)
                let tags;
                if (hasTagsField && decoded.fields[kvProtocol.tags] != null) {
                    try {
                        tags = JSON.parse(Utils.toUTF8(decoded.fields[kvProtocol.tags]));
                    }
                    catch (_tagsParseError) {
                        // If tags parsing fails, continue without tags
                        tags = undefined;
                    }
                }
                const entry = {
                    key: Utils.toUTF8(decoded.fields[kvProtocol.key]),
                    value: Utils.toUTF8(decoded.fields[kvProtocol.value]),
                    controller: Utils.toHex(decoded.fields[kvProtocol.controller]),
                    protocolID: JSON.parse(Utils.toUTF8(decoded.fields[kvProtocol.protocolID])),
                    tags
                };
                if (options.includeToken === true) {
                    entry.token = {
                        txid: tx.id('hex'),
                        outputIndex: result.outputIndex,
                        beef: Beef.fromBinary(result.beef),
                        satoshis: output.satoshis ?? 1
                    };
                }
                if (options.history === true) {
                    entry.history = await this.historian.buildHistory(tx, {
                        key: entry.key,
                        protocolID: entry.protocolID
                    });
                }
                entries.push(entry);
            }
            catch (_malformedOutputError) {
                // Skip malformed or undecodable outputs rather than failing the entire query
                continue;
            }
        }
        return entries;
    }
    /**
     * Submits a transaction to an overlay service using TopicBroadcaster.
     * Broadcasts the transaction to the configured topics for network propagation.
     *
     * @param {Transaction} transaction - The transaction to broadcast.
     * @returns {Promise<BroadcastResponse | BroadcastFailure>} The broadcast result.
     * @throws {Error} If the broadcast fails or the network is unreachable.
     * @private
     */
    async submitToOverlay(transaction) {
        return await this.topicBroadcaster.broadcast(transaction);
    }
}
export default GlobalKVStore;
//# sourceMappingURL=GlobalKVStore.js.map