import PushDrop from '../script/templates/PushDrop.js';
import * as Utils from '../primitives/utils.js';
import WalletClient from '../wallet/WalletClient.js';
import Transaction from '../transaction/Transaction.js';
import { Beef } from '../transaction/Beef.js';
/**
 * Implements a key-value storage system backed by transaction outputs managed by a wallet.
 * Each key-value pair is represented by a PushDrop token output in a specific context (basket).
 * Allows setting, getting, and removing key-value pairs, with optional encryption.
 */
export default class LocalKVStore {
    /**
     * The wallet interface used to manage outputs and perform cryptographic operations.
     * @private
     * @readonly
     */
    wallet;
    /**
     * The context (basket name) used to namespace the key-value pairs within the wallet.
     * @private
     * @readonly
     */
    context;
    /**
     * Flag indicating whether values should be encrypted before storing.
     * @private
     * @readonly
     */
    encrypt;
    /**
     * An originator to use with PushDrop and the wallet.
     * @private
     * @readonly
     */
    originator;
    acceptDelayedBroadcast = false;
    /**
     * A map to store locks for each key to ensure atomic updates.
     * @private
     */
    keyLocks = new Map();
    /**
     * Creates an instance of the localKVStore.
     *
     * @param {WalletInterface} [wallet=new WalletClient()] - The wallet interface to use. Defaults to a new WalletClient instance.
     * @param {string} [context='kvstoredefault'] - The context (basket) for namespacing keys. Defaults to 'kvstore default'.
     * @param {boolean} [encrypt=true] - Whether to encrypt values. Defaults to true.
     * @param {string} [originator] — An originator to use with PushDrop and the wallet, if provided.
     * @throws {Error} If the context is missing or empty.
     */
    constructor(wallet = new WalletClient(), context = 'kvstore default', encrypt = true, originator, acceptDelayedBroadcast = false) {
        if (typeof context !== 'string' || context.length < 1) {
            throw new Error('A context in which to operate is required.');
        }
        this.wallet = wallet;
        this.context = context;
        this.encrypt = encrypt;
        this.originator = originator;
        this.acceptDelayedBroadcast = acceptDelayedBroadcast;
    }
    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;
    }
    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]();
        }
    }
    getProtocol(key) {
        return { protocolID: [2, this.context], keyID: key };
    }
    async getOutputs(key, limit) {
        const results = await this.wallet.listOutputs({
            basket: this.context,
            tags: [key],
            tagQueryMode: 'all',
            include: 'entire transactions',
            limit
        }, this.originator);
        return results;
    }
    /**
     * Retrieves the value associated with a given key.
     *
     * @param {string} key - The key to retrieve the value for.
     * @param {string | undefined} [defaultValue=undefined] - The value to return if the key is not found.
     * @returns {Promise<string | undefined>} A promise that resolves to the value as a string,
     *   the defaultValue if the key is not found, or undefined if no defaultValue is provided.
     * @throws {Error} If too many outputs are found for the key (ambiguous state).
     * @throws {Error} If the found output's locking script cannot be decoded or represents an invalid token format.
     */
    async get(key, defaultValue = undefined) {
        const lockQueue = await this.queueOperationOnKey(key);
        try {
            const r = await this.lookupValue(key, defaultValue, 5);
            return r.value;
        }
        finally {
            this.finishOperationOnKey(key, lockQueue);
        }
    }
    getLockingScript(output, beef) {
        const [txid, vout] = output.outpoint.split('.');
        const tx = beef.findTxid(txid)?.tx;
        if (tx == null) {
            throw new Error(`beef must contain txid ${txid}`);
        }
        const lockingScript = tx.outputs[Number(vout)].lockingScript;
        return lockingScript;
    }
    async lookupValue(key, defaultValue, limit) {
        const lor = await this.getOutputs(key, limit);
        const r = { value: defaultValue, outpoint: undefined, lor };
        const { outputs } = lor;
        if (outputs.length === 0) {
            return r;
        }
        const output = outputs.at(-1);
        r.outpoint = output.outpoint;
        let field;
        try {
            if (lor.BEEF === undefined) {
                throw new Error('entire transactions listOutputs option must return valid BEEF');
            }
            const lockingScript = this.getLockingScript(output, Beef.fromBinary(lor.BEEF));
            const decoded = PushDrop.decode(lockingScript);
            if (decoded.fields.length < 1 || decoded.fields.length > 2) {
                throw new Error('Invalid token.');
            }
            field = decoded.fields[0];
        }
        catch (error) {
            throw new Error(`Invalid value found. You need to call set to collapse the corrupted state (or relinquish the corrupted ${outputs[0].outpoint} output from the ${this.context} basket) before you can get this value again. Original error: ${error instanceof Error ? error.message : String(error)}`);
        }
        if (this.encrypt) {
            const { plaintext } = await this.wallet.decrypt({
                ...this.getProtocol(key),
                ciphertext: field
            }, this.originator);
            r.value = Utils.toUTF8(plaintext);
        }
        else {
            r.value = Utils.toUTF8(field);
        }
        return r;
    }
    getInputs(outputs) {
        const inputs = [];
        for (const output of outputs) {
            inputs.push({
                outpoint: output.outpoint,
                unlockingScriptLength: 74,
                inputDescription: 'Previous key-value token'
            });
        }
        return inputs;
    }
    async getSpends(key, outputs, pushdrop, atomicBEEF) {
        const p = this.getProtocol(key);
        const tx = Transaction.fromAtomicBEEF(atomicBEEF);
        const spends = {};
        for (const [i] of outputs.entries()) {
            const unlocker = pushdrop.unlock(p.protocolID, p.keyID, 'self');
            const unlockingScript = await unlocker.sign(tx, i);
            spends[i] = {
                unlockingScript: unlockingScript.toHex()
            };
        }
        return spends;
    }
    /**
     * Sets or updates the value associated with a given key atomically.
     * If the key already exists (one or more outputs found), it spends the existing output(s)
     * and creates a new one with the updated value. If multiple outputs exist for the key,
     * they are collapsed into a single new output.
     * If the key does not exist, it creates a new output.
     * Handles encryption if enabled.
     * If signing the update/collapse transaction fails, it relinquishes the original outputs and starts over with a new chain.
     * Ensures atomicity by locking the key during the operation, preventing concurrent updates
     * to the same key from missing earlier changes.
     *
     * @param {string} key - The key to set or update.
     * @param {string} value - The value to associate with the key.
     * @returns {Promise<OutpointString>} A promise that resolves to the outpoint string (txid.vout) of the new or updated token output.
     */
    async set(key, value) {
        const lockQueue = await this.queueOperationOnKey(key);
        try {
            const current = await this.lookupValue(key, undefined, 10);
            if (current.value === value) {
                if (current.outpoint === undefined) {
                    throw new Error('outpoint must be valid when value is valid and unchanged');
                }
                // Don't create a new transaction if the value doesn't need to change
                return current.outpoint;
            }
            const protocol = this.getProtocol(key);
            let valueAsArray = Utils.toArray(value, 'utf8');
            if (this.encrypt) {
                const { ciphertext } = await this.wallet.encrypt({
                    ...protocol,
                    plaintext: valueAsArray
                }, this.originator);
                valueAsArray = ciphertext;
            }
            const pushdrop = new PushDrop(this.wallet, this.originator);
            const lockingScript = await pushdrop.lock([valueAsArray], protocol.protocolID, protocol.keyID, 'self');
            const { outputs, BEEF: inputBEEF } = current.lor;
            let outpoint;
            try {
                const inputs = this.getInputs(outputs);
                const { txid, signableTransaction } = await this.wallet.createAction({
                    description: `Update ${key} in ${this.context}`,
                    inputBEEF,
                    inputs,
                    outputs: [{
                            basket: this.context,
                            tags: [key],
                            lockingScript: lockingScript.toHex(),
                            satoshis: 1,
                            outputDescription: 'Key-value token'
                        }],
                    options: {
                        acceptDelayedBroadcast: this.acceptDelayedBroadcast,
                        randomizeOutputs: false
                    }
                }, this.originator);
                if (outputs.length > 0 && typeof signableTransaction !== 'object') {
                    throw new Error('Wallet did not return a signable transaction when expected.');
                }
                if (signableTransaction == null) {
                    outpoint = `${txid}.0`;
                }
                else {
                    const spends = await this.getSpends(key, outputs, pushdrop, signableTransaction.tx);
                    const { txid } = await this.wallet.signAction({
                        reference: signableTransaction.reference,
                        spends
                    }, this.originator);
                    outpoint = `${txid}.0`;
                }
            }
            catch (error) {
                throw new Error(`There are ${outputs.length} outputs with tag ${key} that cannot be unlocked. Original error: ${error instanceof Error ? error.message : String(error)}`);
            }
            return outpoint;
        }
        finally {
            this.finishOperationOnKey(key, lockQueue);
        }
    }
    /**
     * Removes the key-value pair associated with the given key.
     * It finds the existing output(s) for the key and spends them without creating a new output.
     * If multiple outputs exist, they are all spent in the same transaction.
     * If the key does not exist, it does nothing.
     * If signing the removal transaction fails, it relinquishes the original outputs instead of spending.
     *
     * @param {string} key - The key to remove.
     * @returns {Promise<string[]>} A promise that resolves to the txids of the removal transactions if successful.
     */
    async remove(key) {
        const lockQueue = await this.queueOperationOnKey(key);
        try {
            const txids = [];
            for (;;) {
                const { outputs, BEEF: inputBEEF, totalOutputs } = await this.getOutputs(key);
                if (outputs.length > 0) {
                    const pushdrop = new PushDrop(this.wallet, this.originator);
                    try {
                        const inputs = this.getInputs(outputs);
                        const { signableTransaction } = await this.wallet.createAction({
                            description: `Remove ${key} in ${this.context}`,
                            inputBEEF,
                            inputs,
                            options: {
                                acceptDelayedBroadcast: this.acceptDelayedBroadcast
                            }
                        }, this.originator);
                        if (typeof signableTransaction !== 'object') {
                            throw new TypeError('Wallet did not return a signable transaction when expected.');
                        }
                        const spends = await this.getSpends(key, outputs, pushdrop, signableTransaction.tx);
                        const { txid } = await this.wallet.signAction({
                            reference: signableTransaction.reference,
                            spends
                        }, this.originator);
                        if (txid === undefined) {
                            throw new Error('signAction must return a valid txid');
                        }
                        txids.push(txid);
                    }
                    catch (error) {
                        throw new Error(`There are ${totalOutputs} outputs with tag ${key} that cannot be unlocked. Original error: ${error instanceof Error ? error.message : String(error)}`);
                    }
                }
                if (outputs.length === totalOutputs) {
                    break;
                }
            }
            return txids;
        }
        finally {
            this.finishOperationOnKey(key, lockQueue);
        }
    }
}
//# sourceMappingURL=LocalKVStore.js.map