# -*- coding: utf-8 -*- #
# Copyright 2019 Google LLC. All Rights Reserved.
#
# 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.
"""Library for manipulating serverless local development setup."""

import base64
import collections
import json
import os
import os.path
import re
import uuid

from apitools.base.py import encoding_helper
from apitools.base.py import exceptions as apitools_exceptions
from googlecloudsdk.api_lib.app import yaml_parsing as app_engine_yaml_parsing
from googlecloudsdk.api_lib.run import container_resource
from googlecloudsdk.api_lib.run import service as k8s_service
from googlecloudsdk.api_lib.util import apis
from googlecloudsdk.api_lib.util import messages as messages_util
from googlecloudsdk.calliope import base
from googlecloudsdk.command_lib.auth import auth_util
from googlecloudsdk.command_lib.code import builders
from googlecloudsdk.command_lib.code import common
from googlecloudsdk.command_lib.code import dataobject
from googlecloudsdk.command_lib.code import secrets
from googlecloudsdk.command_lib.code import yaml_helper
from googlecloudsdk.command_lib.iam import iam_util
from googlecloudsdk.command_lib.run import secrets_mapping
from googlecloudsdk.core import config
from googlecloudsdk.core import exceptions
from googlecloudsdk.core import log
from googlecloudsdk.core import properties
from googlecloudsdk.core import yaml
from googlecloudsdk.core.console import console_io
from googlecloudsdk.core.util import encoding
from googlecloudsdk.core.util import files
import six

IAM_MESSAGE_MODULE = apis.GetMessagesModule('iam', 'v1')
CRM_MESSAGE_MODULE = apis.GetMessagesModule('cloudresourcemanager', 'v1')
RUN_MESSAGES_MODULE = apis.GetMessagesModule('run', 'v1')

_DEFAULT_BUILDPACK_BUILDER = 'gcr.io/buildpacks/builder'

_C_IDENTIFIER = r'^[a-zA-Z_][a-zA-Z_0-9]*$'

# A sentinal used for unspecified flag values. We are using an object as a
# sentinal value instead of None so that for boolean flags None (unset) isn't
# confused the set value False.
_FLAG_UNSPECIFIED = object()


class _ParseError(TypeError):
  """File does not parse with the expected schema."""


class ServiceAccountSetting(dataobject.DataObject):
  """Setting object representing a service account."""
  NAMES = ('name',)


class ApplicationDefaultCredentialSetting(dataobject.DataObject):
  """Setting object representing the application default credential."""

  _instance = None

  def __new__(cls, **kwargs):
    if not cls._instance:
      cls._instance = super(ApplicationDefaultCredentialSetting,
                            cls).__new__(cls, **kwargs)

    return cls._instance


def _GaeBuilderPackagePath(runtime):
  """GCR package path for a builder that works on the given appengine runtime.

  Args:
    runtime: Name of a runtime from app.yaml, e.g. 'python38'.

  Returns:
    gcr.io image path.
  """
  return 'gcr.io/gae-runtimes/buildpacks/%s/builder:latest' % runtime


def _IsGcpBaseBuilder(bldr):
  """Return true if the builder is the GCP base builder.

  Args:
    bldr: Name of the builder.

  Returns:
    True if the builder is the GCP base builder.
  """
  return bldr == _DEFAULT_BUILDPACK_BUILDER


class _SecretPath(dataobject.DataObject):
  """Configuration for a single secret version.

    Attributes:
      key: The secret version to mount.
      path: The file path to mount it on.
  """

  NAMES = ('key', 'path')


class _SecretVolume(dataobject.DataObject):
  """Configuration for a single volume that mounts secret versions.

    Attributes:
      name: (str) The name of the volume to be referenced in the k8s resource.
      mount_path: (str) The filesystem location where the volume is mounted.
      secret_name: (str) The secret manager reference.
      mapped_secret: (str) If set, the path of the secret in another project to
        use. For example 'projects/123/secrets/foo'.
      items: (List[SecretPath]) The list of keys and paths for the secret.
  """
  NAMES = ('name', 'mapped_secret', 'mount_path', 'secret_name', 'items')

  @classmethod
  def FromParsedYaml(cls,
                     mount,
                     volume_secret,
                     secret_aliases,
                     renamed_secrets=None):
    """Make a SecretVolume based on the volumeMount and secret from the yaml."""
    items = []
    renamed_secrets = renamed_secrets or {}
    for item in volume_secret.items:
      items.append(_SecretPath(key=item.key, path=item.path))
    name = volume_secret.secretName
    mapped_secret = secret_aliases.get(volume_secret.secretName, None)
    if (not mapped_secret and not secrets.IsValidK8sName(name)):
      mapped_secret = name
      name = renamed_secrets.get(name, None)
      if not name:
        name, mapped_secret = _BuildReachableSecret(volume_secret.secretName)
        renamed_secrets[mapped_secret] = name
    return cls(
        name=mount.name,
        mount_path=mount.mountPath,
        secret_name=name,
        items=items,
        mapped_secret=mapped_secret)

  def IsSameSecret(self, other):
    if self.mapped_secret or other.mapped_secret:
      return self.mapped_secret == other.mapped_secret
    return self.secret_name == other.secret_name


class _SecretEnvVar(dataobject.DataObject):
  """Configuration for a single env var that's pulled from a secret.

  Attributes:
    name: (str) the name of the secret in the resource
    key: (str) the secret version to use
    mapped_secret: (str) if set, the path of the secret from the other project
      that this secret will pull from. For example 'projects/123/secrets/foo'.
      If not set, will pull from the secret called 'name' in the current
      project.
  """
  NAMES = ('name', 'key', 'mapped_secret')


