// Copyright 2016-2022, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import * as grpc from "@grpc/grpc-js";
import { isGrpcError, ResourceError, RunError } from "../errors";
import * as log from "../log";
import * as runtimeConfig from "../runtime/config";
import * as debuggable from "../runtime/debuggable";
import * as settings from "../runtime/settings";
import * as stack from "../runtime/stack";
import * as localState from "../runtime/state";

import * as langproto from "../proto/language_pb";
import * as plugproto from "../proto/plugin_pb";

/**
 * Raises the gRPC Max Message size from `4194304` (4mb) to `419430400` (400mb).
 *
 * @internal
 */
export const maxRPCMessageSize: number = 1024 * 1024 * 400;

/**
 * @internal
 */
export class LanguageServer<T> implements grpc.UntypedServiceImplementation {
    readonly program: () => Promise<T>;

    // Satisfy the grpc.UntypedServiceImplementation interface.
    [name: string]: any;

    constructor(program: () => Promise<T>) {
        this.program = program;
    }

    onPulumiExit(hasError: boolean) {
        // Check for leaks once the CLI exits but skip if the program otherwise
        // errored to keep error output clean
        if (!hasError) {
            const [leaks, leakMessage] = debuggable.leakedPromises();
            if (leaks.size !== 0) {
                throw new Error(leakMessage);
            }
        }
    }

    getRequiredPlugins(call: any, callback: any): void {
        const resp: any = new langproto.GetRequiredPluginsResponse();
        resp.setPluginsList([]);
        callback(undefined, resp);
    }

    run(call: any, callback: any): Promise<void> {
        const req: any = call.request;
        const resp: any = new langproto.RunResponse();

        // Setup a new async state store for this run
        const store = new localState.LocalStore();
        return localState.asyncLocalStorage.run(store, async () => {
            const errorSet = new Set<Error>();
            const uncaughtHandler = newUncaughtHandler(errorSet);
            try {
                const args = req.getArgsList();
                const engineAddr = args && args.length > 0 ? args[0] : "";

                settings.resetOptions(
                    req.getProject(),
                    req.getStack(),
                    req.getParallel(),
                    engineAddr,
                    req.getMonitorAddress(),
                    req.getDryrun(),
                    req.getOrganization(),
                );

                const config: { [key: string]: string } = {};
                for (const [k, v] of req.getConfigMap()?.entries() || []) {
                    config[<string>k] = <string>v;
                }
                runtimeConfig.setAllConfig(config, req.getConfigsecretkeysList() || []);

                process.setMaxListeners(settings.getMaximumListeners());

                process.on("uncaughtException", uncaughtHandler);
                // @ts-ignore 'unhandledRejection' will almost always invoke uncaughtHandler with an Error. so
                // just suppress the TS strictness here.
                process.on("unhandledRejection", uncaughtHandler);

                try {
                    await stack.runInPulumiStack(this.program);
                    await settings.disconnect();
                    process.off("uncaughtException", uncaughtHandler);
                    process.off("unhandledRejection", uncaughtHandler);
                } catch (e) {
                    await settings.disconnect();
                    process.off("uncaughtException", uncaughtHandler);
                    process.off("unhandledRejection", uncaughtHandler);

                    if (!isGrpcError(e)) {
                        throw e;
                    }
                }

                if (errorSet.size !== 0 || log.hasErrors()) {
                    let errorMessage: string = "";
                    if (errorSet.size !== 0) {
                        errorMessage = ": ";
                        errorSet.forEach((error) => {
                            errorMessage += `${error.message}, `;
                        });
                        errorMessage = errorMessage.slice(0, -2);
                    } else {
                        errorMessage = ". Check logs for more details";
                    }
                    throw new Error(`One or more errors occurred${errorMessage}`);
                }
            } catch (e) {
                const err = e instanceof Error ? e : new Error(`unknown error ${e}`);
                resp.setError(err.message);
                callback(err, undefined);
            }

            callback(undefined, resp);
        });
    }

    getPluginInfo(call: any, callback: any): void {
        const resp: any = new plugproto.PluginInfo();
        resp.setVersion("1.0.0");
        callback(undefined, resp);
    }
}

function newUncaughtHandler(errorSet: Set<Error>): (err: Error) => void {
    return (err: Error) => {
        // In node, if you throw an error in a chained promise, but the exception is not finally
        // handled, then you can end up getting an unhandledRejection for each exception/promise
        // pair.  Because the exception is the same through all of these, we keep track of it and
        // only report it once so the user doesn't get N messages for the same thing.
        if (errorSet.has(err)) {
            return;
        }

        errorSet.add(err);

        // Default message should be to include the full stack (which includes the message), or
        // fallback to just the message if we can't get the stack.
        //
        // If both the stack and message are empty, then just stringify the err object itself. This
        // is also necessary as users can throw arbitrary things in JS (including non-Errors).
        let defaultMessage = "";
        if (err) {
            defaultMessage = err.stack || err.message || "" + err;
        }

        // First, log the error.
        if (RunError.isInstance(err)) {
            // Always hide the stack for RunErrors.
            log.error(err.message);
        } else if (ResourceError.isInstance(err)) {
            // Hide the stack if requested to by the ResourceError creator.
            const message = err.hideStack ? err.message : defaultMessage;
            log.error(message, err.resource);
        } else if (!isGrpcError(err)) {
            log.error(`Unhandled exception: ${defaultMessage}`);
        }
    };
}