#!/usr/bin/env node
/**
 * Tapo Device Discovery Tool
 * 
 * Discovers TP-Link Tapo devices on the local network using UDP broadcast.
 * Based on the protocol from the tapo-rs library.
 * 
 * Usage: node tapo-discover.js [broadcast_ip] [timeout_seconds]
 *   broadcast_ip: Default is 255.255.255.255
 *   timeout_seconds: Default is 10
 */

import dgram from 'dgram';
import crypto from 'crypto';
import { Buffer } from 'buffer';

// Tapo discovery port
const TAPO_DISCOVERY_PORT = 20002;
const DISCOVERY_INTERVAL_MS = 3000;

/**
 * Generate a simple RSA key pair for the discovery request
 * Tapo devices expect an RSA public key in PEM format
 */
function generateRsaKeyPair() {
    const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
        modulusLength: 1024,
        publicKeyEncoding: {
            type: 'pkcs1',
            format: 'pem'
        },
        privateKeyEncoding: {
            type: 'pkcs1',
            format: 'pem'
        }
    });
    return { publicKey, privateKey };
}

/**
 * CRC32 implementation (same polynomial as crc32fast in Rust)
 */
function crc32(data) {
    let crc = 0xFFFFFFFF;
    const table = getCrc32Table();

    for (let i = 0; i < data.length; i++) {
        crc = (crc >>> 8) ^ table[(crc ^ data[i]) & 0xFF];
    }

    return (crc ^ 0xFFFFFFFF) >>> 0;
}

let crc32Table = null;
function getCrc32Table() {
    if (crc32Table) return crc32Table;

    crc32Table = new Uint32Array(256);
    for (let i = 0; i < 256; i++) {
        let c = i;
        for (let j = 0; j < 8; j++) {
            c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
        }
        crc32Table[i] = c;
    }
    return crc32Table;
}

/**
 * Generate the AES discovery query packet
 * Based on tapo-rs: aes_discovery_query_generator.rs
 */
function generateDiscoveryPacket() {
    const { publicKey } = generateRsaKeyPair();

    // Create the JSON payload with RSA public key
    const keyPayload = JSON.stringify({
        params: {
            rsa_key: publicKey
        }
    });
    const keyPayloadBytes = Buffer.from(keyPayload, 'utf8');

    // Generate random device serial (4 bytes)
    const secret = crypto.randomBytes(4);
    const deviceSerial = secret.readUInt32BE(0);

    // Header fields (based on tapo-rs implementation)
    const version = 2;        // u8
    const msgType = 0;        // u8
    const opCode = 1;         // u16 BE
    const msgSize = keyPayloadBytes.length;  // u16 BE
    const flags = 17;         // u8
    const paddingByte = 0;    // u8
    const initialCrc = 0x5A6B7C8D;  // i32 BE (placeholder, will be replaced)

    // Build header (16 bytes total)
    const header = Buffer.alloc(16);
    let offset = 0;
    header.writeUInt8(version, offset++);           // 1 byte
    header.writeUInt8(msgType, offset++);           // 1 byte
    header.writeUInt16BE(opCode, offset);           // 2 bytes
    offset += 2;
    header.writeUInt16BE(msgSize, offset);          // 2 bytes
    offset += 2;
    header.writeUInt8(flags, offset++);             // 1 byte
    header.writeUInt8(paddingByte, offset++);       // 1 byte
    header.writeUInt32BE(deviceSerial, offset);     // 4 bytes
    offset += 4;
    header.writeInt32BE(initialCrc, offset);        // 4 bytes (placeholder)

    // Combine header and payload
    const query = Buffer.concat([header, keyPayloadBytes]);

    // Calculate CRC32 of the entire packet and update bytes 12-16
    const crcValue = crc32(query);
    query.writeUInt32BE(crcValue, 12);

    return query;
}

/**
 * Parse discovery response from Tapo device
 */