class Settings(dataobject.DataObject):
  """Settings for local development environments.

    Attributes:
      service_name: Name of the kuberntes service.
      image: Docker image tag.
      credential: Credential setting for either service account or application
        default credential.
      context: Path to directory to use as the current working directory for the
        docker build.
      builder: The builder specification or None.
      local_port: Local port to which to forward the service connection.
      env_vars: Container environment variables.
      env_vars_secrets: Container environment variables where the values come
        from Secret Manager.
      volumes_secrets: Volumes where the values come from secret manager.
      renamed_secrets: internal map of secrets that don't correspond to k8s
        naming conventions.
      cloudsql_instances: Cloud SQL instances.
      memory: Memory limit.
      cpu: CPU limit.
      namespace: Kubernetes namespace to run in.
      readiness_probe: If true, create readiness probe.
      allow_secret_manager: If true, allow fetching secrets from secret manager
  """

  NAMES = ('service_name', 'image', 'credential', 'context', 'builder',
           'local_port', 'env_vars', 'env_vars_secrets', 'volumes_secrets',
           'renamed_secrets', 'cloudsql_instances', 'memory', 'cpu',
           'namespace', 'readiness_probe', 'allow_secret_manager')

  @classmethod
  def Defaults(cls):
    """The settings you get with no args or other overrides."""

    dir_name = os.path.basename(files.GetCWD())
    # Service names may not include space, _ and upper case characters.
    service_name = dir_name.replace('_', '-').replace(' ', '-').lower()
    dockerfile_arg_default = 'Dockerfile'
    builder = builders.DockerfileBuilder(dockerfile=dockerfile_arg_default)

    return cls(
        builder=builder,
        cloudsql_instances=[],
        context=os.path.abspath(files.GetCWD()),
        image=None,  # See Build() below.
        service_name=service_name,
        env_vars={},
        env_vars_secrets={},
        volumes_secrets=[],
        renamed_secrets={},
        allow_secret_manager=_FLAG_UNSPECIFIED,
    )

  def WithServiceYaml(self, yaml_path, is_alpha=False):
    """Overrides settings with service.yaml and returns a new Settings object.

    Args:
      yaml_path: Filename to read.
      is_alpha: boolean set to true if alpha features should be used.

    Returns:
      New Settings object.

    Raises:
      _ParseError: Input does not look like a service.yaml
    """
    knative_service = self._ParseServiceYaml(yaml_path)

    replacements = {
        'service_name': knative_service.metadata.name,
    }

    try:
      [container] = knative_service.spec.template.spec.containers
    except ValueError:
      raise exceptions.Error('knative Service must have exactly one container.')

    # If there are any secrets referenced from other projects, there will be
    # an alias specified
    secret_aliases = {}
    aliases = knative_service.annotations.get(
        container_resource.SECRETS_ANNOTATION, '')
    secret_aliases = {
        k: v.FormatAnnotationItem()
        for k, v in secrets_mapping.ParseAnnotation(aliases, True).items()
    }

    # The secrets aliases are supposed to be specified in the revision Template
    # but we originally specified the wrong location.
    if knative_service.template.IsFullObject():
      template_aliases = knative_service.template_annotations.get(
          container_resource.SECRETS_ANNOTATION, '')
      secret_aliases.update({
          k:
          v.FormatAnnotationItem() for k, v in secrets_mapping.ParseAnnotation(
              template_aliases, True).items()
      })
    new_env_vars = {}
    new_env_vars_secrets = {}
    for var in container.env:
      if var.valueFrom:
        if var.valueFrom.configMapKeyRef:
          raise exceptions.Error('env_vars from config_maps are not supported')
        elif var.valueFrom.secretKeyRef:
          secret_name = var.valueFrom.secretKeyRef.name
          mapped_secret = secret_aliases.get(secret_name, None)

          if not mapped_secret and not secrets.IsValidK8sName(secret_name):
            mapped_secret = secret_name
            secret_name = self.renamed_secrets.get(mapped_secret, None)
            if not secret_name:
              secret_name, mapped_secret = _BuildReachableSecret(mapped_secret)
              self.renamed_secrets[mapped_secret] = secret_name
          new_env_vars_secrets[var.name] = _SecretEnvVar(
              key=var.valueFrom.secretKeyRef.key,
              name=secret_name,
              mapped_secret=mapped_secret)
      else:
        new_env_vars[var.name] = var.value
    replacements.update(
        _MergedEnvVars(self.env_vars, self.env_vars_secrets, new_env_vars,
                       new_env_vars_secrets))
    service_account_name = knative_service.spec.template.spec.serviceAccountName
    if service_account_name:
      replacements['credential'] = ServiceAccountSetting(
          name=service_account_name)

    image_name = container.image
    if image_name:
      replacements['image'] = image_name

    replacements.update(self._ResourceRequests(container))

    all_vols = {}
    for vol in knative_service.spec.template.spec.volumes:
      if vol.secret:
        all_vols[vol.name] = vol.secret
      else:
        raise exceptions.Error('Could not process volume "{}". Only volumes '
                               'from secrets are supported.'.format(vol.name))
    referenced_vols = []
    for vol in container.volumeMounts:
      if vol.name not in all_vols:
        raise exceptions.Error('Container referenced volume "{}" which was not '
                               'found.'.format(vol.name))
      referenced_vols.append(
          _SecretVolume.FromParsedYaml(vol, all_vols[vol.name], secret_aliases,
                                       self.renamed_secrets))
    replacements['volumes_secrets'] = referenced_vols

    return self.replace(**replacements)

  def _ParseServiceYaml(self, yaml_path):
    try:
      yaml_dict = yaml.load_path(yaml_path)
      message = messages_util.DictToMessageWithErrorCheck(
          yaml_dict, RUN_MESSAGES_MODULE.Service)
      knative_service = k8s_service.Service(message, RUN_MESSAGES_MODULE)
    except (yaml.Error, messages_util.DecodeError):
      raise _ParseError()
    return knative_service

  def _ResourceRequests(self, container):
    if not container.resources or not container.resources.limits:
      return {}
    ret = {}
    for prop in container.resources.limits.additionalProperties:
      if prop.key == 'cpu':
        ret['cpu'] = prop.value
      if prop.key == 'memory':
        ret['memory'] = prop.value
    return ret

  def WithAppYaml(self, yaml_path):
    """Overrides settings with app.yaml and returns a new Settings object.

    Args:
      yaml_path: Filename to read.

    Returns:
      New Settings object.

    Raises:
      ParseError: Input does not look like an app.yaml
    """
    try:
      service_config = app_engine_yaml_parsing.ServiceYamlInfo.FromFile(
          yaml_path)
    except yaml.Error:
      raise _ParseError()
    builder_url = _GaeBuilderPackagePath(service_config.parsed.runtime)
    builder = builders.BuildpackBuilder(
        builder=builder_url, trust=True, devmode=False)
    return self.replace(builder=builder)

  def WithArgs(self, args):
    """Overrides settings with args and returns a new Settings object."""

    replacements = {}
    for override_arg in [
        'local_port',
        'memory',
        'cpu',
        'namespace',
        'readiness_probe',
        'cloudsql_instances',
        'image',
        'service_name',
        'allow_secret_manager',
    ]:
      if args.IsKnownAndSpecified(override_arg):
        replacements[override_arg] = getattr(args, override_arg)

    if args.IsSpecified('application_default_credential'):
      replacements['credential'] = ApplicationDefaultCredentialSetting()
    elif args.IsSpecified('service_account'):
      replacements['credential'] = ServiceAccountSetting(
          name=args.service_account)
    context = self.context
    if args.source:
      context = os.path.abspath(args.source)
    replacements['context'] = context

    if getattr(args, 'no_skaffold_file', False):
      replacements['builder'] = None
    else:
      if args.IsKnownAndSpecified('builder'):
        replacements['builder'] = _BuilderFromArg(args.builder)
      elif args.IsKnownAndSpecified('dockerfile'):
        replacements['builder'] = builders.DockerfileBuilder(
            dockerfile=args.dockerfile)
      else:
        if isinstance(self.builder, builders.DockerfileBuilder):
          try:
            replacements['builder'] = self.builder
            replacements['builder'].Validate(context)
          except builders.InvalidLocationError:
            log.status.Print('No Dockerfile detected. '
                             'Using GCP buildpacks to build the container')
            replacements['builder'] = _BuilderFromArg(
                _DEFAULT_BUILDPACK_BUILDER)
    if getattr(args, 'env_vars', None):
      new_envs = args.env_vars
    else:
      new_envs = getattr(args, 'env_vars_file', {}) or {}

    new_env_secrets, new_sec_volumes = self._GetSecrets(
        getattr(args, 'secrets', {}) or {})
    replacements.update(
        _MergedEnvVars(self.env_vars, self.env_vars_secrets, new_envs,
                       new_env_secrets))

    replacements['volumes_secrets'] = _MergeSecretVolumes(
        self.volumes_secrets, new_sec_volumes)
    return self.replace(**replacements)

  def _GetSecrets(self, secrets_args):
    env_vars = {}  # name: _SecretEnevVar
    volumes = {}  # mount_path: _SecretVolume
    aliases = {}  # secret_path: local_name

    for key, secret in secrets_args.items():
      parts = secret.split(':')
      if len(parts) != 2:
        raise exceptions.Error('Expected secret to be of form '
                               '<secretName>:<version>, got {}'.format(secret))
      secret_name, version = parts
      mapped_secret = None
      if secret_name.startswith('projects/'):
        # it's a remote secret, need to alias it
        mapped_secret = secret_name
        if mapped_secret not in aliases:
          secret_name = (
              secret_name.lower().replace('_', '.')[:5] + '-' +
              str(uuid.uuid1()))
          aliases[mapped_secret] = secret_name
        else:
          secret_name = aliases[mapped_secret]
      elif not secrets.IsValidK8sName(secret_name):
        if secret_name in self.renamed_secrets:
          mapped_secret = secret_name
          secret_name = self.renamed_secrets[secret_name]
        else:
          secret_name, mapped_secret = _BuildReachableSecret(secret_name)

      if key.startswith('/'):  # it's a volume
        # the volume path is all but the last segment
        mount_path, filename = os.path.split(key)
        if mount_path not in volumes:
          volume = _SecretVolume(
              name=secret_name,
              mount_path=mount_path,
              secret_name=secret_name,
              items=[],
              mapped_secret=mapped_secret)
          volumes[mount_path] = volume
        else:
          volume = volumes[mount_path]
        volume.items.append(_SecretPath(key=version, path=filename))
      else:
        env_vars[key] = _SecretEnvVar(
            name=secret_name, key=version, mapped_secret=mapped_secret)
    return env_vars, list(volumes.values())

  def Build(self):
    """Validate and compute settings after all user inputs have been read."""
    if isinstance(self.builder, builders.DockerfileBuilder):
      self.builder.Validate(self.context)
    replacements = {}
    if self.image is None:
      replacements['image'] = _DefaultImageName(self.service_name)
    return self.replace(**replacements)


