# -*- coding: utf-8 -*- #
# Copyright 2023 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.
"""Utility for interacting with `artifacts docker upgrade` command group."""


import collections
import functools

from apitools.base.py import exceptions as apitools_exceptions
import frozendict
from google.api_core.exceptions import ResourceExhausted
from googlecloudsdk.api_lib.artifacts import exceptions as ar_exceptions
from googlecloudsdk.api_lib.asset import client_util as asset
from googlecloudsdk.api_lib.cloudresourcemanager import organizations
from googlecloudsdk.api_lib.cloudresourcemanager import projects_api as crm
from googlecloudsdk.api_lib.resource_manager import folders
from googlecloudsdk.api_lib.storage import storage_api
from googlecloudsdk.api_lib.storage import storage_util
from googlecloudsdk.api_lib.util import apis
from googlecloudsdk.command_lib.artifacts import requests as artifacts
from googlecloudsdk.command_lib.projects import util as projects_util
from googlecloudsdk.core import log
from googlecloudsdk.core.console import console_attr

_DOMAIN_TO_BUCKET_PREFIX = frozendict.frozendict({
    "gcr.io": "",
    "us.gcr.io": "us.",
    "asia.gcr.io": "asia.",
    "eu.gcr.io": "eu.",
})

_REPO_ADMIN = "roles/artifactregistry.repoAdmin"
_WRITER = "roles/artifactregistry.writer"
_READER = "roles/artifactregistry.reader"

# In order of most to least privilege, so we can grant the most privileged role.
_AR_ROLES = (_REPO_ADMIN, _WRITER, _READER)

# Set of GCS permissions for GCR that are relevant to AR.
_PERMISSIONS = (
    "storage.objects.get",
    "storage.objects.list",
    "storage.objects.create",
    "storage.objects.delete",
)

# Set of AR permissions that could used over the gcr.io endpoint
_AR_PERMISSIONS = (
    "artifactregistry.repositories.downloadArtifacts",
    "artifactregistry.repositories.uploadArtifacts",
    "artifactregistry.repositories.deleteArtifacts",
)

# Maps a GCS permission for GCR to an equivalent AR role.
_PERMISSION_TO_ROLE = frozendict.frozendict({
    "storage.objects.get": _READER,
    "storage.objects.list": _READER,
    "storage.objects.create": _WRITER,
    "storage.objects.delete": _REPO_ADMIN,
})

_AR_PERMISSIONS_TO_ROLES = [
    ("artifactregistry.repositories.downloadArtifacts", _READER),
    ("artifactregistry.repositories.uploadArtifacts", _WRITER),
    ("artifactregistry.repositories.deleteArtifacts", _REPO_ADMIN),
]

_ANALYSIS_NOT_FULLY_EXPLORED = (
    "Too many IAM policies. Analysis cannot be fully completed."
)


def bucket_suffix(project):
  chunks = project.split(":", 1)
  if len(chunks) == 2:
    # domain-scoped project
    return "{0}.{1}.a.appspot.com".format(chunks[1], chunks[0])
  return project + ".appspot.com"


def bucket_resource_name(domain, project):
  prefix = _DOMAIN_TO_BUCKET_PREFIX[domain]
  suffix = bucket_suffix(project)
  # gcloud-disable-gdu-domain
  return "//storage.googleapis.com/{0}artifacts.{1}".format(prefix, suffix)


def bucket_url(domain, project):
  prefix = _DOMAIN_TO_BUCKET_PREFIX[domain]
  suffix = bucket_suffix(project)
  return f"gs://{prefix}artifacts.{suffix}"


def project_resource_name(project):
  # gcloud-disable-gdu-domain
  return "//cloudresourcemanager.googleapis.com/projects/{0}".format(project)


def iam_policy(domain, project, use_analyze=True):
  """Generates an AR-equivalent IAM policy for a GCR registry.

  Args:
    domain: The domain of the GCR registry.
    project: The project of the GCR registry.
    use_analyze: If true, use AnalyzeIamPolicy to generate the policy

  Returns:
    An iam.Policy.

  Raises:
    Exception: A problem was encountered while generating the policy.
  """

  # Convert the map to an iam.Policy object so that gcloud can format it nicely.
  m, _ = iam_map(
      domain,
      project,
      skip_bucket=False,
      from_ar_permissions=False,
      use_analyze=use_analyze,
  )
  return policy_from_map(m)


def map_from_policy(policy):
  """Converts an iam.Policy object to a map of roles to sets of users.

  Args:
    policy: An iam.Policy object

  Returns:
    A map of roles to sets of users
  """

  role_to_members = collections.defaultdict(set)
  for binding in policy.bindings:
    role_to_members[binding.role].update(binding.members)
  return role_to_members


def policy_from_map(role_to_members):
  """Converts a map of roles to sets of users to an iam.Policy object.

  Args:
    role_to_members: A map of roles to sets of users

  Returns:
    An iam.Policy.
  """

  messages = artifacts.GetMessages()
  bindings = list()

  for role, members in role_to_members.items():
    bindings.append(
        messages.Binding(
            role=role,
            members=tuple(sorted(members)),
        )
    )
  bindings = sorted(bindings, key=lambda b: b.role)
  return messages.Policy(bindings=bindings)


