// *****************************************************************************
// Copyright (C) 2018 Red Hat, Inc. 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-only WITH Classpath-exception-2.0
// *****************************************************************************

import { RequestOptions, RequestService } from '@theia/core/shared/@theia/request';
import { inject, injectable, named } from '@theia/core/shared/inversify';
import * as cp from 'child_process';
import * as fs from '@theia/core/shared/fs-extra';
import * as net from 'net';
import * as path from 'path';
import URI from '@theia/core/lib/common/uri';
import { ContributionProvider } from '@theia/core/lib/common/contribution-provider';
import { HostedPluginUriPostProcessor, HostedPluginUriPostProcessorSymbolName } from './hosted-plugin-uri-postprocessor';
import { environment, isWindows } from '@theia/core';
import { FileUri } from '@theia/core/lib/common/file-uri';
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
import { LogType } from '@theia/plugin-ext/lib/common/types';
import { HostedPluginSupport } from '@theia/plugin-ext/lib/hosted/node/hosted-plugin';
import { MetadataScanner } from '@theia/plugin-ext/lib/hosted/node/metadata-scanner';
import { PluginDebugConfiguration } from '../common/plugin-dev-protocol';
import { HostedPluginProcess } from '@theia/plugin-ext/lib/hosted/node/hosted-plugin-process';
import { isENOENT } from '@theia/plugin-ext/lib/common/errors';

const DEFAULT_HOSTED_PLUGIN_PORT = 3030;

export const HostedInstanceManager = Symbol('HostedInstanceManager');

/**
 * Is responsible for running and handling separate Theia instance with given plugin.
 */
export interface HostedInstanceManager {
    /**
     * Checks whether hosted instance is run.
     */
    isRunning(): boolean;

    /**
     * Runs specified by the given uri plugin in separate Theia instance.
     *
     * @param pluginUri uri to the plugin source location
     * @param port port on which new instance of Theia should be run. Optional.
     * @returns uri where new Theia instance is run
     */
    run(pluginUri: URI, port?: number): Promise<URI>;

    /**
     * Runs specified by the given uri plugin  with debug in separate Theia instance.
     * @param pluginUri uri to the plugin source location
     * @param debugConfig debug configuration
     * @returns uri where new Theia instance is run
     */
    debug(pluginUri: URI, debugConfig: PluginDebugConfiguration): Promise<URI>;

    /**
     * Terminates hosted plugin instance.
     * Throws error if instance is not running.
     */
    terminate(): void;

    /**
     * Returns uri where hosted instance is run.
     * Throws error if instance is not running.
     */
    getInstanceURI(): URI;

    /**
     * Returns uri where plugin loaded into hosted instance is located.
     * Throws error if instance is not running.
     */
    getPluginURI(): URI;

    /**
     * Checks whether given uri points to a valid plugin.
     *
     * @param uri uri to the plugin source location
     */
    isPluginValid(uri: URI): Promise<boolean>;
}

const HOSTED_INSTANCE_START_TIMEOUT_MS = 30000;
const THEIA_INSTANCE_REGEX = /.*Theia app listening on (.*).*\./;
const PROCESS_OPTIONS = {
    cwd: process.cwd(),
};

@injectable()
export abstract class AbstractHostedInstanceManager implements HostedInstanceManager {
    protected hostedInstanceProcess: cp.ChildProcess;
    protected isPluginRunning: boolean = false;
    protected instanceUri: URI;
    protected pluginUri: URI;
    protected instanceOptions: Omit<RequestOptions, 'url'>;

    @inject(HostedPluginSupport)
    protected readonly hostedPluginSupport: HostedPluginSupport;

    @inject(MetadataScanner)
    protected readonly metadata: MetadataScanner;

    @inject(HostedPluginProcess)
    protected readonly hostedPluginProcess: HostedPluginProcess;

    @inject(RequestService)
    protected readonly request: RequestService;

    @inject(EnvVariablesServer)
    protected readonly envVariablesServer: EnvVariablesServer;

    isRunning(): boolean {
        return this.isPluginRunning;
    }

    async run(pluginUri: URI, port?: number): Promise<URI> {
        return this.doRun(pluginUri, port);
    }

    async debug(pluginUri: URI, debugConfig: PluginDebugConfiguration): Promise<URI> {
        return this.doRun(pluginUri, undefined, debugConfig);
    }

