# ##########################################################################
# #
# # pgAdmin 4 - PostgreSQL Tools
# #
# # Copyright (C) 2013 - 2026, The pgAdmin Development Team
# # This software is released under the PostgreSQL Licence
# #
# ##########################################################################

# Google Cloud Deployment Implementation
import json
import os
from urllib.parse import unquote

from config import root
from pgadmin.utils.csrf import pgCSRFProtect
from pgadmin.utils.ajax import plain_text_response, unauthorized, \
    make_json_response, bad_request
from pgadmin.misc.bgprocess import BatchProcess
from pgadmin.misc.cloud.utils import _create_server, CloudProcessDesc
from pgadmin.utils import PgAdminModule, filename_with_file_manager_path
from pgadmin.utils.text_sanitize import sanitize_external_text
from pgadmin.user_login_check import pga_login_required
from flask import session, current_app, request
from flask_babel import gettext as _

from oauthlib.oauth2 import AccessDeniedError
from googleapiclient import discovery
from googleapiclient.errors import HttpError
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request

MODULE_NAME = 'google'
os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1'  # Required for Oauth2


class GooglePostgresqlModule(PgAdminModule):
    """Cloud module to deploy on Google Cloud"""

    def get_exposed_url_endpoints(self):
        return ['google.verify_credentials',
                'google.projects',
                'google.regions',
                'google.database_versions',
                'google.instance_types',
                'google.availability_zones',
                'google.verification_ack',
                'google.callback']


blueprint = GooglePostgresqlModule(MODULE_NAME, __name__,
                                   static_url_path='/misc/cloud/google')


def _get_google_from_session():
    """Build a Google instance from `session['google']['state']`.

    Returns None when no Google state has been seeded yet — callers should
    treat that as "auth not yet started."

    The previous implementation persisted a live Google instance via an
    unsafe serializer, which represented an insecure-deserialization
    vector. We now store only a JSON-safe dict and rebuild on demand.
    """
    google = session.get('google')
    if not google:
        return None
    state = google.get('state')
    if not state:
        return None
    return Google.from_state(state)


def _save_google_to_session(google_obj):
    """Persist the given Google instance's state into the session."""
    if 'google' not in session:
        session['google'] = {}
    session['google']['state'] = google_obj.to_state()


@blueprint.route("/")
@pga_login_required
def index():
    return bad_request(errormsg=_("This URL cannot be called directly."))


@blueprint.route('/verify_credentials/',
                 methods=['POST'], endpoint='verify_credentials')
@pga_login_required
def verify_credentials():
    """
    Initiate process of authorisation for google oauth2
    """
    data = json.loads(request.data)
    client_secret_path = data['secret']['client_secret_file'] if \
        'client_secret_file' in data['secret'] else None
    status = False
    error = None
    res_data = {}

    client_secret_path = unquote(client_secret_path)
    try:
        client_secret_path = \
            filename_with_file_manager_path(client_secret_path)
    except PermissionError as e:
        return unauthorized(errormsg=sanitize_external_text(str(e)))
    except Exception as e:
        return bad_request(errormsg=sanitize_external_text(str(e)))

    if client_secret_path and os.path.exists(client_secret_path):
        with open(client_secret_path, 'r') as json_file:
            client_config = json.load(json_file)

        if 'google' not in session:
            session['google'] = {}

        # Reuse cached Google when client_config hasn't changed; otherwise
        # start fresh (e.g., the user picked a different secret file).
        cached_google = _get_google_from_session()
        if cached_google is not None and \
                session['google'].get('client_config') == client_config:
            _google = cached_google
        else:
            _google = Google(client_config)

        # get auth url
        host_url = request.origin + '/'
        if request.root_path != '':
            host_url = host_url + request.root_path + '/'

        auth_url, error_msg = _google.get_auth_url(host_url)
        if error_msg:
            error = error_msg
        else:
            status = True
            res_data = {'auth_url': auth_url}
        session['google']['client_config'] = client_config
        _save_google_to_session(_google)
    else:
        error = 'Client secret path not found'
        session.pop('google', None)

    return make_json_response(
        success=status,
        errormsg=sanitize_external_text(error),
        data=res_data)