@functools.lru_cache(maxsize=None)
def iam_map(
    domain,
    project,
    skip_bucket,
    from_ar_permissions,
    best_effort=False,
    use_analyze=True,
):
  """Generates an AR-equivalent IAM mapping for a GCR registry.

  Args:
    domain: The domain of the GCR registry.
    project: The project of the GCR registry.
    skip_bucket: If true, get iam policy for project instead of bucket. This can
      be useful when the bucket doesn't exist.
    from_ar_permissions: If true, use AR permissions to generate roles that
      would not need to be added to AR since user already has equivalent access
      for docker commands
    best_effort: If true, lower the scope when encountering auth errors
    use_analyze: If true, use AnalyzeIamPolicy to generate the policy

  Returns:
    (map, failures) where map is a map of roles to sets of users and
    failures is a list of scopes that failed

  Raises:
    Exception: A problem was encountered while generating the policy.
  """
  perm_to_members = None
  failures = []
  if use_analyze:
    if skip_bucket:
      resource = project_resource_name(project)
    else:
      resource = bucket_resource_name(domain, project)
    perm_to_members, failures = get_permissions_using_analyze(
        project, resource, from_ar_permissions, best_effort
    )
  else:
    if from_ar_permissions:
      perm_to_members, failures = get_permissions_with_ancestors(
          project, _AR_PERMISSIONS, best_effort=best_effort
      )
    else:
      if skip_bucket:
        perm_to_members, failures = get_permissions_with_ancestors(
            project, _PERMISSIONS, best_effort=best_effort
        )
      else:
        gcs_bucket = bucket_url(domain, project)
        perm_to_members, failures = get_permissions_with_ancestors(
            project, _PERMISSIONS, gcs_bucket, best_effort=best_effort
        )
  if perm_to_members is None:
    return None, failures

  role_to_members = collections.defaultdict(set)

  if from_ar_permissions:
    # For AR roles, provide all roles that the user has every *Artifacts
    # permission for
    members = perm_to_members[_AR_PERMISSIONS_TO_ROLES[0][0]]
    for needed_perm, role in _AR_PERMISSIONS_TO_ROLES:
      members = members.intersection(perm_to_members[needed_perm])
      for member in members:
        role_to_members[role].add(member)
    return role_to_members, failures

  # For GCR roles, provide the smallest set of roles required to grant all
  # permissions
  for perm, members in perm_to_members.items():
    role = _PERMISSION_TO_ROLE[perm]
    role_to_members[role].update(members)

  # Grant the most privileged role to a member.
  upgraded_members = set()
  final_map = collections.defaultdict(set)
  for role in _AR_ROLES:
    members = role_to_members[role]
    # Don't return deleted members. They show up in the old policies but we
    # can't copy them.
    members = {m for m in members if not m.startswith("deleted:")}
    members.difference_update(upgraded_members)
    if not members:
      continue
    upgraded_members.update(members)
    final_map[role].update(members)
  return final_map, failures


def get_permissions_using_analyze(
    project, resource, from_ar_permissions, best_effort
):
  """Returns a map of permissions to members using AnalyzeIamPolicy."""
  ancestry = crm.GetAncestry(project_id=project)
  failures = []
  analysis = None
  # Reverse the order so we go from org->project
  for num, ancestor in enumerate(reversed(ancestry.ancestor)):
    scope = resource_from_ancestor(ancestor)
    try:
      if from_ar_permissions:
        analysis = analyze_iam_policy(_AR_PERMISSIONS, resource, scope)
      else:
        analysis = analyze_iam_policy(_PERMISSIONS, resource, scope)
      break
    except apitools_exceptions.HttpForbiddenError:
      failures.append(scope)
      if not best_effort:
        raise
      if num == len(ancestry.ancestor) - 1:
        return None, failures

  # If we see any false fullyExplored, that indicates that AnalyzeIamPolicy is
  # returning incomplete information, so the generated policy might be wrong,
  # so we conservatively bail out in that case.
  if not analysis.fullyExplored or not analysis.mainAnalysis.fullyExplored:
    errors = list(err.cause for err in analysis.mainAnalysis.nonCriticalErrors)
    error_msg = "\n".join(errors)
    if not best_effort:
      raise ar_exceptions.ArtifactRegistryError(error_msg)
    warning_msg = (
        "Encountered errors when analyzing IAM policy. This may result in"
        f" incomplete bindings: {error_msg}"
    )
    con = console_attr.GetConsoleAttr()
    log.status.Print(f"{con.Colorize('Warning:','red')} {warning_msg}")

  perm_to_members = collections.defaultdict(set)
  for result in analysis.mainAnalysis.analysisResults:
    if not result.fullyExplored:
      raise ar_exceptions.ArtifactRegistryError(_ANALYSIS_NOT_FULLY_EXPLORED)

    if result.iamBinding.condition is not None and not best_effort:
      # AR doesn't support IAM conditions.
      raise ar_exceptions.ArtifactRegistryError(
          "Conditional IAM binding is not supported."
      )

    members = set()
    for member in result.iamBinding.members:
      if is_convenience(member):
        # convenience values are GCR legacy. They are not needed in AR.
        continue
      members.add(member)

    for acl in result.accessControlLists:
      for access in acl.accesses:
        perm = access.permission
        perm_to_members[perm].update(members)

  return perm_to_members, failures


