"""OAuth2.0 user credentials."""

import datetime
import ipaddress
import json
import multiprocessing
import os
import re
from socket import gethostbyname
import sys
from time import sleep
import threading
from urllib.parse import urlencode, urlparse, parse_qs
import wsgiref.simple_server
import wsgiref.util
import webbrowser

from filelock import FileLock
import google_auth_oauthlib.flow
import google.oauth2.credentials
import google.oauth2.id_token

from gam import controlflow
from gam import display
from gam import fileutils
from gam import transport
from gam.var import (GC_CA_FILE,
                    GC_Values,
                    GM_Globals,
                    GM_WINDOWS)
from gam import utils


MESSAGE_CONSOLE_AUTHORIZATION_PROMPT = '''\nGo to the following link in your browser:

\t{url}

IMPORTANT: If you get a browser error that the site can't be reached AFTER you
click the Allow button, copy the URL from the browser where the error occurred
and paste that here instead.
'''
MESSAGE_CONSOLE_AUTHORIZATION_CODE = 'Enter verification code or browser URL: '
MESSAGE_LOCAL_SERVER_AUTHORIZATION_PROMPT = ('\nYour browser has been opened to'
                                             ' visit:\n\n\t{url}\n\nIf your '
                                             'browser is on a different machine'
                                             ' then press CTRL+C and create a '
                                             'file called nobrowser.txt in the '
                                             'same folder as GAM.\n')
MESSAGE_LOCAL_SERVER_SUCCESS = ('The authentication flow has completed. You may'
                                ' close this browser window and return to GAM.')

MESSAGE_AUTHENTICATION_COMPLETE = ('\nThe authentication flow has completed.\n')


class CredentialsError(Exception):
    """Base error class."""
    pass


class InvalidCredentialsFileError(CredentialsError):
    """Error raised when a file cannot be opened into a credentials object."""
    pass


class EmptyCredentialsFileError(InvalidCredentialsFileError):
    """Error raised when a credentials file contains no content."""
    pass


class InvalidClientSecretsFileFormatError(CredentialsError):
    """Error raised when a client secrets file format is invalid."""
    pass


class InvalidClientSecretsFileError(CredentialsError):
    """Error raised when client secrets file cannot be read."""
    pass