@blueprint.route('/callback',
                 methods=['GET'], endpoint='callback')
@pgCSRFProtect.exempt
@pga_login_required
def callback():
    """
    Call back function on google authentication response.
    :return:
    """
    google_obj = _get_google_from_session()
    if google_obj is None:
        return plain_text_response(
            'Authentication session expired. Please try again.')
    res = google_obj.callback(request)
    _save_google_to_session(google_obj)
    return plain_text_response(res)


@blueprint.route('/verification_ack',
                 methods=['GET'], endpoint='verification_ack')
@pga_login_required
def verification_ack():
    """
    Checks for google oauth2 authorisation confirmation
    :return:
    """
    verified = False
    google_obj = _get_google_from_session()
    if google_obj is not None:
        verified, error = google_obj.verification_ack()
        _save_google_to_session(google_obj)
        return make_json_response(
            success=verified, errormsg=sanitize_external_text(error))
    else:
        return make_json_response(success=verified,
                                  errormsg='Authentication is failed.')


@blueprint.route('/projects/',
                 methods=['GET'], endpoint='projects')
@pga_login_required
def get_projects():
    """
    Lists the projects for authorized user
    :return: list of projects
    """
    google_obj = _get_google_from_session()
    if google_obj is not None:
        projects_list, error = google_obj.get_projects()
        if error:
            return bad_request(errormsg=sanitize_external_text(error))
        return make_json_response(data=projects_list)


@blueprint.route('/regions/<project_id>',
                 methods=['GET'], endpoint='regions')
@pga_login_required
def get_regions(project_id):
    """
    Lists regions based on project for authorized user
    :param project_id: google project id
    :return: google cloud sql region list
    """
    google_obj = _get_google_from_session()
    if google_obj is not None and project_id:
        regions_list, error = google_obj.get_regions(project_id)
        _save_google_to_session(google_obj)
        if error:
            return bad_request(errormsg=sanitize_external_text(error))
        return make_json_response(data=regions_list)
    else:
        return make_json_response(data=[])


@blueprint.route('/availability_zones/<region>',
                 methods=['GET'], endpoint='availability_zones')
@pga_login_required
def get_availability_zones(region):
    """
    List availability zones for specified region
    :param region: google region
    :return: google cloud sql availability zone list
    """
    google_obj = _get_google_from_session()
    if google_obj is not None and region:
        availability_zone_list = google_obj.get_availability_zones(region)
        return make_json_response(data=availability_zone_list)
    else:
        return make_json_response(data=[])


@blueprint.route('/instance_types/<project_id>/<region>/<instance_class>',
                 methods=['GET'], endpoint='instance_types')
@pga_login_required
def get_instance_types(project_id, region, instance_class):
    """
    List the instances types for specified google project, region &
    instance type
    :param project_id: google project id
    :param region: google cloud region
    :param instance_class: google cloud sql instnace class
    :return:
    """
    google_obj = _get_google_from_session()
    if google_obj is not None and project_id and region:
        instance_types_dict = google_obj.get_instance_types(
            project_id, region)
        instance_types_list, error = (
            instance_types_dict.get(instance_class, []))
        if error:
            return bad_request(errormsg=sanitize_external_text(error))
        return make_json_response(data=instance_types_list)
    else:
        return make_json_response(data=[])


@blueprint.route('/database_versions/',
                 methods=['GET'], endpoint='database_versions')
@pga_login_required
def get_database_versions():
    """
    Lists the postgresql database versions.
    :return: PostgreSQL version list
    """
    google_obj = _get_google_from_session()
    if google_obj is not None:
        db_version_list, error = google_obj.get_database_versions()
        if error:
            return bad_request(errormsg=sanitize_external_text(error))
        return make_json_response(data=db_version_list)
    else:
        return make_json_response(data=[])


