# 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.

"""Define gRPC plumbing to expose a custom user-defined `Provider`
instance as a gRPC server so that it can be used as a Pulumi plugin.

"""

from typing import Dict, List, Set, Optional, TypeVar, Any, cast
import argparse
import asyncio
import sys

import grpc
import grpc.aio

from google.protobuf import struct_pb2
from pulumi.provider.provider import InvokeResult, Provider, CallResult, ConstructResult
from pulumi.resource import (
    ProviderResource,
    Resource,
    DependencyResource,
    DependencyProviderResource,
    _parse_resource_reference,
)
from pulumi.runtime import known_types, proto, rpc
from pulumi.runtime.proto import (
    provider_pb2_grpc,
    ResourceProviderServicer,
    status_pb2,
    errors_pb2,
)
from pulumi.runtime.stack import wait_for_rpcs
import pulumi
import pulumi.resource
import pulumi.runtime.config
import pulumi.runtime.settings
from pulumi.errors import (
    InputPropertiesError,
    InputPropertyError,
    InputPropertyErrorDetails,
)

# _MAX_RPC_MESSAGE_SIZE raises the gRPC Max Message size from `4194304` (4mb) to `419430400` (400mb)
_MAX_RPC_MESSAGE_SIZE = 1024 * 1024 * 400
_GRPC_CHANNEL_OPTIONS = [("grpc.max_receive_message_length", _MAX_RPC_MESSAGE_SIZE)]


