// *****************************************************************************
// Copyright (C) 2017 Ericsson 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 { exec } from 'child_process';
import * as fs from 'fs';

import { inject, injectable, named } from '@theia/core/shared/inversify';
import { ILogger } from '@theia/core/lib/common/logger';
import { EnvironmentUtils } from '@theia/core/lib/node/environment-utils';
import { BaseTerminalServer } from './base-terminal-server';
import { ShellProcessFactory, getRootPath } from './shell-process';
import { ProcessManager, TerminalProcess } from '@theia/process/lib/node';
import { isWindows } from '@theia/core/lib/common/os';
import * as cp from 'child_process';
import {
    EnvironmentVariableCollectionWithPersistence, EnvironmentVariableMutatorType, NO_ROOT_URI, SerializableEnvironmentVariableCollection,
    IShellTerminalServer, IShellTerminalServerOptions
}
    from '../common/shell-terminal-protocol';
import { URI } from '@theia/core';
import { MultiKeyMap } from '@theia/core/lib/common/collections';
import { MarkdownString } from '@theia/core/lib/common/markdown-rendering/markdown-string';

interface SerializedExtensionEnvironmentVariableCollection {
    extensionIdentifier: string,
    rootUri: string,
    collection: SerializableEnvironmentVariableCollection,
}

interface WindowsProcess {
    ProcessId: number;
    ParentProcessId: number;
}

@injectable()
export class ShellTerminalServer extends BaseTerminalServer implements IShellTerminalServer {
    @inject(EnvironmentUtils) protected environmentUtils: EnvironmentUtils;

    readonly collections: MultiKeyMap<string, EnvironmentVariableCollectionWithPersistence> = new MultiKeyMap(2);

    constructor(
        @inject(ShellProcessFactory) protected readonly shellFactory: ShellProcessFactory,
        @inject(ProcessManager) processManager: ProcessManager,
        @inject(ILogger) @named('terminal') logger: ILogger) {
        super(processManager, logger);
    }

    async create(options: IShellTerminalServerOptions): Promise<number> {
        try {
            this.validateTerminalOptions(options);
            if (options.strictEnv !== true) {
                options.env = this.environmentUtils.mergeProcessEnv(options.env);
                this.applyToProcessEnvironment(URI.fromFilePath(getRootPath(options.rootURI)), options.env);
            }
            const term = this.shellFactory(options);
            this.postCreate(term);
            return term.id;
        } catch (error) {
            this.logger.error('Error while creating terminal', error);
            return -1;
        }
    }

    /**
     * Validates and sanitizes terminal options received from the client.
     * Invalid values are replaced with safe defaults rather than rejecting the request,
     * so legitimate users with misconfigured preferences still get a working terminal.
     */
    protected validateTerminalOptions(options: IShellTerminalServerOptions): void {
        // Validate shell: must be an existing, executable file. Fall back to default otherwise.
        if (options.shell !== undefined) {
            if (typeof options.shell !== 'string' || !this.isValidShell(options.shell)) {
                this.logger.warn(`Invalid shell "${options.shell}": falling back to default shell.`);
                options.shell = undefined;
            }
        }

        // Validate rootURI: must resolve to an existing directory.
        if (options.rootURI !== undefined) {
            try {
                const rootPath = getRootPath(options.rootURI);
                if (!fs.existsSync(rootPath) || !fs.statSync(rootPath).isDirectory()) {
                    this.logger.warn(`Invalid rootURI "${options.rootURI}": falling back to home directory.`);
                    options.rootURI = undefined;
                }
            } catch {
                this.logger.warn(`Invalid rootURI "${options.rootURI}": falling back to home directory.`);
                options.rootURI = undefined;
            }
        }

        // Validate cols/rows: must be positive integers within reasonable bounds.
        if (options.cols !== undefined) {
            if (typeof options.cols !== 'number' || !Number.isInteger(options.cols) || options.cols < 1 || options.cols > 500) {
                options.cols = undefined;
            }
        }
        if (options.rows !== undefined) {
            if (typeof options.rows !== 'number' || !Number.isInteger(options.rows) || options.rows < 1 || options.rows > 500) {
                options.rows = undefined;
            }
        }

        // Validate args: must be a string array or a single string.
        if (options.args !== undefined) {
            if (typeof options.args === 'string') {
                // single string is fine
            } else if (Array.isArray(options.args)) {
                if (!options.args.every(arg => typeof arg === 'string')) {
                    options.args = undefined;
                }
            } else {
                options.args = undefined;
            }
        }
    }

    protected isValidShell(shell: string): boolean {
        try {
            // On Windows, cmd.exe and powershell.exe are resolved from PATH,
            // so we only check accessibility for absolute paths.
            if (isWindows && !/[/\\]/.test(shell)) {
                return true;
            }
            fs.accessSync(shell, fs.constants.X_OK);
            return true;
        } catch {
            return false;
        }
    }

    // copied and modified from https://github.com/microsoft/vscode/blob/4636be2b71c87bfb0bfe3c94278b447a5efcc1f1/src/vs/workbench/contrib/debug/node/terminals.ts#L32-L75
    private spawnAsPromised(command: string, args: string[]): Promise<string> {
        return new Promise((resolve, reject) => {
            let stdout = '';
            const child = cp.spawn(command, args, {
                shell: true
            });
            if (child.pid) {
                child.stdout.on('data', (data: Buffer) => {
                    stdout += data.toString();
                });
            }
            child.on('error', err => {
                reject(err);
            });
            child.on('close', code => {
                resolve(stdout);
            });
        });
    }

