# -*- coding: utf-8 -*- #
# Copyright 2016 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.

"""A library to support auth commands."""


import abc
import json
import textwrap

from googlecloudsdk.command_lib.util import check_browser
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.credentials import creds
from googlecloudsdk.core.util import files
import six

# Client ID from project "usable-auth-library", configured for
# general purpose API testing
# pylint: disable=g-line-too-long
DEFAULT_CREDENTIALS_DEFAULT_CLIENT_ID = '764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com'
DEFAULT_CREDENTIALS_DEFAULT_CLIENT_SECRET = 'd-FL95Q19q7MQmFpd7hHD0Ty'
CLOUD_PLATFORM_SCOPE = 'https://www.googleapis.com/auth/cloud-platform'
SQL_LOGIN_SCOPE = 'https://www.googleapis.com/auth/sqlservice.login'
GOOGLE_DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive'
USER_EMAIL_SCOPE = 'https://www.googleapis.com/auth/userinfo.email'
OPENID = 'openid'

DEFAULT_SCOPES = [
    OPENID,
    USER_EMAIL_SCOPE,
    CLOUD_PLATFORM_SCOPE,
    SQL_LOGIN_SCOPE
]

CLIENT_SECRET_INSTALLED_TYPE = 'installed'


class Error(exceptions.Error):
  """A base exception for this class."""
  pass


class InvalidClientSecretsError(Error):
  """An error for when we fail to load the client secrets file."""
  pass


class BadCredentialFileException(Error):
  """Raised when credentials file cannot be read."""
  pass


def GetCredentialsConfigFromFile(filename):
  """Returns the JSON content of a credentials config file.

  This function is useful when the content of a file need to be inspected first
  before determining how to handle it (how to initialize the underlying
  credentials). Only UTF-8 JSON files are supported.

  Args:
    filename (str): The filepath to the ADC file representing credentials.

  Returns:
    Optional(Mapping): The JSON content.

  Raises:
    BadCredentialFileException: If JSON parsing of the file fails.
  """

  try:
    # YAML is a superset of JSON.
    content = yaml.load_path(filename)
  except UnicodeDecodeError as e:
    raise BadCredentialFileException(
        'File {0} is not utf-8 encoded: {1}'.format(filename, e))
  except yaml.YAMLParseError as e:
    raise BadCredentialFileException('Could not read json file {0}: {1}'.format(
        filename, e))

  # Require the JSON content to be an object.
  # Credentials and configs are always objects.
  if not isinstance(content, dict):
    raise BadCredentialFileException(
        'Could not read json file {0}'.format(filename))
  return content


def _HandleFlowError(exc, default_help_msg):
  """Prints help messages when auth flow throws errors."""
  # pylint: disable=g-import-not-at-top
  from googlecloudsdk.core import context_aware
  # pylint: enable=g-import-not-at-top
  if context_aware.IsContextAwareAccessDeniedError(exc):
    log.error(context_aware.ContextAwareAccessError.Get())
  else:
    log.error(default_help_msg)


class FlowRunner(six.with_metaclass(abc.ABCMeta, object)):
  """Base auth flow runner class.

  Attributes:
     _scopes: [str], The list of scopes to authorize.
     _client_config: The client configuration in the Google client secrets
       format.
  """

  _FLOW_ERROR_HELP_MSG = 'There was a problem with web authentication.'

  def __init__(self, scopes, client_config, redirect_uri=None):
    self._scopes = scopes
    self._client_config = client_config
    self._redirect_uri = redirect_uri
    self._flow = self._CreateFlow()

  @abc.abstractmethod
  def _CreateFlow(self):
    pass

  def Run(self, **kwargs):
    # pylint: disable=g-import-not-at-top
    from googlecloudsdk.core.credentials import flow as c_flow
    # pylint: enable=g-import-not-at-top
    try:
      return self._flow.Run(**kwargs)
    except c_flow.Error as e:
      _HandleFlowError(e, self._FLOW_ERROR_HELP_MSG)
      raise


