src/transaction/script-builder.js

/**
 * @fileoverview Bitcoin Script Builder
 * @description Build and parse Bitcoin scripts for all address types
 * @version 1.0.0
 * @author yfbsei
 * @license ISC
 */

import { createHash } from 'node:crypto';
import rmd160 from '../core/crypto/hash/ripemd160.js';

/**
 * Bitcoin opcodes
 * @constant {Object}
 */
const OPCODES = {
    // Push operations
    OP_0: 0x00,
    OP_FALSE: 0x00,
    OP_PUSHDATA1: 0x4c,
    OP_PUSHDATA2: 0x4d,
    OP_PUSHDATA4: 0x4e,
    OP_1NEGATE: 0x4f,
    OP_RESERVED: 0x50,
    OP_1: 0x51,
    OP_TRUE: 0x51,
    OP_2: 0x52,
    OP_3: 0x53,
    OP_4: 0x54,
    OP_5: 0x55,
    OP_6: 0x56,
    OP_7: 0x57,
    OP_8: 0x58,
    OP_9: 0x59,
    OP_10: 0x5a,
    OP_11: 0x5b,
    OP_12: 0x5c,
    OP_13: 0x5d,
    OP_14: 0x5e,
    OP_15: 0x5f,
    OP_16: 0x60,

    // Flow control
    OP_NOP: 0x61,
    OP_VER: 0x62,
    OP_IF: 0x63,
    OP_NOTIF: 0x64,
    OP_VERIF: 0x65,
    OP_VERNOTIF: 0x66,
    OP_ELSE: 0x67,
    OP_ENDIF: 0x68,
    OP_VERIFY: 0x69,
    OP_RETURN: 0x6a,

    // Stack operations
    OP_TOALTSTACK: 0x6b,
    OP_FROMALTSTACK: 0x6c,
    OP_2DROP: 0x6d,
    OP_2DUP: 0x6e,
    OP_3DUP: 0x6f,
    OP_2OVER: 0x70,
    OP_2ROT: 0x71,
    OP_2SWAP: 0x72,
    OP_IFDUP: 0x73,
    OP_DEPTH: 0x74,
    OP_DROP: 0x75,
    OP_DUP: 0x76,
    OP_NIP: 0x77,
    OP_OVER: 0x78,
    OP_PICK: 0x79,
    OP_ROLL: 0x7a,
    OP_ROT: 0x7b,
    OP_SWAP: 0x7c,
    OP_TUCK: 0x7d,

    // Splice operations
    OP_CAT: 0x7e,
    OP_SUBSTR: 0x7f,
    OP_LEFT: 0x80,
    OP_RIGHT: 0x81,
    OP_SIZE: 0x82,

    // Bitwise logic
    OP_INVERT: 0x83,
    OP_AND: 0x84,
    OP_OR: 0x85,
    OP_XOR: 0x86,
    OP_EQUAL: 0x87,
    OP_EQUALVERIFY: 0x88,

    // Arithmetic
    OP_1ADD: 0x8b,
    OP_1SUB: 0x8c,
    OP_2MUL: 0x8d,
    OP_2DIV: 0x8e,
    OP_NEGATE: 0x8f,
    OP_ABS: 0x90,
    OP_NOT: 0x91,
    OP_0NOTEQUAL: 0x92,
    OP_ADD: 0x93,
    OP_SUB: 0x94,
    OP_MUL: 0x95,
    OP_DIV: 0x96,
    OP_MOD: 0x97,
    OP_LSHIFT: 0x98,
    OP_RSHIFT: 0x99,
    OP_BOOLAND: 0x9a,
    OP_BOOLOR: 0x9b,
    OP_NUMEQUAL: 0x9c,
    OP_NUMEQUALVERIFY: 0x9d,
    OP_NUMNOTEQUAL: 0x9e,
    OP_LESSTHAN: 0x9f,
    OP_GREATERTHAN: 0xa0,
    OP_LESSTHANOREQUAL: 0xa1,
    OP_GREATERTHANOREQUAL: 0xa2,
    OP_MIN: 0xa3,
    OP_MAX: 0xa4,
    OP_WITHIN: 0xa5,

    // Crypto
    OP_RIPEMD160: 0xa6,
    OP_SHA1: 0xa7,
    OP_SHA256: 0xa8,
    OP_HASH160: 0xa9,
    OP_HASH256: 0xaa,
    OP_CODESEPARATOR: 0xab,
    OP_CHECKSIG: 0xac,
    OP_CHECKSIGVERIFY: 0xad,
    OP_CHECKMULTISIG: 0xae,
    OP_CHECKMULTISIGVERIFY: 0xaf,

    // Expansion
    OP_NOP1: 0xb0,
    OP_CHECKLOCKTIMEVERIFY: 0xb1,
    OP_CLTV: 0xb1,
    OP_CHECKSEQUENCEVERIFY: 0xb2,
    OP_CSV: 0xb2,
    OP_NOP4: 0xb3,
    OP_NOP5: 0xb4,
    OP_NOP6: 0xb5,
    OP_NOP7: 0xb6,
    OP_NOP8: 0xb7,
    OP_NOP9: 0xb8,
    OP_NOP10: 0xb9,

    // Taproot
    OP_CHECKSIGADD: 0xba
};