def _MergedEnvVars(env_vars, env_vars_secrets, new_env_vars,
                   new_env_vars_secrets):
  """Add the new env vars (both values and secrets) to the existing ones."""

  env_vars = env_vars.copy()
  env_vars_secrets = env_vars_secrets.copy()

  # Env Vars can either be values or secrets, but not both.
  # If the variable is set as both, error.
  conflicts = set(new_env_vars).intersection(new_env_vars_secrets)
  if conflicts:
    raise exceptions.Error('{} cannot be secret and literal'.format(conflicts))

  # If the user is overriding an existing env var that was a secret with a
  # literal or vice versa, make sure to remove the old value from the other
  # dict.
  for new_env, val in new_env_vars.items():
    if new_env in env_vars_secrets:
      del env_vars_secrets[new_env]
    env_vars[new_env] = val
  for new_secret, val in new_env_vars_secrets.items():
    if new_secret in env_vars:
      del env_vars[new_secret]
    env_vars_secrets[new_secret] = val
  return {'env_vars': env_vars, 'env_vars_secrets': env_vars_secrets}


def _MergeSecretVolumes(cur_vols, new_vols):
  """Combine the secret volumes from existing source with new volumes."""
  vol_map = {vol.mount_path: vol for vol in cur_vols}
  for new_vol in new_vols:
    if new_vol.mount_path not in vol_map:
      vol_map[new_vol.mount_path] = new_vol
    else:
      cur_vol = vol_map[new_vol.mount_path]
      if cur_vol.IsSameSecret(new_vol):
        cur_items = {item.path: item for item in cur_vol.items}
        new_items = {item.path: item for item in new_vol.items}
        cur_items.update(new_items)
        cur_vol.items[:] = list(cur_items.values())
      else:
        # replace the previous secret with the new secret
        vol_map[new_vol.mount_path] = new_vol
  return list(vol_map.values())