class ProviderServicer(ResourceProviderServicer):
    """Implements a subset of `ResourceProvider` methods to support
    `Construct` and other methods invoked by the engine when the user
    program creates a remote `ComponentResource` (with `remote=true`
    in the constructor).

    See `ResourceProvider` defined in `provider.proto`.

    """

    engine_address: str
    provider: Provider
    args: List[str]
    lock: asyncio.Lock

    def create_grpc_invalid_properties_status(
        self, message: str, errors: Optional[List[InputPropertyErrorDetails]]
    ):
        status = grpc.Status()  # type: ignore[attr-defined]
        # We don't care about the exact status code here, since they are pretty web centric, and don't
        # necessarily make sense in this context.  Pick one that's close enough.
        # type: ignore
        status.code = grpc.StatusCode.INVALID_ARGUMENT.value[0]  # type: ignore[index]
        status.details = message

        if errors is not None:
            s = status_pb2.Status()  # type: ignore[attr-defined]
            # This code needs to match the code above.
            s.code = grpc.StatusCode.INVALID_ARGUMENT.value[0]  # type: ignore[index]
            s.message = message

            error_details = errors_pb2.InputPropertiesError()
            for error in errors:
                property_error = errors_pb2.InputPropertiesError.PropertyError()
                property_error.property_path = error["property_path"]
                property_error.reason = error["reason"]
                error_details.errors.append(property_error)

            details_container = s.details.add()
            details_container.Pack(error_details)

            status.trailing_metadata = (
                ("grpc-status-details-bin", s.SerializeToString()),
            )

        return status

    async def Construct(  # pylint: disable=invalid-overridden-method
        self, request: proto.ConstructRequest, context
    ) -> proto.ConstructResponse:
        # Calls to `Construct` and `Call` are serialized because they currently modify globals. When we are able to
        # avoid modifying globals, we can remove the locking.
        await self.lock.acquire()
        try:
            return await self._construct(request, context)
        except InputPropertiesError as e:
            status = self.create_grpc_invalid_properties_status(e.message, e.errors)
            await context.abort_with_status(status)
            # We already aborted at this point
            raise
        except InputPropertyError as e:
            status = self.create_grpc_invalid_properties_status(
                "", [{"property_path": e.property_path, "reason": e.reason}]
            )
            await context.abort_with_status(status)
            # We already aborted at this point
            raise

        finally:
            self.lock.release()

    async def _construct(
        self, request: proto.ConstructRequest, context
    ) -> proto.ConstructResponse:
        # pylint: disable=unused-argument
        assert isinstance(
            request, proto.ConstructRequest
        ), f"request is not ConstructRequest but is {type(request)} instead"

        organization = request.organization if request.organization else "organization"
        pulumi.runtime.settings.reset_options(
            organization=organization,
            project=_empty_as_none(request.project),
            stack=_empty_as_none(request.stack),
            parallel=_zero_as_none(request.parallel),
            engine_address=self.engine_address,
            monitor_address=_empty_as_none(request.monitorEndpoint),
            preview=request.dryRun,
        )

        pulumi.runtime.config.set_all_config(
            dict(request.config), list(request.configSecretKeys)
        )
        inputs = await self._construct_inputs(request.inputs, request.inputDependencies)

        result = self.provider.construct(
            name=request.name,
            resource_type=request.type,
            inputs=inputs,
            options=self._construct_options(request),
        )

        response = await self._construct_response(result)

        # Wait for outstanding RPCs such as more provider Construct
        # calls. This can happen if i.e. provider creates child
        # resources but does not await their URN promises.
        #
        # Do not await all tasks as that starts hanging waiting for
        # indefinite grpc.aio servier tasks.
        await wait_for_rpcs(await_all_outstanding_tasks=False)

        return response

    @staticmethod
    async def _construct_inputs(
        inputs: struct_pb2.Struct, input_dependencies: Any
    ) -> Dict[str, pulumi.Input[Any]]:
        def deps(key: str) -> Set[str]:
            return set(
                urn
                for urn in input_dependencies.get(
                    key, proto.ConstructRequest.PropertyDependencies()
                ).urns
            )

        return {
            k: await ProviderServicer._select_value(the_input, deps=deps(k))
            for k, the_input in rpc.deserialize_properties(
                inputs, keep_unknowns=True
            ).items()
        }

    @staticmethod
    async def _select_value(the_input: Any, deps: Set[str]) -> Any:
        is_secret = rpc.is_rpc_secret(the_input)

        # If the input isn't a secret and either doesn't have any dependencies, already contains Outputs (from
        # deserialized output values), or is a resource reference, then return it directly without wrapping it
        # as an output.
        if not is_secret and (
            len(deps) == 0
            or _contains_outputs(the_input)
            or await _is_resource_reference(the_input, deps)
        ):
            return the_input

        # Otherwise, wrap it as an output so we can handle secrets
        # and/or track dependencies.
        # Note: If the value is or contains an unknown value, the Output will mark its value as
        # unknown automatically, so we just pass true for is_known here.
        return pulumi.Output(
            resources=set(DependencyResource(urn) for urn in deps),
            future=_as_future(rpc.unwrap_rpc_secret(the_input)),
            is_known=_as_future(True),
            is_secret=_as_future(is_secret),
        )

    @staticmethod
    def _construct_options(request: proto.ConstructRequest) -> pulumi.ResourceOptions:
        parent = None
        if not _empty_as_none(request.parent):
            parent = DependencyResource(request.parent)
        return pulumi.ResourceOptions(
            aliases=list(request.aliases),
            depends_on=[DependencyResource(urn) for urn in request.dependencies],
            protect=request.protect,
            providers={
                pkg: _create_provider_resource(ref)
                for pkg, ref in request.providers.items()
            },
            parent=parent,
        )

    async def _construct_response(
        self, result: ConstructResult
    ) -> proto.ConstructResponse:
        urn = await pulumi.Output.from_input(result.urn).future()
        assert urn is not None

        # Note: property_deps is populated by rpc.serialize_properties.
        property_deps: Dict[str, List[pulumi.resource.Resource]] = {}
        state = await rpc.serialize_properties(
            inputs={k: v for k, v in result.state.items() if k not in ["id", "urn"]},
            property_deps=property_deps,
        )

        deps: Dict[str, proto.ConstructResponse.PropertyDependencies] = {}
        for k, resources in property_deps.items():
            urns = await asyncio.gather(*(r.urn.future() for r in resources))
            # filter out any unknowns
            knownUrns = [u for u in urns if u is not None]
            deps[k] = proto.ConstructResponse.PropertyDependencies(urns=knownUrns)

        return proto.ConstructResponse(urn=urn, state=state, stateDependencies=deps)

    async def Call(
        self, request: proto.CallRequest, context
    ):  # pylint: disable=invalid-overridden-method
        # Calls to `Construct` and `Call` are serialized because they currently modify globals. When we are able to
        # avoid modifying globals, we can remove the locking.
        await self.lock.acquire()
        try:
            return await self._call(request, context)
        except InputPropertiesError as e:
            status = self.create_grpc_invalid_properties_status(e.message, e.errors)
            await context.abort_with_status(status)
            # We already aborted at this point
            raise
        except InputPropertyError as e:
            status = self.create_grpc_invalid_properties_status(
                "", [{"property_path": e.property_path, "reason": e.reason}]
            )
            await context.abort_with_status(status)
            # We already aborted at this point
            raise
        finally:
            self.lock.release()

    async def _call(self, request: proto.CallRequest, context):
        # pylint: disable=unused-argument
        assert isinstance(
            request, proto.CallRequest
        ), f"request is not CallRequest but is {type(request)} instead"

        organization = request.organization if request.organization else "organization"
        pulumi.runtime.settings.reset_options(
            organization=organization,
            project=_empty_as_none(request.project),
            stack=_empty_as_none(request.stack),
            parallel=_zero_as_none(request.parallel),
            engine_address=self.engine_address,
            monitor_address=_empty_as_none(request.monitorEndpoint),
            preview=request.dryRun,
        )

        pulumi.runtime.config.set_all_config(
            dict(request.config),
            list(request.configSecretKeys),
        )

        args = await self._call_args(request)

        result = self.provider.call(token=request.tok, args=args)

        response = await self._call_response(result)

        # Wait for outstanding RPCs such as more provider Construct
        # calls. This can happen if i.e. provider creates child
        # resources but does not await their URN promises.
        #
        # Do not await all tasks as that starts hanging waiting for
        # indefinite grpc.aio servier tasks.
        await wait_for_rpcs(await_all_outstanding_tasks=False)

        return response

    @staticmethod
    async def _call_args(request: proto.CallRequest) -> Dict[str, pulumi.Input[Any]]:
        def deps(key: str) -> Set[str]:
            return set(
                urn
                for urn in request.argDependencies.get(
                    key, proto.CallRequest.ArgumentDependencies()
                ).urns
            )

        return {
            k: await ProviderServicer._select_value(the_input, deps=deps(k))
            for k, the_input in
            # We need to keep_internal, to keep the `__self__` that would normally be filtered because
            # it starts with "__".
            rpc.deserialize_properties(
                request.args, keep_unknowns=True, keep_internal=True
            ).items()
        }

    async def _call_response(self, result: CallResult) -> proto.CallResponse:
        # Note: ret_deps is populated by rpc.serialize_properties.
        ret_deps: Dict[str, List[pulumi.resource.Resource]] = {}
        ret = await rpc.serialize_properties(
            inputs=result.outputs, property_deps=ret_deps
        )

        deps: Dict[str, proto.CallResponse.ReturnDependencies] = {}
        for k, resources in ret_deps.items():
            urns = await asyncio.gather(*(r.urn.future() for r in resources))
            # filter out any unknowns
            knownUrns = [u for u in urns if u is not None]
            deps[k] = proto.CallResponse.ReturnDependencies(urns=knownUrns)

        failures = None
        if result.failures:
            failures = [
                proto.CheckFailure(property=f.property, reason=f.reason)
                for f in result.failures
            ]
        resp = proto.CallResponse(returnDependencies=deps, failures=failures)
        # Since `return` is a keyword, we need to use getattr: https://developers.google.com/protocol-buffers/docs/reference/python-generated#keyword-conflicts
        getattr(resp, "return").CopyFrom(ret)
        return resp

    async def _invoke_response(self, result: InvokeResult) -> proto.InvokeResponse:
        # Note: ret_deps is populated by rpc.serialize_properties but unused
        ret_deps: Dict[str, List[pulumi.resource.Resource]] = {}
        ret = await rpc.serialize_properties(
            inputs=result.outputs, property_deps=ret_deps
        )
        # Since `return` is a keyword, we need to pass the args to `InvokeResponse` using a dictionary.
        resp: Dict[str, Any] = {
            "return": ret,
        }
        if result.failures:
            resp["failures"] = [
                proto.CheckFailure(property=f.property, reason=f.reason)
                for f in result.failures
            ]
        return proto.InvokeResponse(**resp)

    async def Invoke(  # pylint: disable=invalid-overridden-method
        self, request: proto.InvokeRequest, context
    ) -> proto.InvokeResponse:
        args = rpc.deserialize_properties(
            request.args, keep_unknowns=False, keep_internal=False
        )
        result = self.provider.invoke(token=request.tok, args=args)
        response = await self._invoke_response(result)
        return response

    async def Configure(  # pylint: disable=invalid-overridden-method
        self, request, context
    ) -> proto.ConfigureResponse:
        return proto.ConfigureResponse(
            acceptSecrets=True, acceptResources=True, acceptOutputs=True
        )

    async def GetPluginInfo(  # pylint: disable=invalid-overridden-method
        self, request, context
    ) -> proto.PluginInfo:
        return proto.PluginInfo(version=self.provider.version)

    async def GetSchema(  # pylint: disable=invalid-overridden-method
        self, request: proto.GetSchemaRequest, context
    ) -> proto.GetSchemaResponse:
        if request.version != 0:
            raise Exception(f"unsupported schema version {request.version}")
        schema = self.provider.schema if self.provider.schema else "{}"
        return proto.GetSchemaResponse(schema=schema)

    def __init__(
        self, provider: Provider, args: List[str], engine_address: str
    ) -> None:
        super().__init__()
        self.provider = provider
        self.args = args
        self.engine_address = engine_address
        self.lock = asyncio.Lock()


