# Copyright (C) 2014, 2015, 2018 The Meme Factory, Inc.  http://www.meme.com/
#
#    This file is part of Gombe-MI.
#
#    Gombe-MI is free software; you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation; either version 3 of the License, or
#    (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with Gombe-MI.  If not, see <http://www.gnu.org/licenses/>.
#
# Create a new DB user
#
# Karl O. Pinc <kop@meme.com>
#

# Write python 3 compatible code.
from __future__ import print_function
from __future__ import unicode_literals
from __future__ import absolute_import
from __future__ import division

import string
import random
import markupsafe
import logging

from wtforms import (
    StringField,
    RadioField)

from pyramid.view import view_config

from gmi_pyramid.lib.upload import (
    UploadEngine
    , UnsafeUploadEngine
    , Error
    , DBError
    , AuthFailError
    , DataInconsistencyError
    , UserWTForm
    , UserInitialPost
    , CredsLoadedForm
    , SessionDBHandler
    , LogSQLCommand
    , SQLData
    , ExecuteSQL
    , format_exception
    )

from gmi_pyramid.lib.form_constants import(
    LIVE_DB
    , TEST_DB
    , CHECKED
    , UNCHECKED
    )

# Constants
# Group values -- html form:
RO    = 'ro'    ;# Read-only
RW    = 'rw'    ;# Read/Write
ADMIN = 'admin' ;# Administrator

# Group names -- database
RO_GROUP = 'gombemi_readers'
RW_GROUP = 'gombemi_editors'

# Min and max generated password lengths
PW_MIN = 5
PW_MAX = 7

log = logging.getLogger(__name__)

class AddUserInitialPost(UserInitialPost):
    username    = ''
    group       = RO


class AddUserWTForm(UserWTForm):
    '''The wtform used to connect to the "gombemi" db to authenticate .'''
    # We don't actually use the labels, wanting the template to
    # look (and render) like html, but I'll define them anyway
    # just to keep my hand in.
    username     = StringField('New Username:')
    group        = RadioField('User Type:',
                              choices=[('Read-Only:', RO),
                                       ('Read/Write:', RW),
                                       ('Administrator:', ADMIN)])

    ivals        = AddUserInitialPost


class AddUserForm(CredsLoadedForm):
    '''
    Acts like a dict, but with extra methods.

    Attributes:
      uh      The UploadHandler instance using the form
    '''
    def __init__(self, uh, fc=AddUserWTForm, data={}, **kwargs):
        data.update(kwargs)
        super(AddUserForm, self).__init__(uh, fc, data)

    def read(self):
        '''
        Read form data from the client
        '''

        # Read parent's data
        super(AddUserForm, self).read()

        # Read our own data
        self['username']         = self._form.username.data.lower().strip()
        self['group']            = self._form.group.data


    def write(self, result, errors):
        '''
        Produces the dict pyramid will use to render the form.
        '''
        response = super(AddUserForm, self).write(result, errors)

        response['username'] = self['username']

        if self['group'] == ADMIN:
            response['ro_checked']    = UNCHECKED
            response['rw_checked']    = UNCHECKED
            response['admin_checked'] = CHECKED
        elif self['group'] == RW:
            response['ro_checked']    = UNCHECKED
            response['rw_checked']    = CHECKED
            response['admin_checked'] = UNCHECKED
        else: # self['group'] == RO
            response['ro_checked']    = CHECKED
            response['rw_checked']    = UNCHECKED
            response['admin_checked'] = UNCHECKED

        response['ro_value']    = RO
        response['rw_value']    = RW 
        response['admin_value'] = ADMIN

        return response


# Setup errors
class NoUsernameError(Error):
    def __init__(self, e, descr='', detail=''):
        super(NoUsernameError, self).__init__(e, descr, detail)

class InvalidUsernameError(Error):
    def __init__(self, e, descr='', detail=''):
        super(InvalidUsernameError, self).__init__(e, descr, detail)

class InvalidGroupError(Error):
    def __init__(self, e, descr='', detail=''):
        super(InvalidGroupError, self).__init__(e, descr, detail)


# Database statement execution errors
class NewUserError(DBError):
    '''Database generated an error while creating a user'''
    def __init__(self, pgexc):
        '''
        pgexc  The psycopg2 exception object
        '''
        super(NewUserError, self).__init__(pgexc, 'create a user')

class NewSchemaError(DBError):
    '''Database generated an error while creating a user's schema'''
    def __init__(self, pgexc, db):
        '''
        pgexc  The psycopg2 exception object
        db     Database in which schema was to be created
        '''
        super(NewSchemaError, self).__init__(
            pgexc, "create the user's schema in the " + db + ' database')