def AssembleSettings(args, release_track):
  """Layer the defaults, service.yaml/app.yaml values, and cmdline overrides."""

  # See flags.py for where we set help text based on release track, too.
  service_config_may_be_app_yaml = release_track == base.ReleaseTrack.ALPHA

  settings = Settings.Defaults()
  context_dir = getattr(args, 'source', None) or os.path.curdir
  service_config_arg = getattr(args, 'service_config', None)
  yaml_file = common.ChooseExistingServiceYaml(context_dir, service_config_arg)
  if yaml_file:
    try:
      settings = settings.WithServiceYaml(
          yaml_file, release_track == base.ReleaseTrack.ALPHA)
    except _ParseError:
      if service_config_may_be_app_yaml:
        try:
          settings = settings.WithAppYaml(yaml_file)
        except _ParseError:
          raise exceptions.Error(
              '%r is unreadable as a service.yaml or app.yaml file.' %
              yaml_file)
      else:
        raise exceptions.Error('%r is unreadable as a service.yaml file.' %
                               yaml_file)
  settings = settings.WithArgs(args)
  settings = settings.Build()
  return settings


def _DefaultImageName(service_name):
  """Computes a default image name."""
  project_name = properties.VALUES.core.project.Get()
  if project_name:
    image = 'gcr.io/{project}/{service}'.format(
        project=project_name, service=service_name)
  else:
    image = service_name

  # Image names cannot have upper case characters. If the image name is
  # autogenerated, then make sure the image name is lower case. If the
  # user enters an image name manually, it's OK to stop the user and tell
  # them about the illegal image name.
  image = image.lower()
  return image


def _BuilderFromArg(builder_arg):
  is_gcp_base_builder = _IsGcpBaseBuilder(builder_arg)
  return builders.BuildpackBuilder(
      builder=builder_arg,
      trust=is_gcp_base_builder,
      devmode=is_gcp_base_builder)


_POD_TEMPLATE = """
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {service}
  labels:
    service: {service}
spec:
  replicas: 1
  selector:
    matchLabels:
      app: {service}
  template:
    metadata:
      labels:
        app: {service}
    spec:
      containers: []
      terminationGracePeriodSeconds: 0
"""

_CONTAINER_TEMPLATE = """
name: {service}-container
image: {image}
env:
- name: PORT
  value: "8080"
ports:
- containerPort: 8080
"""

# The readiness probe container sits in an infinite wait loop and waits until
# port 8080 (1F90 in hex) is in use. This prevents the reload cycle from being
# considered "complete" until the developer's application binds to $PORT.
_READINESS_PROBE_CONTAINER_TEMPLATE = """
name: {service}-readiness-probe
image: gcr.io/gcp-runtimes/ubuntu_18_0_4:latest
command: ["sleep"]
args: ["infinity"]
readinessProbe:
  exec:
    command:
    - "grep"
    - ":1F90"
    - "/proc/net/tcp"
    - "/proc/net/tcp6"
  periodSeconds: 1
"""


def _CreateDeployment(service_name,
                      image_name,
                      memory_limit=None,
                      cpu_limit=None,
                      cpu_request=None,
                      readiness_probe=False):
  """Create a deployment specification for a service.

  Args:
    service_name: Name of the service.
    image_name: Image tag.
    memory_limit: Container memory limit.
    cpu_limit: Container cpu limit.
    cpu_request: Container cpu request.
    readiness_probe: If true, add a readiness probe.

  Returns:
    Dictionary object representing the deployment yaml, and the main container.
  """
  deployment = yaml.load(_POD_TEMPLATE.format(service=service_name))
  container = yaml.load(
      _CONTAINER_TEMPLATE.format(service=service_name, image=image_name))
  if memory_limit is not None:
    limits = yaml_helper.GetOrCreate(container, ('resources', 'limits'))
    limits['memory'] = memory_limit
  if cpu_limit is not None:
    limits = yaml_helper.GetOrCreate(container, ('resources', 'limits'))
    limits['cpu'] = six.text_type(cpu_limit)
  if cpu_request is not None:
    requests = yaml_helper.GetOrCreate(container, ('resources', 'requests'))
    requests['cpu'] = six.text_type(cpu_request)
  containers = yaml_helper.GetOrCreate(
      deployment, ('spec', 'template', 'spec', 'containers'), constructor=list)
  containers.append(container)

  if readiness_probe:
    readiness_container = yaml.load(
        _READINESS_PROBE_CONTAINER_TEMPLATE.format(service=service_name))
    containers.append(readiness_container)

  return deployment, container