    private async doRun(pluginUri: URI, port?: number, debugConfig?: PluginDebugConfiguration): Promise<URI> {
        if (this.isPluginRunning) {
            this.hostedPluginSupport.sendLog({ data: 'Hosted plugin instance is already running.', type: LogType.Info });
            throw new Error('Hosted instance is already running.');
        }

        let command: string[];
        let processOptions: cp.SpawnOptions;
        if (pluginUri.scheme === 'file') {
            processOptions = { ...PROCESS_OPTIONS, env: { ...process.env } };
            // get filesystem path that work cross operating systems
            processOptions.env!.HOSTED_PLUGIN = FileUri.fsPath(pluginUri.toString());

            // Use an isolated config directory for the hosted instance so that its
            // workspace state (recentworkspace.json, etc.) does not overwrite the
            // main application's state.
            const configDirUri = await this.envVariablesServer.getConfigDirUri();
            const configDirPath = FileUri.fsPath(configDirUri);
            const hostedConfigDir = path.join(configDirPath, 'hosted-instance');
            await fs.ensureDir(hostedConfigDir);
            processOptions.env!.THEIA_CONFIG_DIR = hostedConfigDir;

            if (environment.electron.is()) {
                // When running in Electron, the backend process uses the Electron binary as its
                // Node.js runtime. Set ELECTRON_RUN_AS_NODE so the spawned backend instance runs
                // as a plain Node.js process rather than launching a new Electron app window.
                processOptions.env!.ELECTRON_RUN_AS_NODE = '1';
            }
            command = await this.getStartCommand(port, debugConfig);
        } else {
            throw new Error('Not supported plugin location: ' + pluginUri.toString());
        }

        this.instanceUri = await this.postProcessInstanceUri(await this.runHostedPluginTheiaInstance(command, processOptions));
        this.pluginUri = pluginUri;
        // disable redirect to grab the release
        this.instanceOptions = {
            followRedirects: 0
        };
        this.instanceOptions = await this.postProcessInstanceOptions(this.instanceOptions);
        await this.checkInstanceUriReady();

        return this.instanceUri;
    }

    terminate(): void {
        if (this.isPluginRunning && !!this.hostedInstanceProcess.pid) {
            this.hostedPluginProcess.killProcessTree(this.hostedInstanceProcess.pid);
            this.hostedPluginSupport.sendLog({ data: 'Hosted instance has been terminated', type: LogType.Info });
            this.isPluginRunning = false;
        } else {
            throw new Error('Hosted plugin instance is not running.');
        }
    }

    getInstanceURI(): URI {
        if (this.isPluginRunning) {
            return this.instanceUri;
        }
        throw new Error('Hosted plugin instance is not running.');
    }

    getPluginURI(): URI {
        if (this.isPluginRunning) {
            return this.pluginUri;
        }
        throw new Error('Hosted plugin instance is not running.');
    }

    /**
     * Checks that the `instanceUri` is responding before exiting method
     */
    public async checkInstanceUriReady(): Promise<void> {
        return new Promise<void>((resolve, reject) => this.pingLoop(60, resolve, reject));
    }

    /**
     * Start a loop to ping, if ping is OK return immediately, else start a new ping after 1second. We iterate for the given amount of loops provided in remainingCount
     * @param remainingCount the number of occurrence to check
     * @param resolve resolve function if ok
     * @param reject reject function if error
     */
    private async pingLoop(remainingCount: number,
        resolve: (value?: void | PromiseLike<void> | undefined | Error) => void,
        reject: (value?: void | PromiseLike<void> | undefined | Error) => void): Promise<void> {
        const isOK = await this.ping();
        if (isOK) {
            resolve();
        } else {
            if (remainingCount > 0) {
                setTimeout(() => this.pingLoop(--remainingCount, resolve, reject), 1000);
            } else {
                reject(new Error('Unable to ping the remote server'));
            }
        }
    }

    /**
     * Ping the plugin URI (checking status of the head)
     */
    private async ping(): Promise<boolean> {
        try {
            const url = this.instanceUri.toString();
            // Wait that the status is OK
            const response = await this.request.request({ url, type: 'HEAD', ...this.instanceOptions });
            return response.res.statusCode === 200;
        } catch {
            return false;
        }
    }

    async isPluginValid(uri: URI): Promise<boolean> {
        const pckPath = path.join(FileUri.fsPath(uri), 'package.json');
        try {
            const pck = await fs.readJSON(pckPath);
            this.metadata.getScanner(pck);
            return true;
        } catch (err) {
            if (!isENOENT(err)) {
                console.error(err);
            }
            return false;
        }
    }