class OobFlowRunner(FlowRunner):
  """A flow runner to run OobFlow."""

  def _CreateFlow(self):
    # pylint: disable=g-import-not-at-top
    from googlecloudsdk.core.credentials import flow as c_flow
    # pylint: enable=g-import-not-at-top
    return c_flow.OobFlow.from_client_config(
        self._client_config,
        self._scopes,
        autogenerate_code_verifier=not properties.VALUES.auth
        .disable_code_verifier.GetBool())


class NoBrowserFlowRunner(FlowRunner):
  """A flow runner to run NoBrowserFlow."""

  def _CreateFlow(self):
    # pylint: disable=g-import-not-at-top
    from googlecloudsdk.core.credentials import flow as c_flow
    # pylint: enable=g-import-not-at-top
    return c_flow.NoBrowserFlow.from_client_config(
        self._client_config,
        self._scopes,
        autogenerate_code_verifier=not properties.VALUES.auth
        .disable_code_verifier.GetBool())


class RemoteLoginWithAuthProxyFlowRunner(FlowRunner):
  """A flow runner to run RemoteLoginWithAuthProxyFlow."""

  def _CreateFlow(self):
    # pylint: disable=g-import-not-at-top
    from googlecloudsdk.core.credentials import flow as c_flow
    # pylint: enable=g-import-not-at-top
    return c_flow.RemoteLoginWithAuthProxyFlow.from_client_config(
        self._client_config,
        self._scopes,
        autogenerate_code_verifier=not properties.VALUES.auth
        .disable_code_verifier.GetBool(),
        redirect_uri=self._redirect_uri)


class NoBrowserHelperRunner(FlowRunner):
  """A flow runner to run NoBrowserHelperFlow."""

  def _CreateFlow(self):
    # pylint: disable=g-import-not-at-top
    from googlecloudsdk.core.credentials import flow as c_flow
    # pylint: enable=g-import-not-at-top
    try:
      return c_flow.NoBrowserHelperFlow.from_client_config(
          self._client_config,
          self._scopes,
          autogenerate_code_verifier=not properties.VALUES.auth
          .disable_code_verifier.GetBool())
    except c_flow.LocalServerCreationError:
      log.error('Cannot start a local server to handle authorization '
                'redirection. Please run this command on a machine where '
                'gcloud can start a local server.')
      raise


class BrowserFlowWithOobFallbackRunner(FlowRunner):
  """A flow runner to try normal web flow and fall back to oob flow."""

  _FLOW_ERROR_HELP_MSG = ('There was a problem with web authentication. '
                          'Try running again with --no-launch-browser.')

  def _CreateFlow(self):
    # pylint: disable=g-import-not-at-top
    from googlecloudsdk.core.credentials import flow as c_flow
    # pylint: enable=g-import-not-at-top
    try:
      return c_flow.FullWebFlow.from_client_config(
          self._client_config,
          self._scopes,
          autogenerate_code_verifier=not properties.VALUES.auth
          .disable_code_verifier.GetBool())
    except c_flow.LocalServerCreationError as e:
      log.warning(e)
      log.warning('Defaulting to URL copy/paste mode.')
      return c_flow.OobFlow.from_client_config(
          self._client_config,
          self._scopes,
          autogenerate_code_verifier=not properties.VALUES.auth
          .disable_code_verifier.GetBool())


class BrowserFlowWithNoBrowserFallbackRunner(FlowRunner):
  """A flow runner to try normal web flow and fall back to NoBrowser flow."""

  _FLOW_ERROR_HELP_MSG = ('There was a problem with web authentication. '
                          'Try running again with --no-browser.')

  def _CreateFlow(self):
    # pylint: disable=g-import-not-at-top
    from googlecloudsdk.core.credentials import flow as c_flow
    # pylint: enable=g-import-not-at-top
    try:
      return c_flow.FullWebFlow.from_client_config(
          self._client_config,
          self._scopes,
          autogenerate_code_verifier=not properties.VALUES.auth
          .disable_code_verifier.GetBool())
    except c_flow.LocalServerCreationError as e:
      log.warning(e)
      log.warning('Defaulting to --no-browser mode.')
      return c_flow.NoBrowserFlow.from_client_config(
          self._client_config,
          self._scopes,
          autogenerate_code_verifier=not properties.VALUES.auth
          .disable_code_verifier.GetBool())