_SERVICE_TEMPLATE = """
apiVersion: v1
kind: Service
metadata:
  name: {service}
spec:
  type: LoadBalancer
  selector:
    app: {service}
  ports:
  - protocol: TCP
    port: 8080
    targetPort: 8080
"""


def CreateService(service_name):
  """Create a service specification.

  Args:
    service_name: Name of the service.

  Returns:
    Dictionary objects representing the service yaml.
  """
  yaml_text = _SERVICE_TEMPLATE.format(service=service_name)
  return yaml.load(yaml_text)


def _AddEnvironmentVariables(container, env_vars):
  """Add environment variable settings to a container.

  Args:
    container: (dict) Container to edit.
    env_vars: (dict) Key value environment variable pairs.
  """
  env_list = yaml_helper.GetOrCreate(container, ('env',), constructor=list)
  invalid_keys = []
  for key, value in sorted(env_vars.items()):
    if not re.match(_C_IDENTIFIER, key):
      invalid_keys.append(six.ensure_str(key))
      continue
    env_list.append({'name': key, 'value': value})
  if invalid_keys:
    raise ValueError('Environment variable name must be a C_IDENTIFIER. '
                     'Invalid names: %r' % invalid_keys)


def _AddSecretEnvironmentVariables(container, env_vars_secrets):
  """Add environment variables from secrets to a container.

  Args:
    container: (dict) Container to edit.
    env_vars_secrets: (dict) Key value environment variable pairs. Values are
      dict with key/name keys in them.
  """
  env_list = yaml_helper.GetOrCreate(container, ('env',), constructor=list)
  for key, value in sorted(env_vars_secrets.items()):
    env_list.append({
        'name': key,
        'valueFrom': {
            'secretKeyRef': {
                'name': value.name,
                'key': value.key
            }
        }
    })


def CreateDevelopmentServiceAccount(service_account_email):
  """Creates a service account for local development.

  Args:
    service_account_email: Email of the service account.

  Returns:
    The resource name of the service account.
  """
  project_id = _GetServiceAccountProject(service_account_email)
  service_account_name = 'projects/{project}/serviceAccounts/{account}'.format(
      project=project_id, account=service_account_email)

  exists = _ServiceAccountExists(service_account_name)
  if _IsReservedServiceAccountName(service_account_email):
    if not exists:
      raise ValueError('%s cannot be created because it is a service '
                       'account name' % service_account_email)
    else:
      return service_account_name

  if not exists:
    account_id = _GetServiceAccountId(service_account_email)
    _CreateAccount('Serverless Local Development Service Account', account_id,
                   project_id)

    permission_msg = ('The project editor role allows the service account '
                      'to create, delete, and modify most resources in the '
                      'project.')
    prompt_string = (
        'Add project editor role to {}?'.format(service_account_email))
    # Make the service account an editor on the project
    if console_io.PromptContinue(
        message=permission_msg, prompt_string=prompt_string):
      _AddBinding(project_id, 'serviceAccount:' + service_account_email,
                  'roles/editor')

  return service_account_name


# Regular expression for parsing a service account email address.
# Format is [id]@[project].iam.gserviceaccount.com
_PROJECT_SERVICE_ACCOUNT_RE = re.compile(
    r'(?P<id>[^@]+)@(?P<project>[^\.]+).iam.gserviceaccount.com')

# Regular expression for parsing a compute service account email address.
# Format is [project-id]-compute@developer.gserviceaccount.com
_APPENGINE_SERVICE_ACCOUNT = re.compile(
    r'(?P<project_id>[^\.]+).google.com@appspot.gserviceaccount.com')

# Regular expression for parsing a compute service account email address.
# Format is [project-number]-compute@developer.gserviceaccount.com
_COMPUTE_SERVICE_ACCOUNT = re.compile(
    r'(?P<project_number>\d+)-compute@developer.gserviceaccount.com')


def _GetServiceAccountProject(service_account_email):
  """Get the project id from a service account email.

  Args:
    service_account_email: (str) Email address of service account.

  Returns:
    The project id of the project to which the service account belongs.
  """
  matcher = _PROJECT_SERVICE_ACCOUNT_RE.match(service_account_email)
  if matcher:
    return matcher.group('project')

  matcher = _APPENGINE_SERVICE_ACCOUNT.match(service_account_email)
  if matcher:
    return matcher.group('project_id')

  matcher = _COMPUTE_SERVICE_ACCOUNT.match(service_account_email)
  if matcher:
    return _ProjectNumberToId(matcher.group('project_number'))

  raise ValueError(service_account_email +
                   ' is not a valid service account address')


_SERVICE_ACCOUNT_RE = re.compile(r'(?P<id>[^@]+)@.*\.gserviceaccount\.com')


def _GetServiceAccountId(service_account_email):
  matcher = _SERVICE_ACCOUNT_RE.match(service_account_email)
  if not matcher:
    raise ValueError(service_account_email +
                     ' is not a valid service account address')
  return matcher.group('id')


def _ProjectNumberToId(project_number):
  """Coverts project number to project id.

  Args:
    project_number: (str) The project number as a string.

  Returns:
    The project id.
  """
  resource_manager = apis.GetClientInstance('cloudresourcemanager', 'v1')
  req = CRM_MESSAGE_MODULE.CloudresourcemanagerProjectsGetRequest(
      projectId=project_number)
  project = resource_manager.projects.Get(req)
  return six.ensure_text(project.projectId)


def _IsReservedServiceAccountName(service_account_email):
  return (_APPENGINE_SERVICE_ACCOUNT.match(service_account_email) or
          _COMPUTE_SERVICE_ACCOUNT.match(service_account_email))