function parseDiscoveryResponse(data, rinfo) {
    const result = {
        ip: rinfo.address,
        port: rinfo.port,
        rawSize: data.length
    };

    // Response has 16-byte header + JSON payload
    if (data.length > 16) {
        try {
            const jsonStr = data.slice(16).toString('utf8');
            // Find the end of JSON (some responses have trailing garbage)
            const jsonEnd = jsonStr.lastIndexOf('}') + 1;
            if (jsonEnd > 0) {
                const json = JSON.parse(jsonStr.slice(0, jsonEnd));
                result.data = json;

                // Extract common fields
                if (json.result) {
                    result.deviceId = json.result.device_id;
                    result.owner = json.result.owner;
                    result.deviceType = json.result.device_type;
                    result.deviceModel = json.result.device_model;
                    result.factoryDefault = json.result.factory_default;
                    result.mgtEncryptSchm = json.result.mgt_encrypt_schm;
                }
            }
        } catch (e) {
            result.parseError = e.message;
            result.rawHex = data.toString('hex');
        }
    } else {
        result.rawHex = data.toString('hex');
    }

    return result;
}

/**
 * Main discovery function
 */
async function discoverDevices(broadcastAddr = '255.255.255.255', timeoutSeconds = 10) {
    return new Promise((resolve, reject) => {
        const devices = new Map();
        const socket = dgram.createSocket('udp4');

        socket.on('error', (err) => {
            console.error('Socket error:', err);
            socket.close();
            reject(err);
        });

        socket.on('message', (msg, rinfo) => {
            if (!devices.has(rinfo.address)) {
                const device = parseDiscoveryResponse(msg, rinfo);
                devices.set(rinfo.address, device);

                console.log(`\n✓ Found device at ${rinfo.address}`);
                if (device.deviceModel) {
                    console.log(`  Model: ${device.deviceModel}`);
                }
                if (device.deviceType) {
                    console.log(`  Type: ${device.deviceType}`);
                }
                if (device.deviceId) {
                    console.log(`  Device ID: ${device.deviceId}`);
                }
                if (device.mgtEncryptSchm) {
                    console.log(`  Encryption: ${JSON.stringify(device.mgtEncryptSchm)}`);
                }
                if (device.parseError) {
                    console.log(`  Parse error: ${device.parseError}`);
                }
            }
        });

        socket.bind(() => {
            socket.setBroadcast(true);

            console.log(`Discovering Tapo devices on ${broadcastAddr}:${TAPO_DISCOVERY_PORT} for ${timeoutSeconds} seconds...`);

            const sendDiscovery = () => {
                try {
                    const packet = generateDiscoveryPacket();
                    socket.send(packet, 0, packet.length, TAPO_DISCOVERY_PORT, broadcastAddr, (err) => {
                        if (err) {
                            console.error('Error sending discovery packet:', err.message);
                        }
                    });
                } catch (err) {
                    console.error('Error generating discovery packet:', err.message);
                }
            };

            // Send immediately and then at intervals
            sendDiscovery();
            const interval = setInterval(sendDiscovery, DISCOVERY_INTERVAL_MS);

            // Stop after timeout
            setTimeout(() => {
                clearInterval(interval);
                socket.close();
                resolve(Array.from(devices.values()));
            }, timeoutSeconds * 1000);
        });
    });
}

// Main execution
async function main() {
    const args = process.argv.slice(2).filter(a => !a.startsWith('--'));
    const broadcastAddr = args[0] || '255.255.255.255';
    const timeout = parseInt(args[1]) || 10;
    const jsonOutput = args.includes('--json');

    console.log('╔════════════════════════════════════════╗');
    console.log('║     Tapo Device Discovery Tool         ║');
    console.log('╚════════════════════════════════════════╝\n');

    const devices = await discoverDevices(broadcastAddr, timeout);

    console.log('\n────────────────────────────────────────');
    console.log(`Discovery complete. Found ${devices.length} device(s).`);

    if (devices.length > 0) {
        console.log('\nDiscovered Devices:');
        devices.forEach((device, idx) => {
            console.log(`  ${idx + 1}. ${device.ip} - ${device.deviceModel || 'Unknown Model'} (${device.deviceType || 'Unknown Type'})`);
        });
    } else {
        console.log('\nNo Tapo devices found on the network.');
        console.log('Tips:');
        console.log('  - Ensure devices are powered on and connected to the same network');
        console.log('  - Try specifying your network broadcast address (e.g., 192.168.3.255)');
        console.log('  - Some devices may not respond to UDP discovery');
    }

    if (jsonOutput && devices.length > 0) {
        console.log('\nJSON Output:');
        console.log(JSON.stringify(devices, null, 2));
    }
}

main().catch(console.error);