class Credentials(google.oauth2.credentials.Credentials):
    """Google OAuth2.0 Credentials with GAM-specific properties and methods."""

    DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%SZ'

    def __init__(self,
                 token,
                 refresh_token=None,
                 id_token=None,
                 token_uri=None,
                 client_id=None,
                 client_secret=None,
                 scopes=None,
                 quota_project_id=None,
                 expiry=None,
                 id_token_data=None,
                 filename=None):
        """A thread-safe OAuth2.0 credentials object.

    Credentials adds additional utility properties and methods to a
    standard OAuth2.0 credentials object. When used to store credentials on
    disk, it implements a file lock to avoid collision during writes.

    Args:
      token: Optional String, The OAuth 2.0 access token. Can be None if refresh
        information is provided.
      refresh_token: String, The OAuth 2.0 refresh token. If specified,
        credentials can be refreshed.
      id_token: String, The Open ID Connect ID Token.
      token_uri: String, The OAuth 2.0 authorization server's token endpoint
        URI. Must be specified for refresh, can be left as None if the token can
        not be refreshed.
      client_id: String, The OAuth 2.0 client ID. Must be specified for refresh,
        can be left as None if the token can not be refreshed.
      client_secret: String, The OAuth 2.0 client secret. Must be specified for
        refresh, can be left as None if the token can not be refreshed.
      scopes: Sequence[str], The scopes used to obtain authorization.
        This parameter is used by :meth:`has_scopes`. OAuth 2.0 credentials can
          not request additional scopes after authorization. The scopes must be
          derivable from the refresh token if refresh information is provided
          (e.g. The refresh token scopes are a superset of this or contain a
          wild card scope like
            'https://www.googleapis.com/auth/any-api').
      quota_project_id: String, The project ID used for quota and billing. This
        project may be different from the project used to create the
        credentials.
      expiry: datetime.datetime, The time at which the provided token will
        expire.
      id_token_data: Oauth2.0 ID Token data which was previously fetched for
        this access token against the google.oauth2.id_token library.
      filename: String, Path to a file that will be used to store the
        credentials. If provided, a lock file of the same name and a ".lock"
          extension will be created for concurrency controls. Note: New
            credentials are not saved to disk until write() or refresh() are
            called.

    Raises:
      TypeError: If id_token_data is not the required dict type.
    """
        super().__init__(token=token,
                                          refresh_token=refresh_token,
                                          id_token=id_token,
                                          token_uri=token_uri,
                                          client_id=client_id,
                                          client_secret=client_secret,
                                          scopes=scopes,
                                          quota_project_id=quota_project_id)

        # Load data not restored by the super class
        self.expiry = expiry
        if id_token_data and not isinstance(id_token_data, dict):
            raise TypeError(f'Expected type id_token_data dict but received '
                            f'{type(id_token_data)}')
        self._id_token_data = id_token_data.copy() if id_token_data else None

        # If a filename is provided, use a lock file to control concurrent access
        # to the resource. If no filename is provided, use a thread lock that has
        # the same interface as FileLock in order to simplify the implementation.
        if filename:
            # Convert relative paths into absolute
            self._filename = os.path.abspath(filename)
            lock_file = os.path.abspath(f'{self._filename}.lock')
            self._lock = FileLock(lock_file)
        else:
            self._filename = None
            self._lock = _FileLikeThreadLock()

    # Use a property to prevent external mutation of the filename.
    @property
    def filename(self):
        return self._filename

    @classmethod
    def from_authorized_user_info(cls, info, filename=None):
        """Generates Credentials from JSON containing authorized user info.

    Args:
      info: Dict, authorized user info in Google format.
      filename: String, the filename used to store these credentials on disk. If
        no filename is provided, the credentials will not be saved to disk.

    Raises:
      ValueError: If missing fields are detected in the info.
    """
        # We need all of these keys
        keys_needed = {'client_id', 'client_secret'}
        # We need 1 or more of these keys
        keys_need_one_of = {'refresh_token', 'auth_token', 'token'}
        missing = keys_needed.difference(info.keys())
        has_one_of = set(info) & keys_need_one_of
        if missing or not has_one_of:
            raise ValueError(
                'Authorized user info was not in the expected format, missing '
                f'fields {", ".join(missing)} and one of '
                f'{", ".join(keys_need_one_of)}.')

        expiry = info.get('token_expiry')
        if expiry:
            # Convert the raw expiry to datetime
            expiry = datetime.datetime.strptime(expiry,
                                                Credentials.DATETIME_FORMAT)
        id_token_data = info.get('decoded_id_token')

        # Provide backwards compatibility with field names when loading from JSON.
        # Some field names may be different, depending on when/how the credentials
        # were pickled.
        return cls(token=info.get('token', info.get('auth_token', '')),
                   refresh_token=info.get('refresh_token', ''),
                   id_token=info.get('id_token_jwt', info.get('id_token')),
                   token_uri=info.get('token_uri'),
                   client_id=info['client_id'],
                   client_secret=info['client_secret'],
                   scopes=info.get('scopes'),
                   quota_project_id=info.get('quota_project_id'),
                   expiry=expiry,
                   id_token_data=id_token_data,
                   filename=filename)

    @classmethod
    def from_google_oauth2_credentials(cls, credentials, filename=None):
        """Generates Credentials from a google.oauth2.Credentials object."""
        info = json.loads(credentials.to_json())
        # Add properties which are not exported with the native to_json() output.
        info['id_token'] = credentials.id_token
        if credentials.expiry:
            info['token_expiry'] = credentials.expiry.strftime(
                Credentials.DATETIME_FORMAT)
        info['quota_project_id'] = credentials.quota_project_id

        return cls.from_authorized_user_info(info, filename=filename)

    @classmethod
    def from_credentials_file(cls, filename):
        """Generates Credentials from a stored Credentials file.

    The same file will be used to save the credentials when the access token is
    refreshed.

    Args:
      filename: String, the name of a file containing JSON credentials to load.
        The same filename will be used to save credentials back to disk.

    Returns:
      The credentials loaded from disk.

    Raises:
      InvalidCredentialsFileError: When the credentials file cannot be opened.
      EmptyCredentialsFileError: When the provided file contains no credentials.
    """
        file_content = fileutils.read_file(filename,
                                           continue_on_error=True,
                                           display_errors=False)
        if file_content is None:
            raise InvalidCredentialsFileError(
                f'File {filename} could not be opened')
        info = json.loads(file_content)
        if not info:
            raise EmptyCredentialsFileError(
                f'File {filename} contains no credential data')

        try:
            # We read the existing data from the passed in file, but we also want to
            # save future data/tokens in the same place.
            return cls.from_authorized_user_info(info, filename=filename)
        except ValueError as e:
            raise InvalidCredentialsFileError(str(e))

    @classmethod
    def from_client_secrets(cls,
                            client_id,
                            client_secret,
                            scopes,
                            access_type='offline',
                            login_hint=None,
                            filename=None,
                            use_console_flow=False):
        """Runs an OAuth Flow from client secrets to generate credentials.

    Args:
      client_id: String, The OAuth2.0 Client ID.
      client_secret: String, The OAuth2.0 Client Secret.
      scopes: Sequence[str], A list of scopes to include in the credentials.
      access_type: String, 'offline' or 'online'.  Indicates whether your
        application can refresh access tokens when the user is not present at
        the browser. Valid parameter values are online, which is the default
        value, and offline.  Set the value to offline if your application needs
        to refresh access tokens when the user is not present at the browser.
        This is the method of refreshing access tokens described later in this
        document. This value instructs the Google authorization server to return
        a refresh token and an access token the first time that your application
        exchanges an authorization code for tokens.
      login_hint: String, The email address that will be displayed on the Google
        login page as a hint for the user to login to the correct account.
      filename: String, the path to a file to use to save the credentials.
      use_console_flow: Boolean, True if the authentication flow should be run
        strictly from a console; False to launch a browser for authentication.

    Returns:
      Credentials
    """
        client_config = {
            'installed': {
                'client_id': client_id,
                'client_secret': client_secret,
                'redirect_uris': [
                    'http://localhost', 'urn:ietf:wg:oauth:2.0:oob'
                ],
                'auth_uri': 'https://accounts.google.com/o/oauth2/v2/auth',
                'token_uri': 'https://oauth2.googleapis.com/token',
            }
        }

        flow = _ShortURLFlow.from_client_config(client_config,
                                                scopes,
                                                autogenerate_code_verifier=True)
        flow_kwargs = {'access_type': access_type}
        if login_hint:
            flow_kwargs['login_hint'] = login_hint

        flow.run_dual(use_console_flow,
                **flow_kwargs)
        return cls.from_google_oauth2_credentials(flow.credentials,
                                                  filename=filename)

    @classmethod
    def from_client_secrets_file(cls,
                                 client_secrets_file,
                                 scopes,
                                 access_type='offline',
                                 login_hint=None,
                                 credentials_file=None,
                                 use_console_flow=False):
        """Runs an OAuth Flow from secrets stored on disk to generate credentials.

    Args:
      client_secrets_file: String, path to a file containing a client ID and
        secret.
      scopes: Sequence[str], A list of scopes to include in the credentials.
      access_type: String, 'offline' or 'online'.  Indicates whether your
        application can refresh access tokens when the user is not present at
        the browser. Valid parameter values are online, which is the default
        value, and offline.  Set the value to offline if your application needs
        to refresh access tokens when the user is not present at the browser.
        This is the method of refreshing access tokens described later in this
        document. This value instructs the Google authorization server to return
        a refresh token and an access token the first time that your application
        exchanges an authorization code for tokens.
      login_hint: String, The email address that will be displayed on the Google
        login page as a hint for the user to login to the correct account.
      credentials_file: String, the path to a file to use to save the
        credentials.
      use_console_flow: Boolean, True if the authentication flow should be run
        strictly from a console; False to launch a browser for authentication.

    Raises:
      InvalidClientSecretsFileError: If the client secrets file cannot be
        opened.
      InvalidClientSecretsFileFormatError: If the client secrets file does not
        contain the required data or the data is malformed.

    Returns:
      Credentials
    """
        cs_data = fileutils.read_file(client_secrets_file,
                                      continue_on_error=True,
                                      display_errors=False)
        if not cs_data:
            raise InvalidClientSecretsFileError(
                f'File {client_secrets_file} could not be opened')
        try:
            cs_json = json.loads(cs_data)
            client_id = cs_json['installed']['client_id']
            # Chop off .apps.googleusercontent.com suffix as it's not needed
            # and we need to keep things short for the Auth URL.
            client_id = re.sub(r'\.apps\.googleusercontent\.com$', '',
                               client_id)
            client_secret = cs_json['installed']['client_secret']
        except (ValueError, IndexError, KeyError):
            raise InvalidClientSecretsFileFormatError(
                f'Could not extract Client ID or Client Secret from file {client_secrets_file}'
            )

        return cls.from_client_secrets(client_id,
                                       client_secret,
                                       scopes,
                                       access_type=access_type,
                                       login_hint=login_hint,
                                       filename=credentials_file,
                                       use_console_flow=use_console_flow)

    def _fetch_id_token_data(self):
        """Fetches verification details from Google for the OAuth2.0 token.

    See more: https://developers.google.com/identity/sign-in/web/backend-auth

    Raises:
      CredentialsError: If no id_token is present.
    """
        if not self.id_token:
            raise CredentialsError(
                'Failed to fetch token data. No id_token present.')

        request = transport.create_request()
        if self.expired:
            # The id_token needs to be unexpired, in order to request data about it.
            self.refresh(request)

        self._id_token_data = google.oauth2.id_token.verify_oauth2_token(
            self.id_token, request, clock_skew_in_seconds=10)

    def get_token_value(self, field):
        """Retrieves data from the OAuth ID token.

    See more: https://developers.google.com/identity/sign-in/web/backend-auth

    Args:
      field: The name of the key/field to fetch

    Returns:
      The value associated with the given key or 'Unknown' if the key data can
      not be found in the access token data.
    """
        if not self._id_token_data:
            self._fetch_id_token_data()
        # Maintain legacy GAM behavior here to return "Unknown" if the field is
        # otherwise unpopulated.
        return self._id_token_data.get(field, 'Unknown')

    def to_json(self, strip=None):
        """Creates a JSON representation of a Credentials.

    Args:
        strip: Sequence[str], Optional list of members to exclude from the
          generated JSON.

    Returns:
        str: A JSON representation of this instance, suitable to pass to
             from_json().
    """
        expiry = self.expiry.strftime(
            Credentials.DATETIME_FORMAT) if self.expiry else None
        prep = {
            'token': self.token,
            'refresh_token': self.refresh_token,
            'token_uri': self.token_uri,
            'client_id': self.client_id,
            'client_secret': self.client_secret,
            'id_token': self.id_token,
            # Google auth doesn't currently give us scopes back on refresh.
            # 'scopes': sorted(self.scopes),
            'token_expiry': expiry,
            'decoded_id_token': self._id_token_data,
        }

        # Remove empty entries
        prep = {k: v for k, v in prep.items() if v is not None}

        # Remove entries that explicitly need to be removed
        if strip is not None:
            prep = {k: v for k, v in prep.items() if k not in strip}

        return json.dumps(prep, indent=2, sort_keys=True)

    def refresh(self, request=None):
        """Refreshes the credential's access token.

    Args:
      request: google.auth.transport.Request, The object used to make HTTP
        requests. If not provided, a default request will be used.

    Raises:
      google.auth.exceptions.RefreshError: If the credentials could not be
      refreshed.
    """
        with self._lock:
            if request is None:
                request = transport.create_request()
            self._locked_refresh(request)
            # Save the new tokens back to disk, if these credentials are disk-backed.
            if self._filename:
                self._locked_write()

    def _locked_refresh(self, request):
        """Refreshes the credential's access token while the file lock is held."""
        assert self._lock.is_locked
        super().refresh(request)

    def write(self):
        """Writes credentials to disk."""
        with self._lock:
            self._locked_write()

    def _locked_write(self):
        """Writes credentials to disk while the file lock is held."""
        assert self._lock.is_locked
        if not self.filename:
            # If no filename was provided to the constructor, these credentials cannot
            # be saved to disk.
            raise CredentialsError(
                'The credentials have no associated filename and cannot be saved '
                'to disk.')
        fileutils.write_file(self._filename, self.to_json())

    def delete(self):
        """Deletes all files on disk related to these credentials."""
        with self._lock:
            # Only attempt to remove the file if the lock we're using is a FileLock.
            if isinstance(self._lock, FileLock):
                os.remove(self._filename)
                if self._lock.lock_file and not GM_Globals[GM_WINDOWS]:
                    os.remove(self._lock.lock_file)

    _REVOKE_TOKEN_BASE_URI = 'https://accounts.google.com/o/oauth2/revoke'

    def revoke(self, http=None):
        """Revokes this credential's access token with the server.

    Args:
      http: httplib2.Http compatible object for use as a transport. If no http
        is provided, a default will be used.
    """
        with self._lock:
            if http is None:
                http = transport.create_http()
            params = urlencode({'token': self.refresh_token})
            revoke_uri = f'{Credentials._REVOKE_TOKEN_BASE_URI}?{params}'
            http.request(revoke_uri, 'GET')