def _ServiceAccountExists(service_account_name):
  """Tests if service account email.

  Args:
    service_account_name: (str) Service account resource name.

  Returns:
    True if the service account exists.
  """
  service = apis.GetClientInstance('iam', 'v1')
  try:
    request = IAM_MESSAGE_MODULE.IamProjectsServiceAccountsGetRequest(
        name=service_account_name)
    service.projects_serviceAccounts.Get(request)
    return True
  except apitools_exceptions.HttpNotFoundError:
    return False


def _CreateAccount(display_name, account_id, project):
  """Create an account if it does not already exist.

  Args:
    display_name: (str) Display name.
    account_id: (str) User account id.
    project: (str) Project name.
  """
  service = apis.GetClientInstance('iam', 'v1')
  try:
    service_account_msg = IAM_MESSAGE_MODULE.ServiceAccount(
        displayName=display_name)
    request = IAM_MESSAGE_MODULE.CreateServiceAccountRequest(
        accountId=account_id, serviceAccount=service_account_msg)
    service.projects_serviceAccounts.Create(
        IAM_MESSAGE_MODULE.IamProjectsServiceAccountsCreateRequest(
            name='projects/' + project, createServiceAccountRequest=request))
  except apitools_exceptions.HttpConflictError:
    # If account already exists, we can ignore the error
    pass


def _AddBinding(project, account, role):
  """Adds a binding.

  Args:
    project: (str) Project name.
    account: (str) User account.
    role: (str) Role.
  """
  crm_client = apis.GetClientInstance('cloudresourcemanager', 'v1')
  policy = crm_client.projects.GetIamPolicy(
      CRM_MESSAGE_MODULE.CloudresourcemanagerProjectsGetIamPolicyRequest(
          resource=project))

  if not iam_util.BindingInPolicy(policy, account, role):
    iam_util.AddBindingToIamPolicy(CRM_MESSAGE_MODULE.Binding, policy, account,
                                   role)
    req = CRM_MESSAGE_MODULE.CloudresourcemanagerProjectsSetIamPolicyRequest(
        resource=project,
        setIamPolicyRequest=CRM_MESSAGE_MODULE.SetIamPolicyRequest(
            policy=policy))
    crm_client.projects.SetIamPolicy(req)


class KubeConfigGenerator(object):
  """The base code generator with default return values.

  Subclasses may override any of the member methods.
  """

  def CreateConfigs(self):
    """Create top level kubernetes configs.

    Returns:
      List of kubernetes configuration yamls encoded as dictionaries.
    """
    return []

  def ModifyDeployment(self, deployment):
    """Modify a deployment.

    Subclasses that override this method should use this method for adding
    or deleting resources (e.g. containers, volumes, metadata) to the
    deployment.

    Args:
      deployment: (dict) Deployment yaml in dictionary form.
    """

  def ModifyContainer(self, container):
    """Modify a container.

    Subclasses that override this method should use this method for adding,
    deleting, or modifying any of the yaml for a container.

    Args:
      container: (dict) Container yaml in dictionary form.
    """


class AppContainerGenerator(KubeConfigGenerator):
  """Generate deployment and service for a developer's app."""

  def __init__(self,
               service_name,
               image_name,
               env_vars=None,
               env_vars_secrets=None,
               memory_limit=None,
               cpu_limit=None,
               cpu_request=None,
               readiness_probe=False):
    self._service_name = service_name
    self._image_name = image_name
    self._env_vars = env_vars
    self._env_vars_secrets = env_vars_secrets
    self._memory_limit = memory_limit
    self._cpu_limit = cpu_limit
    self._cpu_request = cpu_request
    self._readiness_probe = readiness_probe

  def CreateConfigs(self):
    deployment, container = _CreateDeployment(
        self._service_name, self._image_name, self._memory_limit,
        self._cpu_limit, self._cpu_request, self._readiness_probe)
    default_env_vars = {
        'K_SERVICE': self._service_name,
        'K_CONFIGURATION': 'dev',
        'K_REVISION': 'dev-0001',
    }

    _AddEnvironmentVariables(container, default_env_vars)
    if self._env_vars:
      _AddEnvironmentVariables(container, self._env_vars)
    if self._env_vars_secrets:
      _AddSecretEnvironmentVariables(container, self._env_vars_secrets)
    service = CreateService(self._service_name)
    return [deployment, service]


class SecretInfo(object):
  """Information about a generated secret."""

  def __init__(self):
    self.secret_name = 'local-development-credential'
    self.path = ('/etc/' + self.secret_name.replace('-', '_') +
                 '/local_development_service_account.json')


def GetServiceAccountSecret(account_name):
  """Get a service account secret file as text.

  Args:
    account_name: (str) Name of the service account.

  Returns:
    Context of the service account secret as a string.
  """
  service_account = CreateDevelopmentServiceAccount(account_name)
  return CreateServiceAccountKey(service_account)


def GetUserCredential():
  """Get a copy of the application default credential for a user.

  Returns:
    Text version of the user's application default credential.
  """
  auth_util.AssertADCExists()
  return json.dumps(auth_util.GetADCAsJson())


class CredentialGenerator(KubeConfigGenerator):
  """Configures service account secret."""

  def __init__(self, credential_fetcher):
    self._credential_fetcher = credential_fetcher

  def GetInfo(self):
    return SecretInfo()

  def CreateConfigs(self):
    """Create a secret."""
    return [LocalDevelopmentSecretSpec(self._credential_fetcher())]

  def ModifyDeployment(self, deployment):
    """Add a secret volume to a deployment."""
    secret_info = self.GetInfo()
    volumes = yaml_helper.GetOrCreate(deployment,
                                      ('spec', 'template', 'spec', 'volumes'),
                                      list)
    _AddSecretVolume(volumes, secret_info.secret_name)

  def ModifyContainer(self, container):
    """Add volume mount and set application credential environment variable."""
    secret_info = self.GetInfo()
    mounts = yaml_helper.GetOrCreate(container, ('volumeMounts',), list)
    _AddSecretVolumeMount(mounts, secret_info.secret_name)
    envs = yaml_helper.GetOrCreate(container, ('env',), list)
    _AddSecretEnvVar(envs, secret_info.path)