def deploy_on_google(data):
    """Deploy the Postgres instance on RDS."""
    _cmd = 'python'
    _cmd_script = '{0}/pgacloud/pgacloud.py'.format(root)
    _label = data['instance_details']['name']

    # Supported arguments for google cloud sql deployment
    args = [_cmd_script,
            data['cloud'],
            'create-instance',

            '--project', data['instance_details']['project'],

            '--region', data['instance_details']['region'],

            '--name', data['instance_details']['name'],

            '--db-version', data['instance_details']['db_version'],

            '--instance-type', data['instance_details']['instance_type'],

            '--storage-type', data['instance_details']['storage_type'],

            '--storage-size', str(data['instance_details']['storage_size']),

            '--public-ip', str(data['instance_details']['public_ips']),

            '--availability-zone',
            data['instance_details']['availability_zone'],

            '--high-availability',
            str(data['instance_details']['high_availability']),

            '--secondary-availability-zone',
            data['instance_details']['secondary_availability_zone'],
            ]

    _cmd_msg = '{0} {1} {2}'.format(_cmd, _cmd_script, ' '.join(args))
    try:
        sid = _create_server({
            'gid': data['db_details']['gid'],
            'name': data['instance_details']['name'],
            'db': 'postgres',
            'username': 'postgres',
            'port': 5432,
            'cloud_status': -1
        })

        p = BatchProcess(
            desc=CloudProcessDesc(sid, _cmd_msg, data['cloud'],
                                  data['instance_details']['name']),
            cmd=_cmd,
            args=args
        )

        # Set env variables for background process of deployment
        env = dict()
        google_obj = _get_google_from_session()
        if google_obj is None or google_obj.credentials_json is None:
            return False, None, 'Google credentials missing from session.'
        env['GOOGLE_CREDENTIALS'] = json.dumps(google_obj.credentials_json)

        if 'db_password' in data['db_details']:
            env['GOOGLE_DATABASE_PASSWORD'] = data['db_details']['db_password']

        p.set_env_variables(None, env=env)
        p.update_server_id(p.id, sid)
        p.start()

        return True, p, {'label': _label, 'sid': sid}
    except Exception as e:
        current_app.logger.exception(e)
        return False, None, str(e)


def clear_google_session():
    """Clear Google Session"""
    if 'google' in session:
        session.pop('google')


