// Copyright 2016-2020, 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 runtime from "../runtime";

const langproto = require("../proto/language_pb.js");
const plugproto = require("../proto/plugin_pb.js");

// maxRPCMessageSize 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>;

    running: boolean;

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

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

        this.running = false;
    }

    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] = runtime.leakedPromises();
            if (leaks.size !== 0) {
                throw new Error(leakMessage);
            }
        }
        // these are globals and we need to clean up after ourselves
        runtime.resetOptions("", "", -1, "", "", false);
    }

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

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

        this.running = true;

        const errorSet = new Set<Error>();
        const uncaughtHandler = newUncaughtHandler(errorSet);
        try {
            const args = req.getArgsList();
            const engineAddr = args && args.length > 0 ? args[0] : "";

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

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

            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 runtime.runInPulumiStack(this.program);
                await runtime.disconnect();
                process.off("uncaughtException", uncaughtHandler);
                process.off("unhandledRejection", uncaughtHandler);
            } catch (e) {
                await runtime.disconnect();
                process.off("uncaughtException", uncaughtHandler);
                process.off("unhandledRejection", uncaughtHandler);

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

            if (errorSet.size !== 0 || log.hasErrors()) {
                throw new Error("One or more errors occurred");
            }

        } 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}`);
        }
    };
}
