"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ContactsManager = void 0;
const index_js_1 = require("../wallet/index.js");
const index_js_2 = require("../primitives/index.js");
const index_js_3 = require("../script/index.js");
const index_js_4 = require("../transaction/index.js");
const CONTACT_PROTOCOL_ID = [2, 'contact'];
// In-memory cache for cross-platform compatibility
class MemoryCache {
    constructor() {
        this.cache = new Map();
    }
    getItem(key) {
        return this.cache.get(key) ?? null;
    }
    setItem(key, value) {
        this.cache.set(key, value);
    }
    removeItem(key) {
        this.cache.delete(key);
    }
    clear() {
        this.cache.clear();
    }
}
class ContactsManager {
    constructor(wallet, originator) {
        this.cache = new MemoryCache();
        this.CONTACTS_CACHE_KEY = 'metanet-contacts';
        this.wallet = wallet ?? new index_js_1.WalletClient();
        this.originator = originator;
    }
    /**
     * Load all records from the contacts basket
     * @param identityKey Optional specific identity key to fetch
     * @param forceRefresh Whether to force a check for new contact data
     * @param limit Maximum number of contacts to return
     * @returns A promise that resolves with an array of contacts
     */
    async getContacts(identityKey, forceRefresh = false, limit = 1000) {
        if (!forceRefresh) {
            const fromCache = this.loadCachedContacts(identityKey);
            if (fromCache !== null)
                return fromCache;
        }
        const tags = await this.buildIdentityKeyTags(identityKey);
        const outputs = await this.wallet.listOutputs({ basket: 'contacts', include: 'locking scripts', includeCustomInstructions: true, tags, limit }, this.originator);
        if (outputs.outputs == null || outputs.outputs.length === 0) {
            this.cache.setItem(this.CONTACTS_CACHE_KEY, JSON.stringify([]));
            return [];
        }
        const contacts = await this.decryptContactOutputs(outputs.outputs);
        this.cache.setItem(this.CONTACTS_CACHE_KEY, JSON.stringify(contacts));
        return identityKey != null ? contacts.filter(c => c.identityKey === identityKey) : contacts;
    }
    /** Returns cached contacts (optionally filtered) or null if cache is missing/invalid. */
    loadCachedContacts(identityKey) {
        const cached = this.cache.getItem(this.CONTACTS_CACHE_KEY);
        if (cached == null || cached === '')
            return null;
        try {
            const cachedContacts = JSON.parse(cached);
            return identityKey != null ? cachedContacts.filter(c => c.identityKey === identityKey) : cachedContacts;
        }
        catch (e) {
            console.warn('Invalid cached contacts JSON; will reload from chain', e);
            return null;
        }
    }
    /** Builds the HMAC-based identity-key tag array; empty array if no identity key is given. */
    async buildIdentityKeyTags(identityKey) {
        if (identityKey == null)
            return [];
        const { hmac: hashedIdentityKey } = await this.wallet.createHmac({
            protocolID: CONTACT_PROTOCOL_ID,
            keyID: identityKey,
            counterparty: 'self',
            data: index_js_2.Utils.toArray(identityKey, 'utf8')
        }, this.originator);
        return [`identityKey ${index_js_2.Utils.toHex(hashedIdentityKey)}`];
    }
    /** Decodes and decrypts all contact outputs in parallel, returning valid Contact objects. */
    async decryptContactOutputs(rawOutputs) {
        const decryptTasks = [];
        for (const output of rawOutputs) {
            try {
                if (output.lockingScript == null || output.customInstructions == null)
                    continue;
                const decoded = index_js_3.PushDrop.decode(index_js_3.LockingScript.fromHex(output.lockingScript));
                const keyID = JSON.parse(output.customInstructions).keyID;
                decryptTasks.push({ keyID, ciphertext: decoded.fields[0] });
            }
            catch (error) {
                console.warn('ContactsManager: Failed to decode contact output:', error);
            }
        }
        const decryptResults = await Promise.allSettled(decryptTasks.map(async (task) => await this.wallet.decrypt({ ciphertext: task.ciphertext, protocolID: CONTACT_PROTOCOL_ID, keyID: task.keyID, counterparty: 'self' }, this.originator)));
        const contacts = [];
        for (const result of decryptResults) {
            if (result.status === 'fulfilled') {
                try {
                    contacts.push(JSON.parse(index_js_2.Utils.toUTF8(result.value.plaintext)));
                }
                catch (error) {
                    console.warn('ContactsManager: Failed to parse contact data:', error);
                }
            }
            else {
                console.warn('ContactsManager: Failed to decrypt contact output:', result.reason);
            }
        }
        return contacts;
    }
    /**
     * Save or update a Metanet contact
     * @param contact The displayable identity information for the contact
     * @param metadata Optional metadata to store with the contact (ex. notes, aliases, etc)
     */
    async saveContact(contact, metadata) {
        const cached = this.cache.getItem(this.CONTACTS_CACHE_KEY);
        const contacts = (cached != null && cached !== '') ? JSON.parse(cached) : await this.getContacts();
        const contactToStore = { ...contact, metadata };
        const existingIndex = contacts.findIndex(c => c.identityKey === contact.identityKey);
        if (existingIndex >= 0)
            contacts[existingIndex] = contactToStore;
        else
            contacts.push(contactToStore);
        const hashedIdentityKey = await this.hashIdentityKey(contact.identityKey);
        const outputs = await this.wallet.listOutputs({
            basket: 'contacts', include: 'entire transactions', includeCustomInstructions: true,
            tags: [`identityKey ${index_js_2.Utils.toHex(hashedIdentityKey)}`], limit: 100
        }, this.originator);
        const { existingOutput, keyID } = await this.findExistingOutput(outputs, contact.identityKey);
        const lockingScript = await this.encryptAndLock(contactToStore, keyID);
        if (existingOutput != null) {
            await this.updateContactOutput(outputs, existingOutput, lockingScript, keyID, hashedIdentityKey, contact);
        }
        else {
            await this.createContactOutput(lockingScript, keyID, hashedIdentityKey, contact);
        }
        this.cache.setItem(this.CONTACTS_CACHE_KEY, JSON.stringify(contacts));
    }
    /** Computes the HMAC-based hash of an identity key for tag indexing. */
    async hashIdentityKey(identityKey) {
        const { hmac } = await this.wallet.createHmac({
            protocolID: CONTACT_PROTOCOL_ID, keyID: identityKey, counterparty: 'self',
            data: index_js_2.Utils.toArray(identityKey, 'utf8')
        }, this.originator);
        return hmac;
    }
    /** Scans existing outputs to find the one matching the given identity key; returns output + keyID. */
    async findExistingOutput(outputs, identityKey) {
        let existingOutput = null;
        let keyID = index_js_2.Utils.toBase64((0, index_js_2.Random)(32));
        if (outputs.outputs == null)
            return { existingOutput, keyID };
        for (const output of outputs.outputs) {
            try {
                const [txid, outputIndex] = output.outpoint.split('.');
                const tx = index_js_4.Transaction.fromBEEF(outputs.BEEF, txid);
                const decoded = index_js_3.PushDrop.decode(tx.outputs[Number(outputIndex)].lockingScript);
                if (output.customInstructions == null)
                    continue;
                keyID = JSON.parse(output.customInstructions).keyID;
                const { plaintext } = await this.wallet.decrypt({ ciphertext: decoded.fields[0], protocolID: CONTACT_PROTOCOL_ID, keyID, counterparty: 'self' }, this.originator);
                const storedContact = JSON.parse(index_js_2.Utils.toUTF8(plaintext));
                if (storedContact.identityKey === identityKey) {
                    existingOutput = output;
                    break;
                }
            }
            catch (_malformedOrUndecryptableOutput) { /* skip */ }
        }
        return { existingOutput, keyID };
    }
    /** Encrypts a contact and produces its PushDrop locking script. */
    async encryptAndLock(contactData, keyID) {
        const { ciphertext } = await this.wallet.encrypt({
            plaintext: index_js_2.Utils.toArray(JSON.stringify(contactData), 'utf8'),
            protocolID: CONTACT_PROTOCOL_ID, keyID, counterparty: 'self'
        }, this.originator);
        return await new index_js_3.PushDrop(this.wallet, this.originator).lock([ciphertext], CONTACT_PROTOCOL_ID, keyID, 'self');
    }
    /** Spends an existing contact output and creates a replacement with updated data. */
    async updateContactOutput(outputs, existingOutput, lockingScript, keyID, hashedIdentityKey, contact) {
        const [txid, outputIndex] = String(existingOutput.outpoint).split('.');
        const prevOutpoint = `${txid}.${outputIndex}`;
        const pushdrop = new index_js_3.PushDrop(this.wallet, this.originator);
        const { signableTransaction } = await this.wallet.createAction({
            description: 'Update Contact',
            inputBEEF: outputs.BEEF,
            inputs: [{ outpoint: prevOutpoint, unlockingScriptLength: 74, inputDescription: 'Spend previous contact output' }],
            outputs: [{
                    basket: 'contacts', satoshis: 1, lockingScript: lockingScript.toHex(),
                    outputDescription: `Updated Contact: ${contact.name ?? contact.identityKey.slice(0, 10)}`,
                    tags: [`identityKey ${index_js_2.Utils.toHex(hashedIdentityKey)}`], customInstructions: JSON.stringify({ keyID })
                }],
            options: { acceptDelayedBroadcast: false, randomizeOutputs: false }
        }, this.originator);
        if (signableTransaction == null)
            throw new Error('Unable to update contact');
        const unlockingScript = await pushdrop.unlock(CONTACT_PROTOCOL_ID, keyID, 'self')
            .sign(index_js_4.Transaction.fromBEEF(signableTransaction.tx), 0);
        const { tx } = await this.wallet.signAction({
            reference: signableTransaction.reference,
            spends: { 0: { unlockingScript: unlockingScript.toHex() } }
        }, this.originator);
        if (tx == null)
            throw new Error('Failed to update contact output');
    }
    /** Creates a new on-chain contact output. */
    async createContactOutput(lockingScript, keyID, hashedIdentityKey, contact) {
        const { tx } = await this.wallet.createAction({
            description: 'Add Contact',
            outputs: [{
                    basket: 'contacts', satoshis: 1, lockingScript: lockingScript.toHex(),
                    outputDescription: `Contact: ${contact.name ?? contact.identityKey.slice(0, 10)}`,
                    tags: [`identityKey ${index_js_2.Utils.toHex(hashedIdentityKey)}`], customInstructions: JSON.stringify({ keyID })
                }],
            options: { acceptDelayedBroadcast: false, randomizeOutputs: false }
        }, this.originator);
        if (tx == null)
            throw new Error('Failed to create contact output');
    }
    /**
     * Remove a contact from the contacts basket
     * @param identityKey The identity key of the contact to remove
     */
    async removeContact(identityKey) {
        // Update in-memory cache
        const cached = this.cache.getItem(this.CONTACTS_CACHE_KEY);
        if (cached != null && cached !== '') {
            try {
                const contacts = JSON.parse(cached);
                this.cache.setItem(this.CONTACTS_CACHE_KEY, JSON.stringify(contacts.filter(c => c.identityKey !== identityKey)));
            }
            catch (e) {
                console.warn('Failed to update cache after contact removal:', e);
            }
        }
        const tags = await this.buildIdentityKeyTags(identityKey);
        const outputs = await this.wallet.listOutputs({ basket: 'contacts', include: 'entire transactions', includeCustomInstructions: true, tags, limit: 100 }, this.originator);
        if (outputs.outputs == null)
            return;
        for (const output of outputs.outputs) {
            try {
                const spent = await this.trySpendContactOutput(output, outputs, identityKey);
                if (spent)
                    return;
            }
            catch (_malformedOrUndecryptableOutput) { /* skip */ }
        }
    }
    /** Attempts to decrypt and spend a single output if it matches the given identity key. Returns true if spent. */
    async trySpendContactOutput(output, outputs, identityKey) {
        const [txid, outputIndex] = String(output.outpoint).split('.');
        const tx = index_js_4.Transaction.fromBEEF(outputs.BEEF, txid);
        const decoded = index_js_3.PushDrop.decode(tx.outputs[Number(outputIndex)].lockingScript);
        if (output.customInstructions == null)
            return false;
        const keyID = JSON.parse(output.customInstructions).keyID;
        const { plaintext } = await this.wallet.decrypt({ ciphertext: decoded.fields[0], protocolID: CONTACT_PROTOCOL_ID, keyID, counterparty: 'self' }, this.originator);
        const storedContact = JSON.parse(index_js_2.Utils.toUTF8(plaintext));
        if (storedContact.identityKey !== identityKey)
            return false;
        const prevOutpoint = `${txid}.${outputIndex}`;
        const pushdrop = new index_js_3.PushDrop(this.wallet, this.originator);
        const { signableTransaction } = await this.wallet.createAction({
            description: 'Delete Contact',
            inputBEEF: outputs.BEEF,
            inputs: [{ outpoint: prevOutpoint, unlockingScriptLength: 74, inputDescription: 'Spend contact output to delete' }],
            outputs: [],
            options: { acceptDelayedBroadcast: false, randomizeOutputs: false }
        }, this.originator);
        if (signableTransaction == null)
            throw new Error('Unable to delete contact');
        const unlockingScript = await pushdrop.unlock(CONTACT_PROTOCOL_ID, keyID, 'self')
            .sign(index_js_4.Transaction.fromBEEF(signableTransaction.tx), 0);
        const { tx: deleteTx } = await this.wallet.signAction({
            reference: signableTransaction.reference,
            spends: { 0: { unlockingScript: unlockingScript.toHex() } }
        }, this.originator);
        if (deleteTx == null)
            throw new Error('Failed to delete contact output');
        return true;
    }
}
exports.ContactsManager = ContactsManager;
//# sourceMappingURL=ContactsManager.js.map