import node_path from "node:path";
import { AndroidAgent, AndroidDevice, getConnectedDevicesWithDetails } from "@midscene/android";
import { createScrcpyPreviewDescriptor, definePlaygroundPlatform } from "@midscene/playground";
import { PLAYGROUND_SERVER_PORT, SCRCPY_ADB_CONNECT_TIMEOUT_MS, SCRCPY_PREVIEW_METADATA_TIMEOUT_MS, SCRCPY_PUSH_TIMEOUT_MS, SCRCPY_SERVER_PORT, SCRCPY_START_TIMEOUT_MS, SCRCPY_VIDEO_STREAM_TIMEOUT_MS } from "@midscene/shared/constants";
import { findAvailablePort } from "@midscene/shared/node";
import { exec } from "node:child_process";
import { createReadStream } from "node:fs";
import { createServer } from "node:http";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { getDebug } from "@midscene/shared/logger";
import cors from "cors";
import express from "express";
import { Server } from "socket.io";
async function getAdbTargets() {
    const devices = await getConnectedDevicesWithDetails();
    return devices.filter((device)=>'device' === device.state).map((device, index)=>({
            id: device.udid,
            label: device.udid,
            description: [
                device.model,
                device.resolution
            ].filter(Boolean).join(' · ') || device.state,
            status: device.state,
            isDefault: 0 === index
        }));
}
async function getAdbTargetsSafe() {
    try {
        return {
            targets: await getAdbTargets()
        };
    } catch (error) {
        return {
            targets: [],
            error: error instanceof Error ? error.message : String(error)
        };
    }
}
const androidPlaygroundPlatform = definePlaygroundPlatform({
    id: 'android',
    title: 'Midscene Android Playground',
    description: "Android playground platform descriptor",
    async prepare (options) {
        const staticDir = options?.staticDir || node_path.join(__dirname, '../../static');
        const [playgroundPort, resolvedScrcpyPort] = await Promise.all([
            findAvailablePort(PLAYGROUND_SERVER_PORT),
            options?.scrcpyPort ? Promise.resolve(options.scrcpyPort) : findAvailablePort(SCRCPY_SERVER_PORT)
        ]);
        const scrcpyPort = resolvedScrcpyPort;
        if (playgroundPort !== PLAYGROUND_SERVER_PORT) console.log(`⚠️  Port ${PLAYGROUND_SERVER_PORT} is busy, using port ${playgroundPort} instead`);
        if (scrcpyPort !== SCRCPY_SERVER_PORT) console.log(`⚠️  Port ${SCRCPY_SERVER_PORT} is busy, using port ${scrcpyPort} instead`);
        const sessionManager = {
            async getSetupSchema () {
                const { targets, error } = await getAdbTargetsSafe();
                return {
                    title: 'Welcome to\nMidscene.js Playground!',
                    description: 'Select an available ADB device to create the current Android Agent',
                    primaryActionLabel: 'Create Agent',
                    autoSubmitWhenReady: 1 === targets.length,
                    notice: error ? {
                        type: 'warning',
                        message: 'Android device discovery failed',
                        description: error
                    } : void 0,
                    fields: [
                        {
                            key: 'deviceId',
                            label: 'ADB device',
                            type: 'select',
                            required: true,
                            options: targets.map((target)=>({
                                    label: target.label,
                                    value: target.id,
                                    description: target.description
                                })),
                            defaultValue: targets.find((target)=>target.isDefault)?.id,
                            placeholder: 'Select a connected Android device'
                        }
                    ],
                    targets
                };
            },
            listTargets: async ()=>(await getAdbTargetsSafe()).targets,
            async createSession (input) {
                const targets = await getAdbTargets();
                const deviceId = 'string' == typeof input?.deviceId && input.deviceId ? input.deviceId : targets.find((target)=>target.isDefault)?.id;
                if (!deviceId) throw new Error('No Android devices found. Connect a device with USB debugging enabled and try again.');
                const connectAgent = async ()=>{
                    const device = new AndroidDevice(deviceId);
                    await device.connect();
                    return new AndroidAgent(device);
                };
                if (options?.scrcpyServer) options.scrcpyServer.currentDeviceId = deviceId;
                const agent = await connectAgent();
                return {
                    agent,
                    agentFactory: connectAgent,
                    preview: createScrcpyPreviewDescriptor({
                        scrcpyPort
                    }, {
                        title: 'Android device preview'
                    }),
                    displayName: deviceId,
                    metadata: {
                        deviceId,
                        scrcpyPort
                    }
                };
            }
        };
        return {
            platformId: 'android',
            title: 'Midscene Android Playground',
            sessionManager,
            sidecars: options?.scrcpyServer ? [
                {
                    id: 'android-scrcpy',
                    start: async ()=>{
                        await options.scrcpyServer?.launch(scrcpyPort);
                    },
                    stop: async ()=>{
                        options.scrcpyServer?.close();
                    }
                }
            ] : void 0,
            launchOptions: {
                port: playgroundPort,
                openBrowser: false,
                verbose: false,
                staticPath: staticDir,
                configureServer (server) {
                    server.scrcpyPort = scrcpyPort;
                }
            },
            preview: createScrcpyPreviewDescriptor({
                scrcpyPort
            }, {
                title: 'Android device preview'
            }),
            metadata: {
                scrcpyPort,
                sessionConnected: false,
                setupState: 'required'
            }
        };
    }
});
function getScrcpyPreviewStatusMessage(phase) {
    switch(phase){
        case 'connecting-device':
            return 'Preparing Android device connection…';
        case 'pushing-server':
            return 'Uploading scrcpy runtime to device…';
        case 'starting-service':
            return 'Starting scrcpy service…';
        case 'waiting-for-video':
            return 'Waiting for first video frame…';
    }
}
function buildScrcpyPreviewStatusEvent(phase) {
    return {
        phase,
        message: getScrcpyPreviewStatusMessage(phase)
    };
}
function _define_property(obj, key, value) {
    if (key in obj) Object.defineProperty(obj, key, {
        value: value,
        enumerable: true,
        configurable: true,
        writable: true
    });
    else obj[key] = value;
    return obj;
}
class PromiseTimeoutError extends Error {
    constructor(message, timeoutMs){
        super(message), _define_property(this, "timeoutMs", void 0);
        this.name = 'PromiseTimeoutError';
        this.timeoutMs = timeoutMs;
    }
}
function runTimeoutCallback(callback, context) {
    if (!callback) return;
    Promise.resolve().then(callback).catch((error)=>{
        console.error(`Failed to run ${context}:`, error);
    });
}
function withTimeout(promise, timeoutMs, message, options = {}) {
    return new Promise((resolve, reject)=>{
        let timedOut = false;
        const timer = setTimeout(()=>{
            timedOut = true;
            runTimeoutCallback(options.onTimeout, 'timeout callback');
            reject(new PromiseTimeoutError(message, timeoutMs));
        }, timeoutMs);
        Promise.resolve(promise).then((value)=>{
            clearTimeout(timer);
            if (timedOut) return void runTimeoutCallback(()=>options.onSettledAfterTimeout?.(value), 'post-timeout settle callback');
            resolve(value);
        }, (error)=>{
            clearTimeout(timer);
            if (timedOut) return void runTimeoutCallback(()=>options.onRejectedAfterTimeout?.(error), 'post-timeout rejection callback');
            reject(error);
        });
    });
}
function scrcpy_server_define_property(obj, key, value) {
    if (key in obj) Object.defineProperty(obj, key, {
        value: value,
        enumerable: true,
        configurable: true,
        writable: true
    });
    else obj[key] = value;
    return obj;
}
const debugPage = getDebug('android:playground');
const promiseExec = promisify(exec);
const LOOPBACK_HOSTS = new Set([
    'localhost',
    '127.0.0.1',
    '::1',
    '[::1]'
]);
function isPrivateIP(hostname) {
    return LOOPBACK_HOSTS.has(hostname) || /^10\./.test(hostname) || /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) || /^192\.168\./.test(hostname) || /^100\.(6[4-9]|[7-9]\d|1[0-2]\d)\./.test(hostname);
}
function isAllowedOrigin(origin) {
    if (!origin) return true;
    try {
        const url = new URL(origin);
        return isPrivateIP(url.hostname);
    } catch  {
        return false;
    }
}
function resolveRequestedDeviceId(options, currentDeviceId) {
    const requestedDeviceId = 'string' == typeof options?.deviceId ? options.deviceId.trim() : '';
    return requestedDeviceId || currentDeviceId || void 0;
}
class ScrcpyServer {
    setupApiRoutes() {
        this.app.get('/api/devices', async (req, res)=>{
            try {
                const devices = await this.getDevicesList();
                res.json({
                    devices,
                    currentDeviceId: this.currentDeviceId
                });
            } catch (error) {
                res.status(500).json({
                    error: error.message || 'Failed to get devices list'
                });
            }
        });
    }
    async getDevicesList() {
        if (this.deviceListSource) return this.deviceListSource.getDevices();
        try {
            debugPage('start to get devices list');
            const client = await this.getAdbClient();
            if (!client) {
                console.warn('failed to get adb client');
                return [];
            }
            debugPage('success to get adb client, start to request devices list');
            let devices;
            try {
                devices = await client.getDevices();
                debugPage('original devices list:', devices);
            } catch (error) {
                console.error('failed to get devices list:', error);
                return [];
            }
            if (!devices || 0 === devices.length) return [];
            const formattedDevices = devices.map((device)=>{
                const result = {
                    id: device.serial,
                    name: device.product || device.model || device.serial,
                    status: device.state || 'device'
                };
                return result;
            });
            return formattedDevices;
        } catch (error) {
            console.error('failed to get devices list:', error);
            return [];
        }
    }
    broadcastDevicesList(devices) {
        const currentDevicesJson = JSON.stringify(devices);
        if (this.lastDeviceList === currentDevicesJson) return;
        debugPage('devices list changed, push to all connected clients');
        this.lastDeviceList = currentDevicesJson;
        if (this.currentDeviceId && !devices.some((device)=>device.id === this.currentDeviceId)) this.currentDeviceId = null;
        if (!this.currentDeviceId && devices.length > 0) {
            const onlineDevices = devices.filter((device)=>'device' === device.status.toLowerCase());
            if (onlineDevices.length > 0) {
                this.currentDeviceId = onlineDevices[0].id;
                debugPage('auto select the first online device:', this.currentDeviceId);
            }
        }
        this.io.emit('devices-list', {
            devices,
            currentDeviceId: this.currentDeviceId
        });
    }
    async getAdbClient() {
        const { AdbServerClient } = await import("@yume-chan/adb");
        const { AdbServerNodeTcpConnector } = await import("@yume-chan/adb-server-node-tcp");
        try {
            if (this.adbClient) debugPage('use existing adb client');
            else {
                await withTimeout(promiseExec('adb start-server'), SCRCPY_ADB_CONNECT_TIMEOUT_MS, `Timed out starting adb server after ${Math.round(SCRCPY_ADB_CONNECT_TIMEOUT_MS / 1000)}s`);
                debugPage('adb server started');
                debugPage('initialize adb client');
                this.adbClient = new AdbServerClient(new AdbServerNodeTcpConnector({
                    host: '127.0.0.1',
                    port: 5037
                }));
                await debugPage('success to initialize adb client');
            }
            return this.adbClient;
        } catch (error) {
            console.error('failed to get adb client:', error);
            return null;
        }
    }
    async getAdb(deviceId) {
        const { Adb } = await import("@yume-chan/adb");
        try {
            const client = await this.getAdbClient();
            if (!client) throw new Error('Failed to initialize ADB client');
            const targetDeviceId = deviceId || this.currentDeviceId;
            if (targetDeviceId) {
                this.currentDeviceId = targetDeviceId;
                return new Adb(await withTimeout(client.createTransport({
                    serial: targetDeviceId
                }), SCRCPY_ADB_CONNECT_TIMEOUT_MS, `Timed out connecting to Android device ${targetDeviceId} via ADB after ${Math.round(SCRCPY_ADB_CONNECT_TIMEOUT_MS / 1000)}s`));
            }
            const devices = await withTimeout(client.getDevices(), SCRCPY_ADB_CONNECT_TIMEOUT_MS, `Timed out listing Android devices via ADB after ${Math.round(SCRCPY_ADB_CONNECT_TIMEOUT_MS / 1000)}s`);
            if (0 === devices.length) return null;
            this.currentDeviceId = devices[0].serial;
            return new Adb(await withTimeout(client.createTransport(devices[0]), SCRCPY_ADB_CONNECT_TIMEOUT_MS, `Timed out connecting to Android device ${devices[0].serial} via ADB after ${Math.round(SCRCPY_ADB_CONNECT_TIMEOUT_MS / 1000)}s`));
        } catch (error) {
            console.error('failed to get adb client:', error);
            throw error;
        }
    }
    async startScrcpy(adb, options = {}, onProgress) {
        const { AdbScrcpyClient, AdbScrcpyOptions3_3_3 } = await import("@yume-chan/adb-scrcpy");
        const { ReadableStream } = await import("@yume-chan/stream-extra");
        const { DefaultServerPath } = await import("@yume-chan/scrcpy");
        const currentDir = 'undefined' != typeof __dirname ? __dirname : node_path.dirname(fileURLToPath(import.meta.url));
        const serverBinPath = node_path.resolve(currentDir, '../../bin/scrcpy-server');
        try {
            onProgress?.('pushing-server');
            await withTimeout(AdbScrcpyClient.pushServer(adb, ReadableStream.from(createReadStream(serverBinPath))), SCRCPY_PUSH_TIMEOUT_MS, `Timed out pushing scrcpy server to device after ${Math.round(SCRCPY_PUSH_TIMEOUT_MS / 1000)}s`);
            const scrcpyOptions = new AdbScrcpyOptions3_3_3({
                audio: false,
                control: true,
                maxSize: 1024,
                sendFrameMeta: true,
                videoBitRate: 2000000,
                ...options
            });
            onProgress?.('starting-service');
            const startPromise = AdbScrcpyClient.start(adb, DefaultServerPath, scrcpyOptions);
            return await withTimeout(startPromise, SCRCPY_START_TIMEOUT_MS, `Timed out starting scrcpy service after ${Math.round(SCRCPY_START_TIMEOUT_MS / 1000)}s`, {
                onSettledAfterTimeout: async (lateClient)=>{
                    try {
                        await lateClient.close();
                    } catch (closeError) {
                        console.error('failed to close late scrcpy client after timeout:', closeError);
                    }
                }
            });
        } catch (error) {
            console.error('failed to start scrcpy:', error);
            throw error;
        }
    }
    setupSocketHandlers() {
        this.io.on('connection', async (socket)=>{
            debugPage('client connected, id: %s, client address: %s', socket.id, socket.handshake.address);
            let scrcpyClient = null;
            let adb = null;
            const emitPreviewStatus = (phase)=>{
                socket.emit('preview-status', buildScrcpyPreviewStatusEvent(phase));
            };
            const sendDevicesList = async ()=>{
                try {
                    debugPage('Socket request to get devices list');
                    const devices = await this.getDevicesList();
                    debugPage('send devices list to client:', devices);
                    socket.emit('devices-list', {
                        devices,
                        currentDeviceId: this.currentDeviceId
                    });
                } catch (error) {
                    console.error('failed to send devices list:', error);
                    socket.emit('error', {
                        message: 'failed to get devices list'
                    });
                }
            };
            socket.on('get-devices', async ()=>{
                debugPage('received client request to get devices list');
                await sendDevicesList();
            });
            socket.on('switch-device', async (deviceId)=>{
                debugPage('received client request to switch device:', deviceId);
                try {
                    if (scrcpyClient) {
                        await scrcpyClient.close();
                        scrcpyClient = null;
                    }
                    this.currentDeviceId = deviceId;
                    debugPage('device switched to:', deviceId);
                    socket.emit('device-switched', {
                        deviceId
                    });
                    this.io.emit('global-device-switched', {
                        deviceId,
                        timestamp: Date.now()
                    });
                } catch (error) {
                    console.error('failed to switch device:', error);
                    socket.emit('error', {
                        message: `Failed to switch device: ${error?.message || 'Unknown error'}`
                    });
                }
            });
            socket.on('connect-device', async (options = {})=>{
                const { ScrcpyVideoCodecId } = await import("@yume-chan/scrcpy");
                try {
                    debugPage('received device connection request, options: %s, client id: %s', options, socket.id);
                    emitPreviewStatus('connecting-device');
                    const requestedDeviceId = resolveRequestedDeviceId(options, this.currentDeviceId);
                    if (requestedDeviceId) this.currentDeviceId = requestedDeviceId;
                    adb = await this.getAdb(requestedDeviceId);
                    if (!adb) {
                        console.error('no available device found');
                        socket.emit('error', {
                            message: 'No device found'
                        });
                        return;
                    }
                    debugPage('starting scrcpy service, device id: %s', this.currentDeviceId);
                    scrcpyClient = await this.startScrcpy(adb, options, emitPreviewStatus);
                    debugPage('scrcpy service started successfully');
                    debugPage('check scrcpyClient object structure: %s', Object.getOwnPropertyNames(scrcpyClient).map((name)=>{
                        const type = typeof scrcpyClient[name];
                        const isPromise = 'object' === type && scrcpyClient[name] && 'function' == typeof scrcpyClient[name].then;
                        return `${name}: ${type}${isPromise ? ' (Promise)' : ''}`;
                    }));
                    try {
                        if (scrcpyClient.videoStream) {
                            debugPage('videoStream exists, type: %s', typeof scrcpyClient.videoStream);
                            let videoStream;
                            if ('object' == typeof scrcpyClient.videoStream && 'function' == typeof scrcpyClient.videoStream.then) {
                                debugPage('videoStream is a Promise, waiting for resolution...');
                                emitPreviewStatus('waiting-for-video');
                                videoStream = await withTimeout(scrcpyClient.videoStream, SCRCPY_VIDEO_STREAM_TIMEOUT_MS, `Timed out waiting for scrcpy video stream metadata after ${Math.round(SCRCPY_VIDEO_STREAM_TIMEOUT_MS / 1000)}s`);
                            } else {
                                debugPage('videoStream is not a Promise, directly use');
                                emitPreviewStatus('waiting-for-video');
                                videoStream = scrcpyClient.videoStream;
                            }
                            debugPage('video stream fetched successfully, metadata: %s', videoStream.metadata);
                            const metadata = videoStream.metadata || {};
                            debugPage('original metadata: %s', metadata);
                            if (!metadata.codec) {
                                debugPage('metadata does not have codec field, use H264 by default');
                                metadata.codec = ScrcpyVideoCodecId.H264;
                            }
                            if (!metadata.width || !metadata.height) {
                                debugPage('metadata does not have width or height field, use default values');
                                metadata.width = metadata.width || 1080;
                                metadata.height = metadata.height || 1920;
                            }
                            debugPage('prepare to send video-metadata event to client, data: %s', JSON.stringify(metadata));
                            socket.emit('video-metadata', metadata);
                            debugPage('video-metadata event sent to client, id: %s, timeout budget: %ss', socket.id, Math.round(SCRCPY_PREVIEW_METADATA_TIMEOUT_MS / 1000));
                            const { stream } = videoStream;
                            const reader = stream.getReader();
                            const processStream = async ()=>{
                                try {
                                    while(true){
                                        const { done, value } = await reader.read();
                                        if (done) break;
                                        const frameType = value.type || 'data';
                                        socket.emit('video-data', {
                                            data: value.data,
                                            type: frameType,
                                            timestamp: Date.now(),
                                            keyFrame: value.keyFrame
                                        });
                                    }
                                } catch (error) {
                                    console.error('error processing video stream:', error);
                                    if (socket.connected) socket.emit('error', {
                                        message: 'video stream processing error'
                                    });
                                    return;
                                }
                                if (socket.connected) socket.emit('error', {
                                    message: 'video stream ended'
                                });
                                if (scrcpyClient) {
                                    try {
                                        await scrcpyClient.close();
                                    } catch (closeError) {
                                        console.error('failed to close scrcpy client after stream ended:', closeError);
                                    }
                                    scrcpyClient = null;
                                }
                            };
                            processStream();
                        } else {
                            console.error('scrcpyClient object does not have videoStream property');
                            socket.emit('error', {
                                message: 'Video stream not available in scrcpy client'
                            });
                        }
                    } catch (error) {
                        console.error('error processing video stream:', error);
                        socket.emit('error', {
                            message: `Video stream processing error: ${error.message}`
                        });
                    }
                    if (scrcpyClient?.controller) socket.emit('control-ready');
                } catch (error) {
                    console.error('failed to connect device:', error);
                    if (scrcpyClient) {
                        try {
                            await scrcpyClient.close();
                        } catch (closeError) {
                            console.error('failed to close scrcpy client after error:', closeError);
                        }
                        scrcpyClient = null;
                    }
                    socket.emit('error', {
                        message: `Failed to connect device: ${error?.message || 'Unknown error'}`
                    });
                }
            });
            socket.on('disconnect', async (reason)=>{
                debugPage('client disconnected, id: %s, reason: %s', socket.id, reason);
                if (scrcpyClient) {
                    try {
                        debugPage('closing scrcpy client');
                        await scrcpyClient.close();
                    } catch (error) {
                        console.error('failed to close scrcpy client:', error);
                    }
                    scrcpyClient = null;
                }
            });
            sendDevicesList();
        });
    }
    async launch(port) {
        this.port = port || this.defaultPort;
        return new Promise((resolve)=>{
            const listenPort = this.port ?? this.defaultPort;
            this.httpServer.listen(listenPort, '0.0.0.0', ()=>{
                console.log(`Scrcpy server running at: http://0.0.0.0:${this.port}`);
                this.startDeviceMonitoring();
                resolve(this);
            });
        });
    }
    startDeviceMonitoring() {
        if (this.deviceListSource) {
            this.deviceListSourceUnsubscribe = this.deviceListSource.subscribe((devices)=>{
                this.broadcastDevicesList(devices);
            });
            this.getDevicesList().then((devices)=>{
                this.broadcastDevicesList(devices);
            }).catch((error)=>{
                console.error('device monitoring error:', error);
            });
            return;
        }
        this.devicePollInterval = setInterval(async ()=>{
            try {
                const devices = await this.getDevicesList();
                this.broadcastDevicesList(devices);
            } catch (error) {
                console.error('device monitoring error:', error);
            }
        }, 3000);
    }
    close() {
        if (this.devicePollInterval) {
            clearInterval(this.devicePollInterval);
            this.devicePollInterval = null;
        }
        if (this.deviceListSourceUnsubscribe) {
            this.deviceListSourceUnsubscribe();
            this.deviceListSourceUnsubscribe = void 0;
        }
        if (this.httpServer?.listening) return this.httpServer.close();
    }
    constructor(options = {}){
        scrcpy_server_define_property(this, "app", void 0);
        scrcpy_server_define_property(this, "httpServer", void 0);
        scrcpy_server_define_property(this, "io", void 0);
        scrcpy_server_define_property(this, "port", void 0);
        scrcpy_server_define_property(this, "defaultPort", SCRCPY_SERVER_PORT);
        scrcpy_server_define_property(this, "adbClient", null);
        scrcpy_server_define_property(this, "currentDeviceId", null);
        scrcpy_server_define_property(this, "devicePollInterval", null);
        scrcpy_server_define_property(this, "deviceListSource", void 0);
        scrcpy_server_define_property(this, "deviceListSourceUnsubscribe", void 0);
        scrcpy_server_define_property(this, "lastDeviceList", '');
        this.deviceListSource = options.deviceListSource;
        this.app = express();
        this.httpServer = createServer(this.app);
        this.io = new Server(this.httpServer, {
            cors: {
                origin (origin, callback) {
                    callback(null, isAllowedOrigin(origin));
                },
                methods: [
                    'GET',
                    'POST'
                ],
                credentials: true
            }
        });
        this.app.use(cors({
            origin (origin, callback) {
                callback(null, isAllowedOrigin(origin));
            },
            credentials: true
        }));
        this.setupSocketHandlers();
        this.setupApiRoutes();
    }
}
export { ScrcpyServer, androidPlaygroundPlatform };