def _CreateGoogleAuthClientConfig(client_id_file=None):
  """Creates a client config from a client id file or gcloud's properties."""
  if client_id_file:
    with files.FileReader(client_id_file) as f:
      return json.load(f)
  return _CreateGoogleAuthClientConfigFromProperties()


def _CreateGoogleAuthClientConfigFromProperties():
  """Creates a client config from gcloud's properties."""
  auth_uri = properties.VALUES.auth.auth_host.Get(required=True)
  token_uri = creds.GetDefaultTokenUri()

  client_id = properties.VALUES.auth.client_id.Get(required=True)
  client_secret = properties.VALUES.auth.client_secret.Get(required=True)
  return {
      'installed': {
          'client_id': client_id,
          'client_secret': client_secret,
          'auth_uri': auth_uri,
          'token_uri': token_uri
      }
  }


def _IsGoogleOwnedClientID(client_config):
  return (client_config['installed']['client_id']
          in (config.CLOUDSDK_CLIENT_ID, DEFAULT_CREDENTIALS_DEFAULT_CLIENT_ID))


def DoInstalledAppBrowserFlowGoogleAuth(scopes,
                                        client_id_file=None,
                                        client_config=None,
                                        no_launch_browser=False,
                                        no_browser=False,
                                        remote_bootstrap=None,
                                        query_params=None,
                                        auth_proxy_redirect_uri=None):
  """Launches a 3LO oauth2 flow to get google-auth credentials.

  Args:
    scopes: [str], The list of scopes to authorize.
    client_id_file: str, The path to a file containing the client id and secret
      to use for the flow.  If None, the default client id for the Cloud SDK is
      used.
    client_config: Optional[Mapping], the client secrets and urls that should be
      used for the OAuth flow.
    no_launch_browser: bool, True if users specify --no-launch-browser flag to
      use the remote login with auth proxy flow.
    no_browser: bool, True if users specify --no-browser flag to ask another
      gcloud instance to help with authorization.
    remote_bootstrap: str, The auth parameters specified by --remote-bootstrap
      flag. Once used, it means the command is to help authorize another
      gcloud (i.e. gcloud without access to browser).
    query_params: Optional[Mapping], extra params to pass to the flow during
      `Run`. These params end up getting used as query
      params for authorization_url.
    auth_proxy_redirect_uri: str, The uri where OAuth service will redirect the
      user to once the authentication is complete for a remote login with auth
      proxy flow.
  Returns:
    core.credentials.google_auth_credentials.Credentials, The credentials
      obtained from the flow.
  """
  # pylint: disable=g-import-not-at-top
  from google.auth import external_account_authorized_user
  from google.oauth2 import credentials as oauth2_credentials
  from googlecloudsdk.core.credentials import flow as c_flow
  # pylint: enable=g-import-not-at-top

  if client_id_file:
    AssertClientSecretIsInstalledType(client_id_file)
  if not client_config:
    client_config = _CreateGoogleAuthClientConfig(client_id_file)
  if not query_params:
    query_params = {}
  can_launch_browser = check_browser.ShouldLaunchBrowser(
      attempt_launch_browser=True)
  if no_browser:
    user_creds = NoBrowserFlowRunner(scopes, client_config).Run(**query_params)
  elif remote_bootstrap:
    if not can_launch_browser:
      raise c_flow.WebBrowserInaccessible(
          'Cannot launch browser. Please run this command on a machine '
          'where gcloud can launch a web browser.')
    user_creds = NoBrowserHelperRunner(scopes, client_config).Run(
        partial_auth_url=remote_bootstrap, **query_params)
  elif no_launch_browser:
    user_creds = RemoteLoginWithAuthProxyFlowRunner(
        scopes, client_config, auth_proxy_redirect_uri
    ).Run(**query_params)
  elif not can_launch_browser:
    # RemoteLoginWithAuthProxyFlowrunner uses redirect_uri for https://sdk.cloud.google.com
    # which is intended for google-owned client only.
    # Non-google-owned clients can only use NoBrowserFlowRunner.
    if client_id_file and not _IsGoogleOwnedClientID(client_config):
      user_creds = NoBrowserFlowRunner(scopes, client_config).Run(
          **query_params
      )
    else:
      user_creds = RemoteLoginWithAuthProxyFlowRunner(
          scopes, client_config, auth_proxy_redirect_uri
      ).Run(**query_params)
  else:
    user_creds = BrowserFlowWithNoBrowserFallbackRunner(
        scopes, client_config).Run(**query_params)
  if user_creds:
    if isinstance(user_creds, oauth2_credentials.Credentials):
      # c_google_auth.Credentials adds reauth capabilities to oauth2
      # credentials, which is needed as they are long-term credentials.
      # pylint: disable=g-import-not-at-top
      from googlecloudsdk.core.credentials import google_auth_credentials as c_google_auth
      # pylint: enable=g-import-not-at-top
      return c_google_auth.Credentials.FromGoogleAuthUserCredentials(user_creds)
    if isinstance(user_creds, external_account_authorized_user.Credentials):
      return user_creds