    protected async getStartCommand(port?: number, debugConfig?: PluginDebugConfiguration): Promise<string[]> {

        const processArguments = process.argv;
        const command = processArguments.filter((arg, index, args) => {
            // remove --port=X and --port X arguments if set
            if (arg.startsWith('--port') || args[index - 1] === '--port') {
                return false;
            }
            return true;
        });
        if (process.env.HOSTED_PLUGIN_HOSTNAME) {
            command.push('--hostname=' + process.env.HOSTED_PLUGIN_HOSTNAME);
        }
        if (port) {
            await this.validatePort(port);
            command.push('--port=' + port);
        }

        if (debugConfig) {
            if (debugConfig.debugPort === undefined) {
                command.push(`--hosted-plugin-${debugConfig.debugMode || 'inspect'}=0.0.0.0`);
            } else if (typeof debugConfig.debugPort === 'string') {
                command.push(`--hosted-plugin-${debugConfig.debugMode || 'inspect'}=0.0.0.0:${debugConfig.debugPort}`);
            } else if (Array.isArray(debugConfig.debugPort)) {
                if (debugConfig.debugPort.length === 0) {
                    // treat empty array just like undefined
                    command.push(`--hosted-plugin-${debugConfig.debugMode || 'inspect'}=0.0.0.0`);
                } else {
                    for (const serverToPort of debugConfig.debugPort) {
                        command.push(`--${serverToPort.serverName}-${debugConfig.debugMode || 'inspect'}=0.0.0.0:${serverToPort.debugPort}`);
                    }
                }
            }
        }
        return command;
    }

    protected async postProcessInstanceUri(uri: URI): Promise<URI> {
        return uri;
    }

    protected async postProcessInstanceOptions(options: Omit<RequestOptions, 'url'>): Promise<Omit<RequestOptions, 'url'>> {
        return options;
    }

    protected runHostedPluginTheiaInstance(command: string[], options: cp.SpawnOptions): Promise<URI> {
        this.isPluginRunning = true;
        return new Promise((resolve, reject) => {
            let started = false;
            const outputListener = (data: string | Buffer) => {
                const line = data.toString();
                const match = THEIA_INSTANCE_REGEX.exec(line);
                if (match) {
                    this.hostedInstanceProcess.stdout!.removeListener('data', outputListener);
                    started = true;
                    resolve(new URI(match[1]));
                }
            };

            if (isWindows) {
                // Has to be set for running on windows (electron).
                // See also: https://github.com/nodejs/node/issues/3675
                options.shell = true;
            }

            this.hostedInstanceProcess = cp.spawn(command.shift()!, command, options);
            this.hostedInstanceProcess.on('error', () => { this.isPluginRunning = false; });
            this.hostedInstanceProcess.on('exit', () => { this.isPluginRunning = false; });
            this.hostedInstanceProcess.stdout!.addListener('data', outputListener);

            this.hostedInstanceProcess.stdout!.addListener('data', data => {
                this.hostedPluginSupport.sendLog({ data: data.toString(), type: LogType.Info });
            });
            this.hostedInstanceProcess.stderr!.addListener('data', data => {
                this.hostedPluginSupport.sendLog({ data: data.toString(), type: LogType.Error });
            });

            setTimeout(() => {
                if (!started) {
                    this.terminate();
                    this.isPluginRunning = false;
                    reject(new Error('Timeout.'));
                }
            }, HOSTED_INSTANCE_START_TIMEOUT_MS);
        });
    }

    protected async validatePort(port: number): Promise<void> {
        if (port < 1 || port > 65535) {
            throw new Error('Port value is incorrect.');
        }

        if (! await this.isPortFree(port)) {
            throw new Error('Port ' + port + ' is already in use.');
        }
    }

    protected isPortFree(port: number): Promise<boolean> {
        return new Promise(resolve => {
            const server = net.createServer();
            server.listen(port, '0.0.0.0');
            server.on('error', () => {
                resolve(false);
            });
            server.on('listening', () => {
                server.close();
                resolve(true);
            });
        });
    }

}

@injectable()
export class NodeHostedPluginRunner extends AbstractHostedInstanceManager {
    @inject(ContributionProvider) @named(Symbol.for(HostedPluginUriPostProcessorSymbolName))
    protected readonly uriPostProcessors: ContributionProvider<HostedPluginUriPostProcessor>;

    protected override async postProcessInstanceUri(uri: URI): Promise<URI> {
        for (const uriPostProcessor of this.uriPostProcessors.getContributions()) {
            uri = await uriPostProcessor.processUri(uri);
        }
        return uri;
    }

    protected override async postProcessInstanceOptions(options: object): Promise<object> {
        for (const uriPostProcessor of this.uriPostProcessors.getContributions()) {
            options = await uriPostProcessor.processOptions(options);
        }
        return options;
    }

    protected override async getStartCommand(port?: number, debugConfig?: PluginDebugConfiguration): Promise<string[]> {
        if (!port) {
            port = process.env.HOSTED_PLUGIN_PORT ?
                Number(process.env.HOSTED_PLUGIN_PORT) :
                (debugConfig?.debugPort ? Number(debugConfig.debugPort) : DEFAULT_HOSTED_PLUGIN_PORT);
        }
        return super.getStartCommand(port, debugConfig);
    }
}

@injectable()
export class ElectronNodeHostedPluginRunner extends AbstractHostedInstanceManager {

}