def is_convenience(s):
  return (
      s.startswith("projectOwner:")
      or s.startswith("projectEditor:")
      or s.startswith("projectViewer:")
  )


def get_permissions_with_ancestors(
    project_id, permissions, gcs_bucket=None, best_effort=True
):
  roles, failures = recursive_get_roles(project_id, best_effort, gcs_bucket)
  perms, perm_failures = get_permissions(permissions, roles, best_effort)
  return perms, failures + perm_failures


def recursive_get_roles(project_id, best_effort, gcs_bucket=None):
  """Returns a map of roles to members for the given project + ancestors (and bucket if provided)."""
  ancestry = crm.GetAncestry(project_id=project_id)
  role_to_members = collections.defaultdict(set)
  if gcs_bucket:
    for binding in (
        storage_api.StorageClient()
        .GetIamPolicy(storage_util.BucketReference.FromUrl(gcs_bucket))
        .bindings
    ):
      role_to_members[binding.role].update(binding.members)

  failures = []
  for resource in reversed(ancestry.ancestor):
    bindings = []
    try:
      if resource.resourceId.type == "project":
        bindings = crm.GetIamPolicy(
            projects_util.ParseProject(project_id)
        ).bindings
      elif resource.resourceId.type == "folder":
        bindings = folders.GetIamPolicy(resource.resourceId.id).bindings
      elif resource.resourceId.type == "organization":
        bindings = (
            organizations.Client().GetIamPolicy(resource.resourceId.id).bindings
        )
      for binding in bindings:
        role_to_members[binding.role].update(binding.members)
    except apitools_exceptions.HttpForbiddenError:
      failures.append(resource.resourceId.type + "s/" + resource.resourceId.id)
      if not best_effort:
        raise
      if resource.resourceId.type == "project":
        return None, failures
  return role_to_members, failures


def get_permissions(permissions, role_map, best_effort=True):
  """Returns a map of permissions to members for the given roles.

  Args:
    permissions: The permissions to look for. All other permissions are ignored.
    role_map: A map of roles to members.
    best_effort: If true, warn instead of failing on auth errors.

  Returns:
    (map, failures) where map is a map of permissions to members and failures
    is a list of roles that failed
  """
  failures = []
  permission_map = collections.defaultdict(set)
  iam_messages = apis.GetMessagesModule("iam", "v1")
  for role, members in role_map.items():
    members = [m for m in members if not is_convenience(m)]
    # if not members:
    #  continue
    request = iam_messages.IamRolesGetRequest(name=role)
    try:
      role_permissions = set(
          apis.GetClientInstance("iam", "v1")
          .roles.Get(request)
          .includedPermissions
      )
    except apitools_exceptions.HttpForbiddenError as e:
      failures.append(role)
      if not best_effort:
        raise e
      continue
    for p in permissions:
      if p in role_permissions:
        permission_map[p].update(members)
  return permission_map, failures


def analyze_iam_policy(permissions, resource, scope):
  """Calls AnalyzeIamPolicy for the given resource.

  Args:
    permissions: for the access selector
    resource: for the resource selector
    scope: for the scope

  Returns:
    An CloudassetAnalyzeIamPolicyResponse.
  Raises:
    ResourceExhausted: If the request fails due to analyzeIamPolicy quota.
  """
  client = asset.GetClient()
  service = client.v1
  messages = asset.GetMessages()

  try:
    return service.AnalyzeIamPolicy(
        messages.CloudassetAnalyzeIamPolicyRequest(
            analysisQuery_accessSelector_permissions=permissions,
            analysisQuery_resourceSelector_fullResourceName=resource,
            scope=scope,
        )
    )
  except apitools_exceptions.HttpError as e:
    if e.status_code == 429:
      raise ar_exceptions.ArtifactRegistryError(
          "Insufficient quota for AnalyzeIamPolicy. Use --no-use-analyze-iam to"
          " generate IAM policies without using AnalyzeIamPolicy."
      )
    raise
  except ResourceExhausted:
    raise ar_exceptions.ArtifactRegistryError(
        "Insufficient quota for AnalyzeIamPolicy. Use --no-use-analyze-iam to"
        " generate IAM policies without using AnalyzeIamPolicy."
    )


def resource_from_ancestor(ancestor):
  """Converts an ancestor to a resource name.

  Args:
    ancestor: an ancestor proto return from GetAncestry

  Returns:
    The resource name of the ancestor
  """
  if ancestor.resourceId.type == "organization":
    return "organizations/{0}".format(ancestor.resourceId.id)
  if ancestor.resourceId.type == "folder":
    return "folders/{0}".format(ancestor.resourceId.id)
  if ancestor.resourceId.type == "project":
    return "projects/{0}".format(ancestor.resourceId.id)