def _localhost_to_ip():
    '''returns IPv4 or IPv6 loopback address which localhost resolves to.
       If localhost does not resolve to valid loopback IP address then returns
       127.0.0.1'''
    # TODO gethostbyname() will only ever return ipv4
    # find a way to support IPv6 here and get preferred IP
    # note that IPv6 may be broken on some systems also :-(
    # for now IPv4 should do.
    local_ip = gethostbyname('localhost')
    local_ipaddress = ipaddress.ip_address(local_ip)
    ip4_local_range = ipaddress.ip_network('127.0.0.0/8')
    ip6_local_range = ipaddress.ip_network('::1/128')
    if local_ipaddress not in ip4_local_range and \
       local_ipaddress not in ip6_local_range:
           local_ip = '127.0.0.1'
    return local_ip

def _wait_for_http_client(d):
    wsgi_app = google_auth_oauthlib.flow._RedirectWSGIApp(MESSAGE_LOCAL_SERVER_SUCCESS)
    wsgiref.simple_server.WSGIServer.allow_reuse_address = False
    # Convert hostn to IP since apparently binding to the IP
    # reduces odds of firewall blocking us
    local_ip = _localhost_to_ip()
    for port in range(8080, 8099):
        try:
            local_server = wsgiref.simple_server.make_server(
              local_ip,
              port,
              wsgi_app,
              handler_class=wsgiref.simple_server.WSGIRequestHandler
              )
            break
        except OSError:
            pass
    redirect_uri_format = (
        "http://{}:{}/" if d['trailing_slash'] else "http://{}:{}"
    )
    # provide redirect_uri to main process so it can formulate auth_url
    d['redirect_uri'] = redirect_uri_format.format(*local_server.server_address)
    # wait until main process provides auth_url
    # so we can open it in web browser.
    while 'auth_url' not in d:
        sleep(0.1)
    if d['open_browser']:
        webbrowser.open(d['auth_url'], new=1, autoraise=True)
    local_server.handle_request()
    authorization_response = wsgi_app.last_request_uri.replace("http", "https")
    d['code'] = authorization_response
    local_server.server_close()


