// Copyright 2016-2021, 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 assert from "assert";
import {
    ComponentResource,
    CustomResource,
    DependencyResource,
    Inputs,
    Output,
    Resource,
    ResourceOptions,
    runtime,
    secret,
} from "../../index";
import * as state from "../../runtime/state";

import * as gstruct from "google-protobuf/google/protobuf/struct_pb";

class TestComponentResource extends ComponentResource {
    constructor(name: string, opts?: ResourceOptions) {
        super("test:index:component", name, {}, opts);

        super.registerOutputs({});
    }
}

class TestCustomResource extends CustomResource {
    constructor(name: string, type?: string, opts?: ResourceOptions) {
        super(type || "test:index:custom", name, {}, opts);
    }
}

class TestErrorResource extends CustomResource {
    constructor(name: string) {
        super("error", name, {});
    }
}

class TestResourceModule implements runtime.ResourceModule {
    construct(name: string, type: string, urn: string): Resource {
        switch (type) {
            case "test:index:component":
                return new TestComponentResource(name, { urn });
            case "test:index:custom":
                return new TestCustomResource(name, type, { urn });
            default:
                throw new Error(`unknown resource type ${type}`);
        }
    }
}

class TestMocks implements runtime.Mocks {
    call(args: runtime.MockCallArgs): Record<string, any> {
        throw new Error(`unknown function ${args.token}`);
    }

    newResource(args: runtime.MockResourceArgs): { id: string | undefined; state: Record<string, any> } {
        switch (args.type) {
            case "test:index:component":
                return { id: undefined, state: {} };
            case "test:index:custom":
            case "test2:index:custom":
                return {
                    id: runtime.isDryRun() ? undefined : "test-id",
                    state: {},
                };
            case "error":
                throw new Error("this is an intentional error");
            default:
                throw new Error(`unknown resource type ${args.type}`);
        }
    }
}

// eslint-disable-next-line @typescript-eslint/naming-convention,no-underscore-dangle,id-blacklist,id-match
const TestStrEnum = {
    Foo: "foo",
    Bar: "bar",
} as const;

// eslint-disable-next-line @typescript-eslint/no-redeclare
type TestStrEnum = typeof TestStrEnum[keyof typeof TestStrEnum];

// eslint-disable-next-line @typescript-eslint/naming-convention,no-underscore-dangle,id-blacklist,id-match
const TestIntEnum = {
    One: 1,
    Zero: 0,
} as const;

// eslint-disable-next-line @typescript-eslint/no-redeclare
type TestIntEnum = typeof TestIntEnum[keyof typeof TestIntEnum];

// eslint-disable-next-line @typescript-eslint/naming-convention,no-underscore-dangle,id-blacklist,id-match
const TestNumEnum = {
    One: 1.0,
    ZeroPointOne: 0.1,
} as const;

// eslint-disable-next-line @typescript-eslint/no-redeclare
type TestNumEnum = typeof TestNumEnum[keyof typeof TestNumEnum];

// eslint-disable-next-line @typescript-eslint/naming-convention,no-underscore-dangle,id-blacklist,id-match
const TestBoolEnum = {
    One: true,
    Zero: false,
} as const;

// eslint-disable-next-line @typescript-eslint/no-redeclare
type TestBoolEnum = typeof TestBoolEnum[keyof typeof TestBoolEnum];

interface TestInputs {
    aNum: number;
    bStr: string;
    cUnd: undefined;
    dArr: Promise<Array<any>>;
    id: string;
    urn: string;
    strEnum: TestStrEnum;
    intEnum: TestIntEnum;
    numEnum: TestNumEnum;
    boolEnum: TestBoolEnum;
}