def AssertClientSecretIsInstalledType(client_id_file):
  """Assert that the file is a valid json file for installed application."""
  actionable_message = (
      'To obtain a valid client ID file, create a Desktop App following'
      ' the steps outlined in'
      ' https://support.google.com/cloud/answer/6158849?hl=en#zippy=%2Cnative-applications%2Cdesktop-apps.'
  )
  try:
    obj = json.loads(files.ReadFileContents(client_id_file))
  except files.Error:
    raise InvalidClientSecretsError(f'Cannot read file: "{client_id_file}".')
  except json.JSONDecodeError:
    raise InvalidClientSecretsError(
        f'Client ID file {client_id_file} is not a valid JSON file.'
        f' {actionable_message}'
    )
  if len(obj) != 1:
    raise InvalidClientSecretsError(
        'Expected a JSON object with a single property for an "installed"'
        f' application. {actionable_message}'
    )
  client_type = tuple(obj)[0]
  if client_type != CLIENT_SECRET_INSTALLED_TYPE:
    raise InvalidClientSecretsError(
        f"Only client IDs of type '{CLIENT_SECRET_INSTALLED_TYPE}' are allowed,"
        f" but encountered type '{client_type}'. {actionable_message}"
    )


def HandleUniverseDomainConflict(new_universe_domain, account):
  """Prompt the user to update the universe domain if there is conflict.

  If the given universe domain is different from the core/universe_domain
  property, prompt the user to update the core/universe_domain property.

  Args:
    new_universe_domain: str, The given new universe domain.
    account: str, The account name to use.
  """
  current_universe_domain = properties.VALUES.core.universe_domain.Get()
  if current_universe_domain == new_universe_domain:
    return

  message = textwrap.dedent("""\
        WARNING: This account [{0}] is from the universe domain [{1}],
        which does not match the current core/universe property [{2}].\n
        Do you want to set property [core/universe_domain] to [{1}]? [Y/N]
        """).format(account, new_universe_domain, current_universe_domain)
  should_update_universe_domain = console_io.PromptContinue(message=message)

  if should_update_universe_domain:
    properties.PersistProperty(
        properties.VALUES.core.universe_domain, new_universe_domain
    )
    log.status.Print('Updated property [core/universe_domain].')