def main(provider: Provider, args: List[str]) -> None:  # args not in use?
    """For use as the `main` in programs that wrap a custom Provider
    implementation into a Pulumi-compatible gRPC server.

    :param provider: an instance of a Provider subclass

    :args: command line arguiments such as os.argv[1:]

    """

    argp = argparse.ArgumentParser(description="Pulumi provider plugin (gRPC server)")
    argp.add_argument("engine", help="Pulumi engine address")
    argp.add_argument("--logflow", action="store_true", help="Currently ignored")
    argp.add_argument("--logtostderr", action="store_true", help="Currently ignored")

    known_args, _ = argp.parse_known_args()
    engine_address: str = known_args.engine

    async def serve() -> None:
        server = grpc.aio.server(options=_GRPC_CHANNEL_OPTIONS)
        servicer = ProviderServicer(provider, args, engine_address=engine_address)
        provider_pb2_grpc.add_ResourceProviderServicer_to_server(servicer, server)
        port = server.add_insecure_port(address="127.0.0.1:0")
        await server.start()
        sys.stdout.buffer.write(f"{port}\n".encode())
        sys.stdout.buffer.flush()
        await server.wait_for_termination()

    try:
        loop = asyncio.get_event_loop()
        try:
            loop.run_until_complete(serve())
        finally:
            loop.close()
    except KeyboardInterrupt:
        pass