describe("runtime", () => {
    beforeEach(() => {
        runtime._reset();
        runtime._resetResourcePackages();
        runtime._resetResourceModules();
    });

    describe("transferProperties", () => {
        describe("output values", () => {
            function* generateTests() {
                const testValues = [
                    { value: undefined, expected: null },
                    { value: null, expected: null },
                    { value: 0, expected: 0 },
                    { value: 1, expected: 1 },
                    { value: "", expected: "" },
                    { value: "hi", expected: "hi" },
                    { value: {}, expected: {} },
                    { value: [], expected: [] },
                ];
                for (const tv of testValues) {
                    for (const deps of [[], ["fakeURN1", "fakeURN2"]]) {
                        for (const isKnown of [true, false]) {
                            for (const isSecret of [true, false]) {
                                const resources = deps.map((dep) => new DependencyResource(dep));
                                yield {
                                    name:
                                        `Output(${JSON.stringify(deps)}, ${JSON.stringify(tv.value)}, ` +
                                        `isKnown=${isKnown}, isSecret=${isSecret})`,
                                    input: new Output(
                                        resources,
                                        Promise.resolve(tv.value),
                                        Promise.resolve(isKnown),
                                        Promise.resolve(isSecret),
                                        Promise.resolve([]),
                                    ),
                                    expected: {
                                        [runtime.specialSigKey]: runtime.specialOutputValueSig,
                                        ...(isKnown && { value: tv.expected }),
                                        ...(isSecret && { secret: isSecret }),
                                        ...(deps.length > 0 && { dependencies: deps }),
                                    },
                                    expectedRoundTrip: new Output(
                                        resources,
                                        Promise.resolve(isKnown ? tv.expected : undefined),
                                        Promise.resolve(isKnown),
                                        Promise.resolve(isSecret),
                                        Promise.resolve([]),
                                    ),
                                };
                            }
                        }
                    }
                }
            }

            async function assertOutputsEqual<T>(a: Output<T>, e: Output<T>) {
                async function urns(res: Set<Resource>): Promise<Set<string>> {
                    const result = new Set<string>();
                    for (const r of res) {
                        result.add(await r.urn.promise());
                    }
                    return result;
                }

                assert.deepStrictEqual(await urns(a.resources()), await urns(e.resources()));
                assert.deepStrictEqual(await a.isKnown, await e.isKnown);
                assert.deepStrictEqual(await a.promise(), await e.promise());
                assert.deepStrictEqual(await a.isSecret, await e.isSecret);
                assert.deepStrictEqual(await urns(await a.allResources!()), await urns(await e.allResources!()));
            }

            for (const test of generateTests()) {
                it(`marshals ${test.name} correctly`, async () => {
                    state.getStore().supportsOutputValues = true;

                    const inputs = { value: test.input };
                    const expected = { value: test.expected };

                    const actual = await runtime.serializeProperties("test", inputs, { keepOutputValues: true });
                    assert.deepStrictEqual(actual, expected);

                    // Roundtrip.
                    const back = runtime.deserializeProperties(gstruct.Struct.fromJavaScript(actual));
                    await assertOutputsEqual(back.value, test.expectedRoundTrip);
                });
            }
        });

        it("marshals basic properties correctly", async () => {
            const inputs: TestInputs = {
                aNum: 42,
                bStr: "a string",
                cUnd: undefined,
                dArr: Promise.resolve(["x", 42, Promise.resolve(true), Promise.resolve(undefined)]),
                id: "foo",
                urn: "bar",
                strEnum: TestStrEnum.Foo,
                intEnum: TestIntEnum.One,
                numEnum: TestNumEnum.One,
                boolEnum: TestBoolEnum.One,
            };
            // Serialize and then deserialize all the properties, checking that they round-trip as expected.
            const transfer = gstruct.Struct.fromJavaScript(await runtime.serializeProperties("test", inputs));
            const result = runtime.deserializeProperties(transfer);
            assert.strictEqual(result.aNum, 42);
            assert.strictEqual(result.bStr, "a string");
            assert.strictEqual(result.cUnd, undefined);
            assert.deepStrictEqual(result.dArr, ["x", 42, true, null]);
            assert.strictEqual(result.id, "foo");
            assert.strictEqual(result.urn, "bar");
            assert.strictEqual(result.strEnum, TestStrEnum.Foo);
            assert.strictEqual(result.intEnum, TestIntEnum.One);
            assert.strictEqual(result.numEnum, TestNumEnum.One);
            assert.strictEqual(result.boolEnum, TestBoolEnum.One);
        });
        it("marshals secrets correctly", async () => {
            const inputs: Inputs = {
                secret1: secret(1),
                secret2: secret(undefined),
            };

            // Serialize and then deserialize all the properties, checking that they round-trip as expected.
            state.getStore().supportsSecrets = true;
            let transfer = gstruct.Struct.fromJavaScript(await runtime.serializeProperties("test", inputs));
            let result = runtime.deserializeProperties(transfer);
            assert.ok(runtime.isRpcSecret(result.secret1));
            assert.ok(runtime.isRpcSecret(result.secret2));
            assert.strictEqual(runtime.unwrapRpcSecret(result.secret1), 1);
            assert.strictEqual(runtime.unwrapRpcSecret(result.secret2), null);

            // Serialize and then deserialize all the properties, checking that they round-trip as expected.
            state.getStore().supportsSecrets = false;
            transfer = gstruct.Struct.fromJavaScript(await runtime.serializeProperties("test", inputs));
            result = runtime.deserializeProperties(transfer);
            assert.ok(!runtime.isRpcSecret(result.secret1));
            assert.ok(!runtime.isRpcSecret(result.secret2));
            assert.strictEqual(result.secret1, 1);
            assert.strictEqual(result.secret2, undefined);
        });
        it("marshals resource references correctly during preview", async () => {
            runtime._setIsDryRun(true);
            runtime.setMocks(new TestMocks());

            const component = new TestComponentResource("test");
            const custom = new TestCustomResource("test");

            const componentURN = await component.urn.promise();
            const customURN = await custom.urn.promise();
            const customID = await custom.id.promise();

            const inputs: Inputs = {
                component: component,
                custom: custom,
            };

            state.getStore().supportsResourceReferences = true;

            let serialized = await runtime.serializeProperties("test", inputs);
            assert.deepEqual(serialized, {
                component: {
                    [runtime.specialSigKey]: runtime.specialResourceSig,
                    urn: componentURN,
                },
                custom: {
                    [runtime.specialSigKey]: runtime.specialResourceSig,
                    urn: customURN,
                    id: customID,
                },
            });

            state.getStore().supportsResourceReferences = false;
            serialized = await runtime.serializeProperties("test", inputs);
            assert.deepEqual(serialized, {
                component: componentURN,
                custom: customID ? customID : runtime.unknownValue,
            });
        });

        it("marshals resource references correctly during update", async () => {
            runtime.setMocks(new TestMocks());

            const component = new TestComponentResource("test");
            const custom = new TestCustomResource("test");

            const componentURN = await component.urn.promise();
            const customURN = await custom.urn.promise();
            const customID = await custom.id.promise();

            const inputs: Inputs = {
                component: component,
                custom: custom,
            };

            state.getStore().supportsResourceReferences = true;

            let serialized = await runtime.serializeProperties("test", inputs);
            assert.deepEqual(serialized, {
                component: {
                    [runtime.specialSigKey]: runtime.specialResourceSig,
                    urn: componentURN,
                },
                custom: {
                    [runtime.specialSigKey]: runtime.specialResourceSig,
                    urn: customURN,
                    id: customID,
                },
            });

            state.getStore().supportsResourceReferences = false;
            serialized = await runtime.serializeProperties("test", inputs);
            assert.deepEqual(serialized, {
                component: componentURN,
                custom: customID,
            });
        });
    });

    describe("deserializeProperty", () => {
        it("fails on unsupported secret values", () => {
            assert.throws(() =>
                runtime.deserializeProperty({
                    [runtime.specialSigKey]: runtime.specialSecretSig,
                }),
            );
        });
        it("fails on unknown signature keys", () => {
            assert.throws(() =>
                runtime.deserializeProperty({
                    [runtime.specialSigKey]: "foobar",
                }),
            );
        });
        it("pushed secretness up correctly", () => {
            const secretValue = {
                [runtime.specialSigKey]: runtime.specialSecretSig,
                value: "a secret value",
            };

            const props = gstruct.Struct.fromJavaScript({
                regular: "a normal value",
                list: ["a normal value", "another value", secretValue],
                map: { regular: "a normal value", secret: secretValue },
                mapWithList: {
                    regular: "a normal value",
                    list: ["a normal value", secretValue],
                },
                listWithMap: [
                    {
                        regular: "a normal value",
                        secret: secretValue,
                    },
                ],
            });

            const result = runtime.deserializeProperties(props);

            // Regular had no secrets in it, so it is returned as is.
            assert.strictEqual(result.regular, "a normal value");

            // One of the elements in the list was a secret, so the secretness is promoted to top level.
            assert.strictEqual(result.list[runtime.specialSigKey], runtime.specialSecretSig);
            assert.strictEqual(result.list.value[0], "a normal value");
            assert.strictEqual(result.list.value[1], "another value");
            assert.strictEqual(result.list.value[2], "a secret value");

            // One of the values of the map was a secret, so the secretness is promoted to top level.
            assert.strictEqual(result.map[runtime.specialSigKey], runtime.specialSecretSig);
            assert.strictEqual(result.map.value.regular, "a normal value");
            assert.strictEqual(result.map.value.secret, "a secret value");

            // The nested map had a secret in one of the values, so the entire thing becomes a secret.
            assert.strictEqual(result.mapWithList[runtime.specialSigKey], runtime.specialSecretSig);
            assert.strictEqual(result.mapWithList.value.regular, "a normal value");
            assert.strictEqual(result.mapWithList.value.list[0], "a normal value");
            assert.strictEqual(result.mapWithList.value.list[1], "a secret value");

            // An array element contained a secret (via a nested map), so the entrie array becomes a secret.
            assert.strictEqual(result.listWithMap[runtime.specialSigKey], runtime.specialSecretSig);
            assert.strictEqual(result.listWithMap.value[0].regular, "a normal value");
            assert.strictEqual(result.listWithMap.value[0].secret, "a secret value");
        });
        it("deserializes resource references properly during preview", async () => {
            runtime.setMocks(new TestMocks());
            state.getStore().supportsResourceReferences = true;
            runtime.registerResourceModule("test", "index", new TestResourceModule());

            const component = new TestComponentResource("test");
            const custom = new TestCustomResource("test");
            const unregistered = new TestCustomResource("test", "test2:index:custom");

            const componentURN = await component.urn.promise();
            const customURN = await custom.urn.promise();
            const customID = await custom.id.promise();
            const unregisteredURN = await unregistered.urn.promise();
            const unregisteredID = await unregistered.id.promise();

            const outputs = {
                component: {
                    [runtime.specialSigKey]: runtime.specialResourceSig,
                    urn: componentURN,
                },
                custom: {
                    [runtime.specialSigKey]: runtime.specialResourceSig,
                    urn: customURN,
                    id: customID,
                },
                unregistered: {
                    [runtime.specialSigKey]: runtime.specialResourceSig,
                    urn: unregisteredURN,
                    id: unregisteredID,
                },
            };

            const deserialized = runtime.deserializeProperty(outputs);
            assert.ok((<ComponentResource>deserialized["component"]).__pulumiComponentResource);
            assert.ok((<CustomResource>deserialized["custom"]).__pulumiCustomResource);
            assert.deepEqual(deserialized["unregistered"], unregisteredID);
        });
    });

    describe("resource error handling", () => {
        it("registerResource errors propagate appropriately", async () => {
            runtime.setMocks(new TestMocks());

            await assert.rejects(
                async () => {
                    const errResource = new TestErrorResource("test");
                    const customURN = await errResource.urn.promise();
                    const customID = await errResource.id.promise();
                },
                (err: Error) => {
                    const containsMessage = err.stack!.indexOf("this is an intentional error") >= 0;
                    const containsRegisterResource = err.stack!.indexOf("registerResource") >= 0;
                    return containsMessage && containsRegisterResource;
                },
            );
        });
    });
});