_CLOUD_PROXY_CONTAINER_NAME = 'cloud-sql-proxy'


class CloudSqlProxyGenerator(KubeConfigGenerator):
  """Generate kubernetes configurations for a Cloud SQL proxy connection."""

  def __init__(self, instance_names, secret_info):
    self._instance_names = instance_names
    self._secret_info = secret_info

  def ModifyDeployment(self, deployment):
    """Add sidecar container and empty volume for unix socket."""
    volumes = yaml_helper.GetOrCreate(
        deployment, ('spec', 'template', 'spec', 'volumes'), constructor=list)
    volumes.append({'name': 'cloudsql', 'emptyDir': {}})

    containers = yaml_helper.GetOrCreate(
        deployment, ('spec', 'template', 'spec', 'containers'),
        constructor=list)
    containers.append(
        _CreateCloudSqlProxyContainer(self._instance_names,
                                      self._secret_info.path))

  def ModifyContainer(self, container):
    """Add volume mount to continer.

    This method will not modify the CloudSql proxy container.

    Args:
      container: (dict) Container yaml as a dict.
    """
    if container['name'] == _CLOUD_PROXY_CONTAINER_NAME:
      return
    volume_mounts = yaml_helper.GetOrCreate(
        container, ('volumeMounts',), constructor=list)
    volume_mounts.append({
        'name': 'cloudsql',
        'mountPath': '/cloudsql',
        'readOnly': True
    })


class SecretsNotAllowedError(exceptions.Error):
  """Error thrown when the deploy is not allowed to access secret manager."""
  pass


class SecretsGenerator(KubeConfigGenerator):
  """Generate kubernetes secrets for referenced secrets."""

  def __init__(self,
               service_name,
               env_secrets,
               secret_volumes,
               namespace,
               allow_secret_manager=_FLAG_UNSPECIFIED):
    self.project_name = properties.VALUES.core.project.Get()
    self.service_name = service_name
    self.secret_volumes = secret_volumes
    self.all_secrets = {}

    secrets_builder = collections.defaultdict(set)
    for _, secret in env_secrets.items():
      secrets_builder[(secret.name, secret.mapped_secret)].add(secret.key)

    for secret in secret_volumes:
      if not secret.items:
        # This is currently unsupported for secrets pulled from secret manager.
        secrets_builder[(secret.secret_name, secret.mapped_secret)].add(
            secrets_mapping.SpecialVersion.MOUNT_ALL)
      else:
        for item in secret.items:
          secrets_builder[(secret.secret_name,
                           secret.mapped_secret)].add(item.key)

    for (secret_name, mapped_secret), versions in secrets_builder.items():
      self.all_secrets[secret_name] = secrets.SecretManagerSecret(
          name=secret_name,
          versions=frozenset(versions),
          mapped_secret=mapped_secret)
    self.namespace = namespace
    self.allow_secret_manager = allow_secret_manager

  def CreateConfigs(self):
    if not self.all_secrets:
      return []
    # If secret manager was unspecified, prompt to continue
    if self.allow_secret_manager is _FLAG_UNSPECIFIED:
      requested = []
      for key, sec in self.all_secrets.items():
        if sec.mapped_secret:
          requested.append(sec.mapped_secret)
        else:
          requested.append(key)
      secrets_msg = ('This config references secrets stored in secret manager.'
                     ' Continuing will fetch the secret values and download '
                     'the secrets to your local machine.')
      prompt_string = (
          'Fetch secrets from secret manager for {}?'.format(requested))
      # Make the service account an editor on the project
      if console_io.CanPrompt() and console_io.PromptContinue(
          message=secrets_msg, prompt_string=prompt_string):
        log.status.Print(
            'You can skip this message in the future by passing the '
            'flag --allow-secret-manager')
        self.allow_secret_manager = True

    if (not self.allow_secret_manager or
        self.allow_secret_manager is _FLAG_UNSPECIFIED):
      raise SecretsNotAllowedError(
          'Config requires secrets but access to secret manager was not '
          'allowed. Replace secrets with environment variables or '
          'allow secret manager with --allow-secret-manager to proceed.')
    return secrets.BuildSecrets(self.project_name,
                                set(self.all_secrets.values()), self.namespace)

  def ModifyDeployment(self, deployment):
    # There's only one deployment for now, but let's make sure it's the right
    # one in case there's more later
    if deployment['metadata']['name'] != self.service_name:
      return
    # If there's no volumes, don't do anything
    if not self.secret_volumes:
      return
    volumes = yaml_helper.GetOrCreate(deployment,
                                      ('spec', 'template', 'spec', 'volumes'),
                                      list)
    for volume in self.secret_volumes:
      _AddSecretVolumeByName(volumes, volume.secret_name, volume.name,
                             volume.items)

  def ModifyContainer(self, container):
    if container['name'] != '{}-container'.format(self.service_name):
      return
    if not self.secret_volumes:
      return
    mounts = yaml_helper.GetOrCreate(container, ('volumeMounts',), list)
    for volume in self.secret_volumes:
      _AddVolumeMount(mounts, volume.name, volume.mount_path)


_CLOUD_SQL_PROXY_VERSION = '1.16'