    public hasChildProcesses(processId: number | undefined): Promise<boolean> {
        if (processId) {
            // if shell has at least one child process, assume that shell is busy
            if (isWindows) {
                return new Promise(resolve => {
                    exec(
                        'powershell -Command "Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId | ConvertTo-Json"',
                        (error, stdout) => {
                            if (error) {
                                this.logger.error(`Failed to get Windows process list: ${error}`);
                                return resolve(true); // assume busy on error
                            }

                            try {
                                const processes: WindowsProcess[] = JSON.parse(stdout);
                                const hasChild = processes.some(proc => proc.ParentProcessId === processId);
                                return resolve(hasChild);
                            } catch (parseError) {
                                this.logger.error(`Failed to parse process list JSON: ${parseError}`);
                                return resolve(true); // assume busy on parse error
                            }
                        },
                    );
                });
            } else {
                return this.spawnAsPromised('/usr/bin/pgrep', ['-lP', String(processId)]).then(stdout => {
                    const r = stdout.trim();
                    if (r.length === 0 || r.indexOf(' tmux') >= 0) { // ignore 'tmux';
                        return false;
                    } else {
                        return true;
                    }
                }, error => true);
            }
        }
        // fall back to safe side
        return Promise.resolve(true);
    }

    applyToProcessEnvironment(cwdUri: URI, env: { [key: string]: string | null }): void {
        let lowerToActualVariableNames: {
            [lowerKey: string]: string | undefined
        } | undefined;
        if (isWindows) {
            lowerToActualVariableNames = {};
            Object.keys(env).forEach(e => lowerToActualVariableNames![e.toLowerCase()] = e);
        }
        this.collections.forEach((mutators, [extensionIdentifier, rootUri]) => {
            if (rootUri === NO_ROOT_URI || this.matchesRootUri(cwdUri, rootUri)) {
                mutators.variableMutators.forEach((mutator, variable) => {
                    const actualVariable = isWindows ? lowerToActualVariableNames![variable.toLowerCase()] || variable : variable;
                    switch (mutator.type) {
                        case EnvironmentVariableMutatorType.Append:
                            env[actualVariable] = (env[actualVariable] || '') + mutator.value;
                            break;
                        case EnvironmentVariableMutatorType.Prepend:
                            env[actualVariable] = mutator.value + (env[actualVariable] || '');
                            break;
                        case EnvironmentVariableMutatorType.Replace:
                            env[actualVariable] = mutator.value;
                            break;
                    }
                });
            }
        });
    }

    matchesRootUri(cwdUri: URI, rootUri: string): boolean {
        return new URI(rootUri).isEqualOrParent(cwdUri);
    }

    /*---------------------------------------------------------------------------------------------
  *  Copyright (c) Microsoft Corporation. All rights reserved.
  *  Licensed under the MIT License. See License.txt in the project root for license information.
  *--------------------------------------------------------------------------------------------*/
    // some code copied and modified from https://github.com/microsoft/vscode/blob/1.49.0/src/vs/workbench/contrib/terminal/common/environmentVariableService.ts

    setCollection(extensionIdentifier: string, baseUri: string, persistent: boolean,
        collection: SerializableEnvironmentVariableCollection): void {
        this.doSetCollection(extensionIdentifier, baseUri, persistent, collection);
        this.updateCollections();
    }

    private doSetCollection(extensionIdentifier: string, baseUri: string, persistent: boolean,
        collection: SerializableEnvironmentVariableCollection): void {
        this.collections.set([extensionIdentifier, baseUri], {
            persistent: persistent,
            description: collection.description,
            variableMutators: new Map(collection.mutators)
        });
    }

    restorePersisted(jsonValue: string): void {
        const collectionsJson: SerializedExtensionEnvironmentVariableCollection[] = JSON.parse(jsonValue);
        collectionsJson.forEach(c => this.doSetCollection(c.extensionIdentifier, c.rootUri ?? NO_ROOT_URI, true, c.collection));

    }

    deleteCollection(extensionIdentifier: string): void {
        this.collections.delete([extensionIdentifier]);
        this.updateCollections();
    }

    private updateCollections(): void {
        this.persistCollections();
    }

    protected persistCollections(): void {
        const collectionsJson: SerializedExtensionEnvironmentVariableCollection[] = [];
        this.collections.forEach((collection, [extensionIdentifier, rootUri]) => {
            if (collection.persistent) {
                collectionsJson.push({
                    extensionIdentifier,
                    rootUri,
                    collection: {
                        description: collection.description,
                        mutators: [...this.collections.get([extensionIdentifier, rootUri])!.variableMutators.entries()]
                    },
                });
            }
        });
        if (this.client) {
            const stringifiedJson = JSON.stringify(collectionsJson);
            this.client.storeTerminalEnvVariables(stringifiedJson);
        }
    }

    async getEnvVarCollectionDescriptionsByExtension(id: number): Promise<Map<string, (string | MarkdownString | undefined)[]>> {
        const terminal = this.processManager.get(id);
        if (!(terminal instanceof TerminalProcess)) {
            throw new Error(`terminal "${id}" does not exist`);
        }
        const result = new Map<string, (string | MarkdownString | undefined)[]>();
        this.collections.forEach((value, key) => {
            const prev = result.get(key[0]) || [];
            prev.push(value.description);
            result.set(key[0], prev);
        });
        return result;
    }

    async getEnvVarCollections(): Promise<[string, string, boolean, SerializableEnvironmentVariableCollection][]> {
        const result: [string, string, boolean, SerializableEnvironmentVariableCollection][] = [];

        this.collections.forEach((value, [extensionIdentifier, rootUri]) => {
            result.push([extensionIdentifier, rootUri, value.persistent, { description: value.description, mutators: [...value.variableMutators.entries()] }]);
        });

        return result;
    }
}
