/********************************************************************************
 * Copyright (c) 2020-2026 EclipseSource and others.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License v. 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0.
 *
 * This Source Code may also be made available under the following Secondary
 * Licenses when the conditions for such availability set forth in the Eclipse
 * Public License v. 2.0 are satisfied: GNU General Public License, version 2
 * with the GNU Classpath Exception which is available at
 * https://www.gnu.org/software/classpath/license.html.
 *
 * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
 ********************************************************************************/
import { Channel, Disposable, DisposableCollection, MaybePromise } from '@theia/core';
import { ForwardingChannel } from '@theia/core/lib/common/message-rpc/channel';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { injectable, postConstruct } from '@theia/core/shared/inversify';
import { RawProcess } from '@theia/process/lib/node/raw-process';
import * as fs from 'fs';
import * as net from 'net';
import { WebSocket } from 'ws';
import { WebSocketConnectionInfo, getWebSocketAddress, isValidWebSocketAddress } from '../common';
import { BaseGLSPServerContribution, GLSPServerContributionOptions } from './glsp-server-contribution';
import { SocketConnectionForwarder } from './socket-connection-forwarder';
import { WebSocketConnectionForwarder } from './websocket-connection-forwarder';

/**
 * Message that is expected to be printed by the embedded server process to the stdout once the
 * server process startup routine has been completed and is ready to accept incoming connections.
 */
export const START_UP_COMPLETE_MSG = '[GLSP-Server]:Startup completed';

export interface GLSPSocketServerContributionOptions extends GLSPServerContributionOptions {
    /**
     * Path to the location of the server executable that should be launched as process
     * Has to be either be a *.jar (Java) or *.js (Node ) file.
     */
    executable?: string;

    /** Socket connection options for new client connections */
    socketConnectionOptions: SocketConnectionOptions;

    /** Additional arguments that should be passed when starting the server process. */
    additionalArgs?: string[];
}

export type SocketConnectionOptions =
    /* Socket connection options*/
    net.TcpSocketConnectOpts | WebSocketConnectionInfo;

export namespace GLSPSocketServerContributionOptions {
    /** Default values for {@link JavaGLSPServerLaunchOptions }**/
    export function createDefaultOptions(): GLSPSocketServerContributionOptions {
        return {
            ...GLSPServerContributionOptions.createDefaultOptions(),
            socketConnectionOptions: {
                port: NaN,
                host: '127.0.0.1'
            }
        };
    }

    /**
     * Utility function to partially set the launch options. Default values (from 'defaultOptions') are used for
     * options that are not specified.
     * @param options (partial) launch options that should be extended with default values (if necessary)
     */
    export function configure(options?: Partial<GLSPSocketServerContributionOptions>): GLSPSocketServerContributionOptions {
        return {
            ...createDefaultOptions(),
            ...options
        };
    }
}

/**
 *  A reusable base implementation for {@link GLSPServerContribution}s that are using a socket connection to communicate
 *  with a Java or Node based GLSP server.
 **/
@injectable()
export abstract class GLSPSocketServerContribution extends BaseGLSPServerContribution {
    override options: GLSPSocketServerContributionOptions;
    protected onReadyDeferred = new Deferred<void>();
    @postConstruct()
    protected override initialize(): void {
        this.options = GLSPSocketServerContributionOptions.configure(this.createContributionOptions());
    }

    abstract override createContributionOptions(): Partial<GLSPSocketServerContributionOptions>;

    doConnect(clientChannel: Channel): MaybePromise<Disposable> {
        const webSocketAddress = getWebSocketAddress(this.options.socketConnectionOptions);
        if (webSocketAddress) {
            if (!isValidWebSocketAddress(webSocketAddress)) {
                throw new Error(`Could not connect to server. The given websocket address is not valid: ${webSocketAddress}`);
            }
            return this.connectToWebSocketServer(clientChannel, webSocketAddress);
        }
        return this.connectToSocketServer(clientChannel);
    }

    async launch(): Promise<void> {
        try {
            if (!this.options.executable) {
                throw new Error('Could not launch GLSP server. No executable path is provided via the contribution options');
            }
            if (!fs.existsSync(this.options.executable)) {
                throw new Error(`Could not launch GLSP server. The given server executable path is not valid: ${this.options.executable}`);
            }
            if (isNaN(this.options.socketConnectionOptions.port)) {
                throw new Error(
                    `Could not launch GLSP Server. The given server port is not a number: ${this.options.socketConnectionOptions.port}`
                );
            }

            if (this.options.executable.endsWith('.jar')) {
                const process = await this.launchJavaProcess();
                this.toDispose.push(Disposable.create(() => process.kill()));
            } else if (this.options.executable.endsWith('.js')) {
                const process = await this.launchNodeProcess();
                this.toDispose.push(Disposable.create(() => process.kill()));
            } else {
                throw new Error(`Could not launch GLSP Server. Invalid executable path ${this.options.executable}`);
            }
        } catch (error) {
            this.onReadyDeferred.reject(error);
        }

        return this.onReadyDeferred.promise;
    }

    protected getPortFromStartupMessage(message: string): number | undefined {
        if (message.includes(START_UP_COMPLETE_MSG)) {
            const port = message.substring(message.lastIndexOf(':') + 1);
            return parseInt(port, 10);
        }
        return undefined;
    }

