// Copyright 2016-2018, 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 { deserializeProperties, serializeProperties } from "./rpc";
import { getProject, getStack, setMockOptions } from "./settings";
import { getStore } from "./state";

import * as structproto from "google-protobuf/google/protobuf/struct_pb";
import * as provproto from "../proto/provider_pb";
import * as resproto from "../proto/resource_pb";

/**
 * {@link MockResourceArgs} is used to construct a new resource mock.
 */
export interface MockResourceArgs {
    /**
     * The token that indicates which resource type is being constructed. This
     * token is of the form "package:module:type".
     */
    type: string;

    /**
     * The logical name of the resource instance.
     */
    name: string;

    /**
     * The inputs for the resource.
     */
    inputs: any;

    /**
     * If provided, the identifier of the provider instance being used to manage
     * this resource.
     */
    provider?: string;

    /**
     * Specifies whether or not the resource is Custom (i.e. managed by a
     * resource provider).
     */
    custom?: boolean;

    /**
     * If provided, the physical identifier of an existing resource to read or
     * import.
     */
    id?: string;
}

/**
 * {@link MockResourceResult} is the result of a new resource mock, returning a
 * physical identifier and the output properties for the resource being
 * constructed.
 */
export type MockResourceResult = {
    id: string | undefined;
    state: Record<string, any>;
};

/**
 * {@link MockResourceArgs} is used to construct call mocks.
 */
export interface MockCallArgs {
    /**
     * The token that indicates which function is being called. This token is of
     * the form "package:module:function".
     */
    token: string;

    /**
     * The arguments provided to the function call.
     */
    inputs: any;

    /**
     * If provided, the identifier of the provider instance being used to make
     * the call.
     */
    provider?: string;
}

/**
 * {@link MockCallResult} is the result of a call mock.
 */
export type MockCallResult = Record<string, any>;

/**
 * {@link Mocks} allows implementations to replace operations normally
 * implemented by the Pulumi engine with their own implementations. This can be
 * used during testing to ensure that calls to provider functions and resource
 * constructors return predictable values.
 */
export interface Mocks {
    /**
     * Mocks provider-implemented function calls (e.g. `aws.get_availability_zones`).
     *
     * @param args MockCallArgs
     */
    call(args: MockCallArgs): MockCallResult | Promise<MockCallResult>;

    /**
     * Mocks resource construction calls. This function should return the
     * physical identifier and the output properties for the resource being
     * constructed.
     *
     * @param args MockResourceArgs
     */
    newResource(args: MockResourceArgs): MockResourceResult | Promise<MockResourceResult>;
}

export class MockMonitor {
    readonly resources = new Map<string, { urn: string; id: string | null; state: any }>();

    constructor(readonly mocks: Mocks) {}

    private newUrn(parent: string, type: string, name: string): string {
        if (parent) {
            const qualifiedType = parent.split("::")[2];
            const parentType = qualifiedType.split("$").pop();
            type = parentType + "$" + type;
        }
        return "urn:pulumi:" + [getStack(), getProject(), type, name].join("::");
    }

    public async invoke(req: any, callback: (err: any, innerResponse: any) => void) {
        try {
            const tok = req.getTok();
            const inputs = deserializeProperties(req.getArgs());

            if (tok === "pulumi:pulumi:getResource") {
                const registeredResource = this.resources.get(inputs.urn);
                if (!registeredResource) {
                    throw new Error(`unknown resource ${inputs.urn}`);
                }
                const resp = new provproto.InvokeResponse();
                resp.setReturn(structproto.Struct.fromJavaScript(registeredResource));
                callback(null, resp);
                return;
            }

            const result: MockCallResult = await this.mocks.call({
                token: tok,
                inputs: inputs,
                provider: req.getProvider(),
            });
            const response = new provproto.InvokeResponse();
            response.setReturn(structproto.Struct.fromJavaScript(await serializeProperties("", result)));
            callback(null, response);
        } catch (err) {
            callback(err, undefined);
        }
    }

    public async readResource(req: any, callback: (err: any, innterResponse: any) => void) {
        try {
            const result: MockResourceResult = await this.mocks.newResource({
                type: req.getType(),
                name: req.getName(),
                inputs: deserializeProperties(req.getProperties()),
                provider: req.getProvider(),
                custom: true,
                id: req.getId(),
            });

            const urn = this.newUrn(req.getParent(), req.getType(), req.getName());
            const serializedState = await serializeProperties("", result.state);

            this.resources.set(urn, { urn, id: result.id ?? null, state: serializedState });

            const response = new resproto.ReadResourceResponse();
            response.setUrn(urn);
            response.setProperties(structproto.Struct.fromJavaScript(serializedState));
            callback(null, response);
        } catch (err) {
            callback(err, undefined);
        }
    }

    public async registerResource(req: any, callback: (err: any, innerResponse: any) => void) {
        try {
            const result: MockResourceResult = await this.mocks.newResource({
                type: req.getType(),
                name: req.getName(),
                inputs: deserializeProperties(req.getObject()),
                provider: req.getProvider(),
                custom: req.getCustom(),
                id: req.getImportid(),
            });

            const urn = this.newUrn(req.getParent(), req.getType(), req.getName());
            const serializedState = await serializeProperties("", result.state);

            this.resources.set(urn, { urn, id: result.id ?? null, state: serializedState });

            const response = new resproto.RegisterResourceResponse();
            response.setUrn(urn);
            response.setId(result.id || "");
            response.setObject(structproto.Struct.fromJavaScript(serializedState));
            callback(null, response);
        } catch (err) {
            callback(err, undefined);
        }
    }

    public registerResourceOutputs(req: any, callback: (err: any, innerResponse: any) => void) {
        try {
            const registeredResource = this.resources.get(req.getUrn());
            if (!registeredResource) {
                throw new Error(`unknown resource ${req.getUrn()}`);
            }
            registeredResource.state = req.getOutputs();

            callback(null, {});
        } catch (err) {
            callback(err, undefined);
        }
    }

    public supportsFeature(req: any, callback: (err: any, innerResponse: any) => void) {
        const id = req.getId();

        // Support for "outputValues" is deliberately disabled for the mock monitor so
        // instances of `Output` don't show up in `MockResourceArgs` inputs.
        const hasSupport = id !== "outputValues";

        callback(null, {
            getHassupport: () => hasSupport,
        });
    }
}

/**
 * Configures the Pulumi runtime to use the given mocks for testing.
 *
 * @param mocks
 *  The mocks to use for calls to provider functions and resource construction.
 * @param project
 *  If provided, the name of the Pulumi project. Defaults to "project".
 * @param stack
 *  If provided, the name of the Pulumi stack. Defaults to "stack".
 * @param preview
 *  If provided, indicates whether or not the program is running a preview. Defaults to false.
 * @param organization
 *  If provided, the name of the Pulumi organization. Defaults to nothing.
 */
export async function setMocks(
    mocks: Mocks,
    project?: string,
    stack?: string,
    preview?: boolean,
    organization?: string,
) {
    setMockOptions(new MockMonitor(mocks), project, stack, preview, organization);

    // Mocks enable all features except outputValues.
    const store = getStore();
    store.supportsSecrets = true;
    store.supportsResourceReferences = true;
    store.supportsOutputValues = false;
    store.supportsDeletedWith = true;
    store.supportsAliasSpecs = true;
    store.supportsTransforms = false;
}