// Reverse mapping for disassembly
const OPCODE_NAMES = Object.entries(OPCODES).reduce((acc, [name, code]) => {
    if (!acc[code]) acc[code] = name;
    return acc;
}, {});

/**
 * Custom error class for script operations
 * @class ScriptError
 * @extends Error
 */
class ScriptError extends Error {
    constructor(message, code, details = {}) {
        super(message);
        this.name = 'ScriptError';
        this.code = code;
        this.details = details;
    }
}

/**
 * Hash160 (SHA256 + RIPEMD160)
 * @param {Buffer} data - Data to hash
 * @returns {Buffer} 20-byte hash
 */
function hash160(data) {
    const sha = createHash('sha256').update(data).digest();
    return rmd160(sha);
}

/**
 * Encode data as a push operation
 * @param {Buffer} data - Data to push
 * @returns {Buffer} Push operation bytes
 */
function encodePush(data) {
    if (!Buffer.isBuffer(data)) {
        data = Buffer.from(data, 'hex');
    }

    const len = data.length;

    if (len === 0) {
        return Buffer.from([OPCODES.OP_0]);
    }

    if (len === 1 && data[0] >= 1 && data[0] <= 16) {
        return Buffer.from([OPCODES.OP_1 + data[0] - 1]);
    }

    if (len === 1 && data[0] === 0x81) {
        return Buffer.from([OPCODES.OP_1NEGATE]);
    }

    if (len < 0x4c) {
        return Buffer.concat([Buffer.from([len]), data]);
    }

    if (len <= 0xff) {
        return Buffer.concat([Buffer.from([OPCODES.OP_PUSHDATA1, len]), data]);
    }

    if (len <= 0xffff) {
        const lenBuf = Buffer.alloc(2);
        lenBuf.writeUInt16LE(len, 0);
        return Buffer.concat([Buffer.from([OPCODES.OP_PUSHDATA2]), lenBuf, data]);
    }

    const lenBuf = Buffer.alloc(4);
    lenBuf.writeUInt32LE(len, 0);
    return Buffer.concat([Buffer.from([OPCODES.OP_PUSHDATA4]), lenBuf, data]);
}

/**
 * Script Builder class
 * @class ScriptBuilder
 */
class ScriptBuilder {
    constructor() {
        this.chunks = [];
    }

    /**
     * Add an opcode
     * @param {number} opcode - Opcode to add
     * @returns {ScriptBuilder} this
     */
    addOp(opcode) {
        this.chunks.push(Buffer.from([opcode]));
        return this;
    }