class SchemaGrantError(DBError):
    '''Database generated an error while creating a user's schema'''
    def __init__(self, pgexc, db):
        '''
        pgexc  The psycopg2 exception object
        db     Database in which schema was to be created
        '''
        super(SchemaGrantError, self).__init__(
            pgexc, "establish permissions to the user's schema in the "
                   + db + ' database')


class AddHandler(SessionDBHandler):
    '''
    Abstract class for adding users and schemas to the db.

    Attributes:
    '''
    def __init__(self, request):
        '''
        request A pyramid request instance
        '''
        super(AddHandler, self).__init__(request)

    def factory(self, ue):
        '''Make a db loader function from an UploadEngine.

        Input:

        Side Effects:
        Yes, lots.
        '''
        return ExecuteSQL(ue, self)

    def render(self, errors, result):
        '''Instead of rendering, just return our results so we can
        connect to another db and do more work.

        Input:
          errors    List of Error instances
          result    Db connection result dict
        '''
        response = super(AddHandler, self).render(errors, result)
        return (response, errors)
            
    def initiating_user_detail(self):
        '''Return a string for log messages detailing the user performing
        the action.
        '''
        return 'By existing user ({user})'.format(user=self.uf['user'])

    def addschema_detail(self, db):
        '''Return a string for the log detailing an add schema attempt.'''
        return ('New schema ({schema}): In database {db}: {bywho}'
                   .format(schema=self.uf['username'], db=db,
                           bywho=self.initiating_user_detail()))

    def log_addschema_success(self, db):
        '''Write a success message to the log when creating a schema.'''
        log.warning('New schema created: {0}'
                   .format(self.addschema_detail(db)))

    def log_addschema_failure(self, db, ex):
        '''Write a failture message to the log when creating a schema.'''
        log.info('Failed to create schema in db {0}: {1}{2}'
                 .format(db, self.addschema_detail(db),
                         format_exception(ex)))


class AddUserHandler(AddHandler):
    '''
    Handler to add a user to the db.

    Attributes:
      newpw   The new user's password.
    '''
    def __init__(self, request):
        '''
        request A pyramid request instance
        '''
        super(AddUserHandler, self).__init__(request)

    def make_form(self):
        '''
        The form gets an extra attribute, the default db.
        '''
        return AddUserForm(self, db=LIVE_DB)

    def make_pw(self):
        '''
        Make a password.
        '''
        # Don't use l, 1, O, and 0 since these get confused.
        set = '23456789abcdefghijkmnopqrstuvwxyzABCDEFGHIJKLMNPQRSTUVWXYZ'
        return ''.join([random.choice(set) \
                        for i in range(0, random.randint(PW_MIN, PW_MAX))])

    def adduser_detail(self):
        '''Return a string for the log detailing an add user attempt.'''
        uf = self.uf
        username = uf['username']
        group    = uf['group']
        return ('New user ({username}): Type {type}: {bywho}'
               .format(username=username, type=group,
                       bywho=self.initiating_user_detail()))

    def log_adduser_success(self):
        '''Write a success message to the log when adding a user.'''
        log.warning('New DB role created: {0}'
                   .format(self.adduser_detail()))

    def log_adduser_failure(self, ex):
        '''Write a failure message to the log when adding a user.'''
        log.info('Failed to create new DB role: {0}{1}'
                 .format(self.adduser_detail(), format_exception(ex)))

    def log_addschema_success(self):
        return super(AddUserHandler, self).log_addschema_success(LIVE_DB)

    def log_addschema_failure(self, ex):
        return (super(AddUserHandler, self)
                .log_addschema_failure(LIVE_DB, ex))

    def get_data(self):
        '''
        Build and stash the SQL to be executed.
        
        Returns:
          List of SQCommand instances
        '''
        self.newpw = self.make_pw()

        uf = self.uf
        sql = []

        group    = uf['group']
        username = uf['username']

        # Create the user
        stmt = ['CREATE ROLE {0}'.format(username)]
        if group == ADMIN:
            # Admins are superusers.  PIs should be, and sometimes
            # superuser is needed to change ownership.  Having 3
            # levels of user is too complicated.
            stmt.append('SUPERUSER')
        stmt.append('INHERIT LOGIN')
        stmt.append("PASSWORD %s")
        if group != ADMIN:
            # Place user in correct role
            stmt.append('IN ROLE')
            if group == RW:
                stmt.append(RW_GROUP)
            else:
                stmt.append(RO_GROUP)

        sql.append(LogSQLCommand(
                ' '.join(stmt)
                , (self.newpw,)
                , NewUserError
                , log_success=self.log_adduser_success
                , log_failure=self.log_adduser_failure))
                   

        if group != ADMIN:
            # Add the schema to the gombemi db
            sql.append(LogSQLCommand(
                    ('CREATE SCHEMA {0} AUTHORIZATION {1}'
                     .format(username, username))
                    , ()
                    , lambda ex: SchemaGrantError(ex, LIVE_DB)
                    , log_success=self.log_addschema_success
                    , log_failure=self.log_addschema_failure))

        self.data = SQLData(sql)

    def write(self, result, errors):
        '''
        Setup dict to render resulting html form

        Returns:
          Dict pyramid will use to render the resulting form
          Reserved keys:
            errors      A list of UploadError exceptions.
            db_changed  Boolean. Whether the db was changed.
        '''
        response = super(AddUserHandler, self).write(result, errors)

        if hasattr(self, 'newpw'):
            response['newpw'] = self.newpw
        response['db_changed'] = not response['errors'] and 'newpw' in response

        return response

    def val_input(self):
        '''
        Validate input needed beyond that required to connect to the db.

        Returns:
          A list of Error instances
        '''
        uf = self.uf
        errors = []

        if uf['username'] == '':
            errors.append(NoUsernameError(
                    'No username supplied for the new user'))
        else:
            # Make sure the username is a sane value lest we create
            # something hard to deal with in the db.

            # The username has already been converted to lower case.
            if (string.ascii_lowercase + '_').find(uf['username'][0]) < 0:
                errors.append(InvalidUsernameError(
                        'Invalid username'
                        , ('Usernames must begin with a letter'
                           ' or an underscore')))
                
            gooduns = string.ascii_lowercase + '_' + string.digits
            for ch in uf['username'][1:]:
                if gooduns.find(ch) < 0:
                    errors.append(InvalidUsernameError(
                        'Invalid username'
                        , ('Usernames must contain only letters, digits, and'
                           ' underscores')))
                    break

        if uf['group'] not in (RO, RW, ADMIN):
            errors.append(InvalidGroupError('Invalid group code supplied'))

        return errors