class Google:
    def __init__(self, client_config=None):
        # Google cloud sql api versions
        self._cloud_resource_manager_api_version = 'v1'
        self._sqladmin_api_version = 'v1'
        self._compute_api_version = 'v1'

        # Scope required for google cloud sql deployment
        self._scopes = ['https://www.googleapis.com/auth/cloud-platform',
                        'https://www.googleapis.com/auth/sqlservice.admin']

        # Instance classed
        self._instance_classes = [{'label': 'Standard', 'value': 'standard'},
                                  {'label': 'High Memory', 'value': 'highmem'},
                                  {'label': 'Shared', 'value': 'shared'}]

        self._client_config = client_config
        self._credentials = None
        self.credentials_json = None
        self._project_id = None
        self._regions = []
        self._availability_zones = {}
        self._verification_successful = False
        self._verification_error = None
        self._redirect_url = None

    def to_state(self):
        """Serialize the persistable state of this Google instance to a
        plain dict for storage in `flask.session`.

        Live SDK objects (like `_credentials`) are intentionally NOT
        included — they are rebuilt from `credentials_json` in
        `from_state()`. Storing only data primitives lets the session
        layer use a safe (non-binary-serializer) format.
        """
        return {
            'client_config': self._client_config,
            'credentials_json': self.credentials_json,
            'redirect_url': self._redirect_url,
            'project_id': self._project_id,
            'regions': self._regions,
            'availability_zones': self._availability_zones,
            'verification_successful': self._verification_successful,
            'verification_error': self._verification_error,
        }

    @classmethod
    def from_state(cls, state):
        """Rebuild a Google instance from a previously-serialized dict.

        The reverse of `to_state()`. The Google SDK credentials object is
        reconstructed from the stored token dict so subsequent API calls
        work without forcing the user back through the OAuth2 flow.
        """
        if not isinstance(state, dict):
            return None
        obj = cls(client_config=state.get('client_config'))
        obj.credentials_json = state.get('credentials_json')
        if obj.credentials_json:
            # Rebuild google.oauth2.credentials.Credentials from the
            # persisted token dict. Credentials() ignores unknown kwargs,
            # so id_token (kept in the dict for API parity) is dropped.
            from google.oauth2.credentials import Credentials
            obj._credentials = Credentials(
                token=obj.credentials_json.get('token'),
                refresh_token=obj.credentials_json.get('refresh_token'),
                token_uri=obj.credentials_json.get('token_uri'),
                client_id=obj.credentials_json.get('client_id'),
                client_secret=obj.credentials_json.get('client_secret'),
                scopes=obj.credentials_json.get('scopes'),
            )
        obj._redirect_url = state.get('redirect_url')
        obj._project_id = state.get('project_id')
        obj._regions = state.get('regions', []) or []
        obj._availability_zones = state.get('availability_zones', {}) or {}
        obj._verification_successful = bool(
            state.get('verification_successful', False))
        obj._verification_error = state.get('verification_error')
        return obj

    def get_auth_url(self, host_url):
        """
        Provides google authorisation url
        :param host_url: Base url for hosting application
        :return: authorisation url to complete authentication
        """
        auth_url = None
        error = None
        # reset below variable to get latest values in fresh
        # authentication call
        self._verification_successful = False
        self._verification_error = None
        try:
            self._redirect_url = host_url + 'google/callback'
            flow = InstalledAppFlow.from_client_config(
                client_config=self._client_config, scopes=self._scopes,
                redirect_uri=self._redirect_url)
            auth_url, state = flow.authorization_url(
                prompt='select_account', access_type='offline',
                include_granted_scopes='true')
            session["state"] = state
        except Exception as e:
            error = str(e)
            self._verification_error = error
        return auth_url, error

    def callback(self, flask_request):
        """
        Callback function on completion of google authorisation request
        :param flask_request:
        :return: Success or error message
        """
        try:
            authorization_response = flask_request.url
            if session['state'] != flask_request.args.get('state', None):
                self._verification_successful = False,
                self._verification_error = 'Invalid state parameter'
            flow = InstalledAppFlow.from_client_config(
                client_config=self._client_config, scopes=self._scopes,
                redirect_uri=self._redirect_url)
            flow.fetch_token(authorization_response=authorization_response)
            self._credentials = flow.credentials
            self.credentials_json = \
                self._credentials_to_dict(self._credentials)
            self._verification_successful = True
            return 'The authentication flow has completed. ' \
                   'This window will be closed.'
        except AccessDeniedError as er:
            self._verification_successful = False
            self._verification_error = er.error
            if self._verification_error == 'access_denied':
                self._verification_error = 'Access denied.'
            return self._verification_error

    @staticmethod
    def _credentials_to_dict(credentials):
        return {'token': credentials.token,
                'refresh_token': credentials.refresh_token,
                'token_uri': credentials.token_uri,
                'client_id': credentials.client_id,
                'client_secret': credentials.client_secret,
                'scopes': credentials.scopes,
                'id_token': credentials.id_token}

    def verification_ack(self):
        """Check the Verification is done or not."""
        return self._verification_successful, self._verification_error

    def _get_credentials(self, scopes):
        """
        Provides google credentials for google cloud sql api calls
        :param scopes: Required scope of credentials
        :return: google credential object
        """
        if not self._credentials or not self._credentials.valid:
            if self._credentials and self._credentials.expired and \
                    self._credentials.refresh_token and \
                    self._credentials.has_scopes(scopes):
                self._credentials.refresh(Request())
                return self._credentials
        return self._credentials

    def get_projects(self):
        """
        List the google projects for authorised user
        :return:
        """
        projects = []
        error = None
        credentials = self._get_credentials(self._scopes)
        service = discovery.build('cloudresourcemanager',
                                  self._cloud_resource_manager_api_version,
                                  credentials=credentials)
        try:
            req = service.projects().list()
            res = req.execute()
            for project in res.get('projects', []):
                projects.append({'label': project['projectId'],
                                 'value': project['projectId']})
        except HttpError as e:
            error = e.reason
        except Exception as e:
            error = str(e)
        return projects, error

    def get_regions(self, project):
        """
        List regions for specified google cloud project
        :param project: google cloud project id.
        :return:
        """
        self._project_id = project
        credentials = self._get_credentials(self._scopes)
        service = discovery.build('compute',
                                  self._compute_api_version,
                                  credentials=credentials)
        error = None
        try:
            req = service.regions().list(project=project)
            res = req.execute()
            for item in res.get('items', []):
                region_name = item['name']
                self._regions.append(
                    {'label': region_name, 'value': region_name})
                region_zones = item.get('zones', [])
                region_zones = list(
                    map(lambda region: region.split('/')[-1], region_zones))
                self._availability_zones[region_name] = region_zones
        except HttpError as e:
            error = e.reason
        except Exception as e:
            error = str(e)
        return self._regions, error

    def get_availability_zones(self, region):
        """
        List availability zones in given google cloud region
        :param region: google cloud region
        :return:
        """
        az_list = []
        for az in self._availability_zones.get(region, []):
            az_list.append({'label': az, 'value': az})
        return az_list

    def get_instance_types(self, project, region):
        """
        Lists google cloud sql instance types.
        :param project:
        :param region:
        :return:
        """
        standard_instances = []
        shared_instances = []
        high_mem = []
        instance_types = {}
        error = None
        credentials = self._get_credentials(self._scopes)
        service = discovery.build('sqladmin',
                                  self._sqladmin_api_version,
                                  credentials=credentials)
        try:
            req = service.tiers().list(project=project)
            res = req.execute()
            for item in res.get('items', []):
                if region in item.get('region', []):
                    if item['tier'].find('standard') != -1:
                        vcpu = item['tier'].split('-')[-1]
                        mem = round(int(item['RAM']) / (1024 * 1024))
                        label = (
                            vcpu + ' vCPU, ' + str(round(mem / 1024)) + ' GB')
                        value = 'db-custom-' + str(vcpu) + '-' + str(mem)
                        standard_instances.append(
                            {'label': label, 'value': value})
                    elif item['tier'].find('highmem') != -1:
                        vcpu = item['tier'].split('-')[-1]
                        mem = round(int(item['RAM']) / (1024 * 1024))
                        label = \
                            (vcpu + ' vCPU, ' + str(round(mem / 1024)) + ' GB')
                        value = 'db-custom-' + str(vcpu) + '-' + str(mem)
                        high_mem.append(
                            {'label': label, 'value': value})
                    else:
                        label = '1 vCPU, ' + str(
                            round((int(item['RAM']) / 1073741824), 2)) + ' GB'
                        value = item['tier']
                        shared_instances.append(
                            {'label': label, 'value': value})
            instance_types = {'standard': standard_instances,
                              'highmem': high_mem,
                              'shared': shared_instances}
        except HttpError as e:
            error = e.reason
        except Exception as e:
            error = str(e)
        return instance_types, error

    def get_database_versions(self):
        """
        Lists the PostgreSQL database versions
        :return:
        """
        pg_database_versions = []
        database_versions = []
        error = None
        credentials = self._get_credentials(self._scopes)
        service = discovery.build('sqladmin',
                                  self._sqladmin_api_version,
                                  credentials=credentials)
        try:
            req = service.flags().list()
            res = req.execute()
            for item in res.get('items', []):
                if item.get('name', '') == 'max_parallel_workers':
                    pg_database_versions = item.get('appliesTo', [])
            for version in pg_database_versions:
                label = (version.title().split('_')[0])[0:7] \
                    + 'SQL ' + version.split('_')[1]
                database_versions.append({'label': label, 'value': version})
        except HttpError as e:
            error = e.reason
        except Exception as e:
            error = str(e)
        return database_versions, error