    /**
     * Push data onto the stack
     * @param {Buffer|string} data - Data to push
     * @returns {ScriptBuilder} this
     */
    pushData(data) {
        const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, 'hex');
        this.chunks.push(encodePush(buf));
        return this;
    }

    /**
     * Push a number onto the stack
     * @param {number} num - Number to push
     * @returns {ScriptBuilder} this
     */
    pushNumber(num) {
        if (num === 0) {
            return this.addOp(OPCODES.OP_0);
        }
        if (num === -1) {
            return this.addOp(OPCODES.OP_1NEGATE);
        }
        if (num >= 1 && num <= 16) {
            return this.addOp(OPCODES.OP_1 + num - 1);
        }

        // Encode as minimal push
        const negative = num < 0;
        let absNum = Math.abs(num);
        const bytes = [];

        while (absNum > 0) {
            bytes.push(absNum & 0xff);
            absNum >>= 8;
        }

        if (bytes[bytes.length - 1] & 0x80) {
            bytes.push(negative ? 0x80 : 0x00);
        } else if (negative) {
            bytes[bytes.length - 1] |= 0x80;
        }

        return this.pushData(Buffer.from(bytes));
    }

    /**
     * Build the script
     * @returns {Buffer} Compiled script
     */
    build() {
        return Buffer.concat(this.chunks);
    }

    /**
     * Reset the builder
     * @returns {ScriptBuilder} this
     */
    reset() {
        this.chunks = [];
        return this;
    }

    // ===== Static factory methods =====

    /**
     * Create P2PKH scriptPubKey
     * @param {Buffer} pubkeyHash - 20-byte public key hash
     * @returns {Buffer} scriptPubKey
     */
    static createP2PKH(pubkeyHash) {
        if (pubkeyHash.length !== 20) {
            throw new ScriptError('P2PKH requires 20-byte hash', 'INVALID_HASH');
        }
        // OP_DUP OP_HASH160 <20 bytes> OP_EQUALVERIFY OP_CHECKSIG
        return Buffer.concat([
            Buffer.from([OPCODES.OP_DUP, OPCODES.OP_HASH160]),
            encodePush(pubkeyHash),
            Buffer.from([OPCODES.OP_EQUALVERIFY, OPCODES.OP_CHECKSIG])
        ]);
    }

    /**
     * Create P2PKH scriptPubKey from public key
     * @param {Buffer} publicKey - Compressed or uncompressed public key
     * @returns {Buffer} scriptPubKey
     */
    static createP2PKHFromPubkey(publicKey) {
        return this.createP2PKH(hash160(publicKey));
    }

    /**
     * Create P2SH scriptPubKey
     * @param {Buffer} scriptHash - 20-byte script hash
     * @returns {Buffer} scriptPubKey
     */
    static createP2SH(scriptHash) {
        if (scriptHash.length !== 20) {
            throw new ScriptError('P2SH requires 20-byte hash', 'INVALID_HASH');
        }
        // OP_HASH160 <20 bytes> OP_EQUAL
        return Buffer.concat([
            Buffer.from([OPCODES.OP_HASH160]),
            encodePush(scriptHash),
            Buffer.from([OPCODES.OP_EQUAL])
        ]);
    }

    /**
     * Create P2WPKH scriptPubKey (SegWit v0)
     * @param {Buffer} pubkeyHash - 20-byte public key hash
     * @returns {Buffer} scriptPubKey
     */
    static createP2WPKH(pubkeyHash) {
        if (pubkeyHash.length !== 20) {
            throw new ScriptError('P2WPKH requires 20-byte hash', 'INVALID_HASH');
        }
        // OP_0 <20 bytes>
        return Buffer.concat([
            Buffer.from([OPCODES.OP_0, 0x14]),
            pubkeyHash
        ]);
    }

    /**
     * Create P2WPKH scriptPubKey from public key
     * @param {Buffer} publicKey - Compressed public key
     * @returns {Buffer} scriptPubKey
     */
    static createP2WPKHFromPubkey(publicKey) {
        return this.createP2WPKH(hash160(publicKey));
    }

    /**
     * Create P2WSH scriptPubKey (SegWit v0)
     * @param {Buffer} scriptHash - 32-byte script hash (SHA256)
     * @returns {Buffer} scriptPubKey
     */
    static createP2WSH(scriptHash) {
        if (scriptHash.length !== 32) {
            throw new ScriptError('P2WSH requires 32-byte hash', 'INVALID_HASH');
        }
        // OP_0 <32 bytes>
        return Buffer.concat([
            Buffer.from([OPCODES.OP_0, 0x20]),
            scriptHash
        ]);
    }

    /**
     * Create P2TR scriptPubKey (Taproot)
     * @param {Buffer} xOnlyPubkey - 32-byte x-only public key
     * @returns {Buffer} scriptPubKey
     */
    static createP2TR(xOnlyPubkey) {
        if (xOnlyPubkey.length !== 32) {
            throw new ScriptError('P2TR requires 32-byte x-only pubkey', 'INVALID_PUBKEY');
        }
        // OP_1 <32 bytes>
        return Buffer.concat([
            Buffer.from([OPCODES.OP_1, 0x20]),
            xOnlyPubkey
        ]);
    }

    /**
     * Create OP_RETURN output script
     * @param {Buffer|string} data - Data to embed (max 80 bytes)
     * @returns {Buffer} scriptPubKey
     */
    static createOpReturn(data) {
        const buf = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
        if (buf.length > 80) {
            throw new ScriptError('OP_RETURN data exceeds 80 bytes', 'DATA_TOO_LONG');
        }
        return Buffer.concat([
            Buffer.from([OPCODES.OP_RETURN]),
            encodePush(buf)
        ]);
    }

    /**
     * Create P2PKH scriptSig (unlocking script)
     * @param {Buffer} signature - DER signature with sighash type
     * @param {Buffer} publicKey - Public key
     * @returns {Buffer} scriptSig
     */
    static createP2PKHScriptSig(signature, publicKey) {
        return Buffer.concat([
            encodePush(signature),
            encodePush(publicKey)
        ]);
    }

    /**
     * Create P2SH scriptSig
     * @param {Array<Buffer>} pushData - Array of data to push
     * @param {Buffer} redeemScript - The redeem script
     * @returns {Buffer} scriptSig
     */
    static createP2SHScriptSig(pushData, redeemScript) {
        const parts = pushData.map(d => encodePush(d));
        parts.push(encodePush(redeemScript));
        return Buffer.concat(parts);
    }

    /**
     * Create multisig script
     * @param {number} m - Required signatures
     * @param {Array<Buffer>} publicKeys - Public keys
     * @returns {Buffer} Multisig script
     */
    static createMultisig(m, publicKeys) {
        if (m < 1 || m > publicKeys.length) {
            throw new ScriptError('Invalid M value for multisig', 'INVALID_M');
        }
        if (publicKeys.length > 16) {
            throw new ScriptError('Too many keys for multisig', 'TOO_MANY_KEYS');
        }

        const builder = new ScriptBuilder();
        builder.pushNumber(m);
        for (const pk of publicKeys) {
            builder.pushData(pk);
        }
        builder.pushNumber(publicKeys.length);
        builder.addOp(OPCODES.OP_CHECKMULTISIG);

        return builder.build();
    }

    /**
     * Create CLTV (CheckLockTimeVerify) script
     * @param {number} locktime - Locktime value
     * @param {Buffer} pubkeyHash - Public key hash
     * @returns {Buffer} CLTV script
     */
    static createCLTV(locktime, pubkeyHash) {
        const builder = new ScriptBuilder();
        builder.pushNumber(locktime);
        builder.addOp(OPCODES.OP_CHECKLOCKTIMEVERIFY);
        builder.addOp(OPCODES.OP_DROP);
        builder.addOp(OPCODES.OP_DUP);
        builder.addOp(OPCODES.OP_HASH160);
        builder.pushData(pubkeyHash);
        builder.addOp(OPCODES.OP_EQUALVERIFY);
        builder.addOp(OPCODES.OP_CHECKSIG);
        return builder.build();
    }

    /**
     * Create CSV (CheckSequenceVerify) script
     * @param {number} sequence - Relative locktime
     * @param {Buffer} pubkeyHash - Public key hash
     * @returns {Buffer} CSV script
     */
    static createCSV(sequence, pubkeyHash) {
        const builder = new ScriptBuilder();
        builder.pushNumber(sequence);
        builder.addOp(OPCODES.OP_CHECKSEQUENCEVERIFY);
        builder.addOp(OPCODES.OP_DROP);
        builder.addOp(OPCODES.OP_DUP);
        builder.addOp(OPCODES.OP_HASH160);
        builder.pushData(pubkeyHash);
        builder.addOp(OPCODES.OP_EQUALVERIFY);
        builder.addOp(OPCODES.OP_CHECKSIG);
        return builder.build();
    }

    /**
     * Parse a script into human-readable format
     * @param {Buffer} script - Script to parse
     * @returns {Array} Array of parsed elements
     */
    static parse(script) {
        const result = [];
        let i = 0;

        while (i < script.length) {
            const opcode = script[i];

            // Direct push (1-75 bytes)
            if (opcode >= 0x01 && opcode <= 0x4b) {
                const len = opcode;
                const data = script.slice(i + 1, i + 1 + len);
                result.push({ type: 'data', value: data, hex: data.toString('hex') });
                i += 1 + len;
                continue;
            }

            // OP_PUSHDATA1
            if (opcode === OPCODES.OP_PUSHDATA1) {
                const len = script[i + 1];
                const data = script.slice(i + 2, i + 2 + len);
                result.push({ type: 'data', value: data, hex: data.toString('hex') });
                i += 2 + len;
                continue;
            }

            // OP_PUSHDATA2
            if (opcode === OPCODES.OP_PUSHDATA2) {
                const len = script.readUInt16LE(i + 1);
                const data = script.slice(i + 3, i + 3 + len);
                result.push({ type: 'data', value: data, hex: data.toString('hex') });
                i += 3 + len;
                continue;
            }

            // OP_PUSHDATA4
            if (opcode === OPCODES.OP_PUSHDATA4) {
                const len = script.readUInt32LE(i + 1);
                const data = script.slice(i + 5, i + 5 + len);
                result.push({ type: 'data', value: data, hex: data.toString('hex') });
                i += 5 + len;
                continue;
            }

            // Regular opcode
            const name = OPCODE_NAMES[opcode] || `OP_UNKNOWN_${opcode.toString(16)}`;
            result.push({ type: 'opcode', value: opcode, name });
            i += 1;
        }

        return result;
    }

    /**
     * Disassemble script to string
     * @param {Buffer} script - Script to disassemble
     * @returns {string} Human-readable script
     */
    static disassemble(script) {
        const parsed = this.parse(script);
        return parsed.map(elem => {
            if (elem.type === 'data') {
                return `<${elem.hex}>`;
            }
            return elem.name;
        }).join(' ');
    }

    /**
     * Detect script type
     * @param {Buffer} script - Script to analyze
     * @returns {Object} Script type info
     */
    static detectType(script) {
        const len = script.length;

        // P2PKH: OP_DUP OP_HASH160 <20> OP_EQUALVERIFY OP_CHECKSIG
        if (len === 25 && script[0] === 0x76 && script[1] === 0xa9 &&
            script[2] === 0x14 && script[23] === 0x88 && script[24] === 0xac) {
            return { type: 'p2pkh', hash: script.slice(3, 23) };
        }

        // P2SH: OP_HASH160 <20> OP_EQUAL
        if (len === 23 && script[0] === 0xa9 && script[1] === 0x14 && script[22] === 0x87) {
            return { type: 'p2sh', hash: script.slice(2, 22) };
        }

        // P2WPKH: OP_0 <20>
        if (len === 22 && script[0] === 0x00 && script[1] === 0x14) {
            return { type: 'p2wpkh', program: script.slice(2) };
        }

        // P2WSH: OP_0 <32>
        if (len === 34 && script[0] === 0x00 && script[1] === 0x20) {
            return { type: 'p2wsh', program: script.slice(2) };
        }

        // P2TR: OP_1 <32>
        if (len === 34 && script[0] === 0x51 && script[1] === 0x20) {
            return { type: 'p2tr', program: script.slice(2) };
        }

        // OP_RETURN
        if (script[0] === 0x6a) {
            return { type: 'op_return', data: script.slice(1) };
        }

        // Multisig
        if (script[script.length - 1] === OPCODES.OP_CHECKMULTISIG) {
            const n = script[script.length - 2] - 0x50;
            const m = script[0] - 0x50;
            if (m >= 1 && m <= 16 && n >= 1 && n <= 16 && m <= n) {
                return { type: 'multisig', m, n };
            }
        }

        return { type: 'unknown' };
    }
}

export {
    OPCODES,
    OPCODE_NAMES,
    ScriptBuilder,
    ScriptError,
    encodePush,
    hash160
};

export default ScriptBuilder;