def _wait_for_user_input(d):
    sys.stdin = open(0)
    code = input(MESSAGE_CONSOLE_AUTHORIZATION_CODE)
    d['code'] = code


class _ShortURLFlow(google_auth_oauthlib.flow.InstalledAppFlow):
    """InstalledAppFlow which utilizes a URL shortener for authorization URLs."""


    def authorization_url(self, http=None, **kwargs):
        """Gets a shortened authorization URL."""
        long_url, state = super().authorization_url(**kwargs)
        short_url = utils.shorten_url(long_url)
        return short_url, state


    def run_dual(self,
                use_console_flow,
                authorization_prompt_message='',
                console_prompt_message='',
                web_success_message='',
                open_browser=True,
                redirect_uri_trailing_slash=True,
                **kwargs):
        mgr = multiprocessing.Manager()
        d = mgr.dict()
        d['trailing_slash'] = redirect_uri_trailing_slash
        d['open_browser'] = use_console_flow
        http_client = multiprocessing.Process(target=_wait_for_http_client,
                                              args=(d,))
        user_input = multiprocessing.Process(target=_wait_for_user_input,
                                             args=(d,))
        http_client.start()
        # we need to wait until web server starts on avail port
        # so we know redirect_uri to use
        while 'redirect_uri' not in d:
            sleep(0.1)
        self.redirect_uri = d['redirect_uri']
        d['auth_url'], _ = self.authorization_url(**kwargs)
        print(MESSAGE_CONSOLE_AUTHORIZATION_PROMPT.format(url=d['auth_url']))
        user_input.start()
        userInput = False
        while True:
            sleep(0.1)
            if not http_client.is_alive():
                user_input.terminate()
                break
            elif not user_input.is_alive():
                userInput = True
                http_client.terminate()
                break
        while True:
            code = d['code']
            if code.startswith('http'):
                parsed_url = urlparse(code)
                parsed_params = parse_qs(parsed_url.query)
                code = parsed_params.get('code', [None])[0]
            try:
                fetch_args = {'code': code}
                if GC_Values.get(GC_CA_FILE):
                    fetch_args['verify'] = GC_Values.get(GC_CA_FILE)
                self.fetch_token(**fetch_args)
                break
            except Exception as e:
                if not userInput:
                    controlflow.system_error_exit(8, str(e))
                display.print_error(str(e))
                _wait_for_user_input(d)
        sys.stdout.write(MESSAGE_AUTHENTICATION_COMPLETE)
        return self.credentials

class _FileLikeThreadLock:
    """A threading.lock which has the same interface as filelock.Filelock."""

    def __init__(self):
        """A shell object that holds a threading.Lock.

    Since we cannot inherit from built-in classes such as threading.Lock, we
    just use a shell object and maintain a lock inside of it.
    """
        self._lock = threading.Lock()

    def __enter__(self, *args, **kwargs):
        return self._lock.__enter__(*args, **kwargs)

    def __exit__(self, *args, **kwargs):
        return self._lock.__exit__(*args, **kwargs)

    def acquire(self, **kwargs):
        return self._lock.acquire(**kwargs)

    def release(self):
        return self._lock.release()

    @property
    def is_locked(self):
        return self._lock.locked()

    @property
    def lock_file(self):
        return None