def _CreateCloudSqlProxyContainer(instances, secret_path):
  return {
      'name': _CLOUD_PROXY_CONTAINER_NAME,
      'image': 'gcr.io/cloudsql-docker/gce-proxy:' + _CLOUD_SQL_PROXY_VERSION,
      'command': ['/cloud_sql_proxy'],
      'args': [
          '-dir=/cloudsql', '-instances=' + ','.join(instances),
          '-credential_file=' + secret_path
      ],
      'volumeMounts': [{
          'name': 'cloudsql',
          'mountPath': '/cloudsql',
      }]
  }


_SECRET_TEMPLATE = """
apiVersion: v1
kind: Secret
metadata:
  name: local-development-credential
type: Opaque
"""


def CreateServiceAccountKey(service_account_name):
  """Create a service account key.

  Args:
    service_account_name: Name of service acccount.

  Returns:
    The contents of the generated private key file as a string.
  """
  default_credential_path = os.path.join(
      config.Paths().global_config_dir,
      _Utf8ToBase64(service_account_name) + '.json')
  credential_file_path = encoding.GetEncodedValue(os.environ,
                                                  'LOCAL_CREDENTIAL_PATH',
                                                  default_credential_path)
  if os.path.exists(credential_file_path):
    return files.ReadFileContents(credential_file_path)

  warning_msg = ('Creating a user-managed service account key for '
                 '{service_account_name}. This service account key will be '
                 'the default credential pointed to by '
                 'GOOGLE_APPLICATION_CREDENTIALS in the local development '
                 'environment. The user is responsible for the storage,'
                 'rotation, and deletion of this key. A copy of this key will '
                 'be stored at {local_key_path}.\n'
                 'Only use service accounts from a test project. Do not use '
                 'service accounts from a production project.').format(
                     service_account_name=service_account_name,
                     local_key_path=credential_file_path)
  console_io.PromptContinue(
      message=warning_msg, prompt_string='Continue?', cancel_on_no=True)

  service = apis.GetClientInstance('iam', 'v1')
  message_module = service.MESSAGES_MODULE

  create_key_request = (
      message_module.IamProjectsServiceAccountsKeysCreateRequest(
          name=service_account_name,
          createServiceAccountKeyRequest=message_module
          .CreateServiceAccountKeyRequest(
              privateKeyType=message_module.CreateServiceAccountKeyRequest
              .PrivateKeyTypeValueValuesEnum.TYPE_GOOGLE_CREDENTIALS_FILE)))
  key = service.projects_serviceAccounts_keys.Create(create_key_request)

  files.WriteFileContents(credential_file_path, key.privateKeyData)

  return six.ensure_text(key.privateKeyData)


def LocalDevelopmentSecretSpec(key):
  """Create a kubernetes yaml spec for a secret.

  Args:
    key: (str) The private key as a JSON string.

  Returns:
    Dictionary representing yaml dictionary.
  """
  yaml_config = yaml.load(_SECRET_TEMPLATE)
  yaml_config['data'] = {
      'local_development_service_account.json': _Utf8ToBase64(key)
  }
  return yaml_config


def _AddSecretVolume(volumes, secret_name):
  _AddSecretVolumeByName(
      volumes, secret_name=secret_name, volume_name=secret_name)


def _AddSecretVolumeByName(volumes, secret_name, volume_name, items=None):
  """Add a secret volume to a list of volumes.

  Args:
    volumes: (list[dict]) List of volume specifications.
    secret_name: (str) Name of the secret.
    volume_name: (str) Name of the volume to add.
    items: (list[_SecretPath]) Optional list of SecretPaths to map on the
      filesystem.
  """
  if any(volume['name'] == volume_name for volume in volumes):
    return
  items_lst = [
      RUN_MESSAGES_MODULE.KeyToPath(key=secpath.key, path=secpath.path)
      for secpath in (items or [])
  ]
  volumes.append(
      encoding_helper.MessageToDict(
          RUN_MESSAGES_MODULE.Volume(
              name=volume_name,
              secret=RUN_MESSAGES_MODULE.SecretVolumeSource(
                  secretName=secret_name, items=items_lst))))


def _AddSecretVolumeMount(mounts, secret_name):
  """Add a secret volume mount.

  Args:
    mounts: (list[dict]) List of volume mount dictionaries.
    secret_name: (str) Name of the secret.
  """
  _AddVolumeMount(
      mounts,
      mount_name=secret_name,
      mount_path='/etc/' + secret_name.replace('-', '_'))


def _AddVolumeMount(mounts, mount_name, mount_path):
  if any(mount_name == mount['name'] for mount in mounts):
    return
  mount = RUN_MESSAGES_MODULE.VolumeMount(
      name=mount_name, mountPath=mount_path, readOnly=True)
  mounts.append(encoding_helper.MessageToDict(mount))


def _AddSecretEnvVar(envs, path):
  """Adds a environmental variable that points to the secret file.

  Add a environment varible where GOOGLE_APPLICATION_CREDENTIALS is the name
  and the path to the secret file is the value.

  Args:
    envs: (list[dict]) List of dictionaries with a name entry and value entry.
    path: (str) Path to secret.
  """
  if 'GOOGLE_APPLICATION_CREDENTIALS' not in (var['name'] for var in envs):
    envs.append({'name': 'GOOGLE_APPLICATION_CREDENTIALS', 'value': path})


def _Utf8ToBase64(s):
  """Encode a utf-8 string as a base 64 string."""
  return six.ensure_text(base64.b64encode(six.ensure_binary(s)))


def _BuildReachableSecret(name):
  new_name = name.replace('_', '.').lower()
  u = str(uuid.uuid1())
  # make sure name is sufficiently small to fit the UUID
  new_name = new_name[:253 - len(u)] + u
  mapped = None
  if new_name != name:
    mapped = name
  return new_name, mapped