class AddTestSchemaHandler(AddHandler):
    '''
    Handler to add a user schema to the test db.

    Attributes:
    '''
    def __init__(self, request):
        '''
        request A pyramid request instance
        '''
        super(AddTestSchemaHandler, self).__init__(request)

    def make_form(self):
        '''
        The form gets an extra attribute, the test db.
        '''
        return AddUserForm(self, db=TEST_DB)
            
    def log_addschema_success(self):
        return super(AddTestSchemaHandler, self).log_addschema_success(TEST_DB)

    def log_addschema_failure(self, ex):
        return (super(AddTestSchemaHandler, self)
                .log_addschema_failure(TEST_DB, ex))

    def get_data(self):
        '''
        Build and stash the SQL to be executed.
        
        Returns:
          List of SQCommand instances
        '''

        uf = self.uf
        sql = []

        username = uf['username']

        # Add the schema to the gombemi db
        sql.append(LogSQLCommand(
                'CREATE SCHEMA {0} AUTHORIZATION {1}'
                .format(username, username)
                , ()
                , lambda ex: SchemaGrantError(ex, TEST_DB)
                , log_success=self.log_addschema_success
                , log_failure=self.log_addschema_failure))

        self.data = SQLData(sql)


class AddUserEngine(UploadEngine):
    '''Customize credential failure error message.'''
    def authfailerror_factory(self):
        orig = super(AddUserEngine, self).authfailerror_factory()
        return AuthFailError(
                orig.e, 'Is your username and password correct?')


@view_config(route_name='add_user'
             , renderer='gmi_pyramid:templates/add_user.mak')
def add_user_view(request):

    # Add user and add schema to default db
    uh = AddUserHandler(request)
    response, errors = AddUserEngine(uh).run()
    if response['db_changed'] and uh.uf['group'] != ADMIN:
        # Add schema to test db
        schemaout, schemaerrors = (
            UnsafeUploadEngine(AddTestSchemaHandler(request)).run())
        # Merge responses of modifying both dbs
        if schemaerrors:
            response['errors'].extend(schemaerrors)
            response['errors'].append(DataInconsistencyError(
                    'There is an inconsistency between databases',
                    (('The ({0}) user is created and a user-personal-schema'
                      ' added to the {1} database but the creation of the'
                      ' user-personal-schema failed in the {2} database')
                     .format(markupsafe.escape(uh.uf['username']),
                             LIVE_DB,
                             TEST_DB)),
                    ('<p>Hint: You may wish to delete the user and schema and'
                     ' try again.</p>')))
        del schemaout['errors']
        response.update(schemaout)
        response['db_changed'] &= not schemaerrors
    return response