T = TypeVar("T")  # pylint: disable=invalid-name


def _as_future(value: T) -> "asyncio.Future[T]":
    fut: "asyncio.Future[T]" = asyncio.Future()
    fut.set_result(value)
    return fut


def _empty_as_none(text: str) -> Optional[str]:
    return None if text == "" else text


def _zero_as_none(value: int) -> Optional[int]:
    return None if value == 0 else value


async def _is_resource_reference(the_input: Any, deps: Set[str]) -> bool:
    """
    Returns True if `the_input` is a Resource and only depends on itself.
    """
    return (
        known_types.is_resource(the_input)
        and len(deps) == 1
        and next(iter(deps)) == await cast(Resource, the_input).urn.future()
    )


def _contains_outputs(the_input: Any) -> bool:
    """
    Returns true if the input contains Outputs (deeply).
    """
    if known_types.is_output(the_input):
        return True

    if isinstance(the_input, list):
        for e in the_input:
            if _contains_outputs(e):
                return True
    elif isinstance(the_input, dict):
        for k in the_input:
            if _contains_outputs(the_input[k]):
                return True

    return False


def _create_provider_resource(ref: str) -> ProviderResource:
    """
    Rehydrate the provider reference into a registered ProviderResource,
    otherwise return an instance of DependencyProviderResource.
    """
    urn, _ = _parse_resource_reference(ref)
    urn_parts = pulumi.urn._parse_urn(urn)
    resource_package = rpc.get_resource_package(urn_parts.typ_name, version="")
    if resource_package is not None:
        return cast(
            ProviderResource,
            resource_package.construct_provider(urn_parts.urn_name, urn_parts.typ, urn),
        )

    return DependencyProviderResource(ref)