    protected launchJavaProcess(): Promise<RawProcess> {
        const args = [
            ...this.getJavaProcessJvmArgs(),
            '-jar',
            this.options.executable!,
            '--port',
            `${this.options.socketConnectionOptions.port}`
        ];

        if (this.useWebSocket()) {
            args.push('--websocket');
        }

        if (this.options.socketConnectionOptions.host) {
            args.push('--host', `${this.options.socketConnectionOptions.host}`);
        }

        if (this.options.additionalArgs) {
            args.push(...this.options.additionalArgs);
        }
        return this.spawnProcessAsync('java', args);
    }

    protected getJavaProcessJvmArgs(): string[] {
        return ['--add-opens', 'java.base/java.util=ALL-UNNAMED'];
    }

    protected launchNodeProcess(): Promise<RawProcess> {
        const args = [this.options.executable!, '--port', `${this.options.socketConnectionOptions.port}`];

        if (this.useWebSocket()) {
            args.push('--webSocket');
        }

        if (this.options.socketConnectionOptions.host) {
            args.push('--host', `${this.options.socketConnectionOptions.host}`);
        }

        if (this.options.additionalArgs) {
            args.push(...this.options.additionalArgs);
        }
        return this.spawnProcessAsync('node', args);
    }

    protected override processLogInfo(line: string): void {
        if (line.startsWith(START_UP_COMPLETE_MSG)) {
            const port = this.getPortFromStartupMessage(line);
            if (port) {
                if (this.options.socketConnectionOptions.port === 0) {
                    this.options.socketConnectionOptions.port = port;
                } else {
                    if (this.options.socketConnectionOptions.port !== port) {
                        throw new Error(
                            `The configured port ${this.options.socketConnectionOptions.port} does not match the port in the startup message: ${line}`
                        );
                    }
                }
            } else {
                throw new Error(`Could not find listening port in startup message of GLSP server: ${line}`);
            }
            this.onReadyDeferred.resolve();
        }
    }

    protected useWebSocket(): boolean {
        return 'path' in this.options.socketConnectionOptions && this.options.socketConnectionOptions.path !== undefined;
    }

    protected checkServerPort(): void {
        if (isNaN(this.options.socketConnectionOptions.port)) {
            throw new Error(
                `Could not connect to to GLSP Server. The given server port is not a number: ${this.options.socketConnectionOptions.port}`
            );
        }
    }

    protected async connectToSocketServer(clientChannel: Channel): Promise<Disposable> {
        const clientDisposable = new DisposableCollection();
        this.checkServerPort();
        const socket = new net.Socket();

        clientDisposable.push(this.forwardToSocketConnection(clientChannel, socket));
        if (clientChannel instanceof ForwardingChannel) {
            socket.on('error', error => clientChannel.onErrorEmitter.fire(error));
        }
        socket.connect(this.options.socketConnectionOptions);
        clientDisposable.push(Disposable.create(() => socket.destroy()));
        return clientDisposable;
    }

    protected forwardToSocketConnection(clientChannel: Channel, socket: net.Socket): Disposable {
        return new SocketConnectionForwarder(clientChannel, socket);
    }

    protected async connectToWebSocketServer(clientChannel: Channel, webSocketAddress: string | undefined): Promise<Disposable> {
        const clientDisposable = new DisposableCollection();
        this.checkServerPort();
        if (!webSocketAddress) {
            throw new Error('Could not connect to to GLSP Server. The WebSocket path cannot be empty');
        }

        const webSocket = new WebSocket(webSocketAddress);
        clientDisposable.push(Disposable.create(() => webSocket.close()));
        clientDisposable.push(this.forwardToWebSocketConnection(clientChannel, webSocket));
        if (clientChannel instanceof ForwardingChannel) {
            webSocket.onerror = error => clientChannel.onErrorEmitter.fire(error);
        }
        return clientDisposable;
    }

    protected forwardToWebSocketConnection(clientChannel: Channel, webSocket: WebSocket): Disposable {
        return new WebSocketConnectionForwarder(clientChannel, webSocket);
    }
}

/**
 * Utility function to parse a server port that is defined via command line arg.
 * @param argsKey Name/Key of the commandLine arg
 * @param defaultPort Default port that should be returned if no (valid) port was passed via CLI#
 * @returns the path value of the given argument if set, default value or `NaN` instead.
 */
export function getPort(argsKey: string, defaultPort?: number): number {
    argsKey = `--${argsKey.replace('--', '').replace('=', '')}=`;
    const args = process.argv.filter(a => a.startsWith(argsKey));
    if (args.length > 0) {
        return Number.parseInt(args[0].substring(argsKey.length), 10);
    }
    return defaultPort ?? NaN;
}

/**
 * Utility function to parse the WebSocket Server path if the GLSP Server should be started in WebSocket mode.#
 * @param argsKey Name/Key of the commandLine arg
 * @param defaultPath Default path that should be returned if no path was passed via CLI
 * @returns the path value of the given argument if set, default value or `undefined` instead.
 */
export function getWebSocketPath(argsKey: string, defaultPath?: string): string | undefined {
    argsKey = `--${argsKey.replace('--', '').replace('=', '')}=`;
    const args = process.argv.filter(a => a.startsWith(argsKey));
    if (args.length > 0) {
        return args[0].substring(argsKey.length);
    }
    return defaultPath;
}
