import contextlib
import tempfile
from datetime import datetime, timedelta

import jsonschema
import requests
from cryptography import x509
from cryptography.hazmat.backends import default_backend as crypto_backend
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from django.core.exceptions import ValidationError
from django.utils import timezone
from django.utils.crypto import get_random_string

from .base import AccessControlDriver, RemoteError

ACCESS_RULE_TYPES = {
    'access_point_group': 1,
    'access_point': 2,
    'access_level': 3,
    'access_group': 4,
    'venue_booking': 12,
}

DEFAULT_TIME_SCHEDULE_ID = '1'  # Usually (?) maps to "Always"

REQUESTS_TIMEOUT = 30  # seconds


class UnauthorizedError(RemoteError):
    pass


class SiPassAccessRule:
    def __init__(self, target, start_time=None, end_time=None,
                 time_schedule_id=DEFAULT_TIME_SCHEDULE_ID):
        self.target = target
        self.start_time = start_time
        self.end_time = end_time
        self.type = ACCESS_RULE_TYPES[target['type']]
        self.time_schedule_id = time_schedule_id


class SiPassToken:
    value: str
    expires_at: datetime

    def __init__(self, value, expires_at):
        self.value = value
        self.expires_at = expires_at

    def has_expired(self):
        if self.expires_at is None:
            return False
        now = datetime.now()
        if now > self.expires_at + timedelta(seconds=30):
            return True
        return False

    def refresh(self, expiration_time):
        self.expires_at = datetime.now() + timedelta(seconds=expiration_time)

    def serialize(self):
        return dict(value=self.value, expires_at=self.expires_at.timestamp())

    @classmethod
    def deserialize(cls, data):
        try:
            value = data['value']
            expires_at = datetime.fromtimestamp(data['expires_at'])
        except Exception:
            return None
        return SiPassToken(value=value, expires_at=expires_at)


class SiPassDriver(AccessControlDriver):
    token: SiPassToken
    token_expiration_time: int

    SYSTEM_CONFIG_SCHEMA = {
        "type": "object",
        "properties": {
            "api_url": {
                "type": "string",
                "format": "uri",
                "pattern": "^https?://",
            },
            "username": {
                "type": "string",
            },
            "password": {
                "type": "string",
            },
            "credential_profile_name": {
                "type": "string",
            },
            "cardholder_workgroup_name": {
                "type": "string",
            },
            "client_id": {
                "type": "string",
            },
            "verify_tls": {
                "type": "boolean",
            },
            "tls_ca_cert": {
                "type": "string",
                "format": "textarea",
            },
            "tls_client_cert": {
                "type": "string",
                "format": "textarea",
            },
        },
        "required": [
            "api_url", "username", "password", "credential_profile_name",
            "cardholder_workgroup_name",
        ],
    }
    RESOURCE_CONFIG_SCHEMA = {
        "type": "object",
        "properties": {
            "access_point_group_name": {
                "type": "string",
            },
            "credential_profile_name": {
                "type": "string",
            }
        },
        "required": [
            "access_point_group_name"
        ]
    }

    DEFAULT_CONFIG = {
        "client_id": "kulkunen",
        "verify_tls": True
    }

    def get_system_config_schema(self):
        return self.SYSTEM_CONFIG_SCHEMA

    def get_resource_config_schema(self):
        return self.RESOURCE_CONFIG_SCHEMA

    def get_resource_identifier(self, resource):
        config = resource.driver_config or {}
        return config.get('access_point_group_name', '')

    def _validate_tls_cert_config(self, config):
        ca_cert = config.get('tls_ca_cert', None)
        if ca_cert:
            try:
                x509.load_pem_x509_certificate(ca_cert.encode('ascii'), crypto_backend())
            except Exception as e:
                raise ValidationError("'tls_ca_cert' must include the certificate in PEM format: \"%s\"" % e)

        client_cert = config.get('tls_client_cert', None)
        if client_cert:
            cert_data = client_cert.encode('ascii')
            try:
                x509.load_pem_x509_certificate(cert_data, crypto_backend())
            except Exception as e:
                raise ValidationError("'tls_client_cert' must include the certificate in PEM format: \"%s\"" % e)
            try:
                load_pem_private_key(cert_data, password=None, backend=crypto_backend())
            except Exception as e:
                raise ValidationError("'tls_client_cert' must include a PEM-encoded private key: \"%s\"" % e)

    def validate_system_config(self, config):
        try:
            jsonschema.validate(config, self.SYSTEM_CONFIG_SCHEMA)
        except jsonschema.exceptions.ValidationError as e:
            raise ValidationError(e.message)

        self._validate_tls_cert_config(config)

    def validate_resource_config(self, resource, config):
        try:
            jsonschema.validate(config, self.RESOURCE_CONFIG_SCHEMA)
        except jsonschema.exceptions.ValidationError as e:
            raise ValidationError(e.message)

    def _save_token(self, token):
        self.update_driver_data(dict(token=token.serialize()))

    def _load_token(self):
        data = self.get_driver_data().get('token')
        return SiPassToken.deserialize(data)

    def api_get_token(self):
        username = self.get_setting('username')
        password = self.get_setting('password')

        data = dict(Username=username, Password=password)
        resp = self.api_req_unauth('authentication', 'POST', data=data)
        if not resp['Token']:
            raise Exception("Username or password incorrect")
        token = SiPassToken(value=resp['Token'], expires_at=None)
        self.logger.info("Token: %s" % token.value)
        return token

    def api_renew_session(self):
        self.api_get('authentication')

    @contextlib.contextmanager
    def ensure_token(self):
        driver_data = self.get_driver_data()
        token_expiration_time = driver_data.get('token_expiration_time')
        token = self._load_token()
        if not token or token.has_expired():
            token = self.api_get_token()

        if not token_expiration_time:
            resp = self.api_req_unauth('authentication/sessiontimeout', 'GET', headers={
                'Authorization': token.value
            })
            if isinstance(resp, int):
                token_expiration_time = resp
            else:
                token_expiration_time = 360  # default
            self.update_driver_data(dict(token_expiration_time=token_expiration_time))

        token.refresh(token_expiration_time)
        self._save_token(token)

        try:
            yield token
        except Exception as e:
            raise

    @contextlib.contextmanager
    def _generate_ca_files(self):
        ca_cert = self.get_setting('tls_ca_cert', True)
        client_cert = self.get_setting('tls_client_cert', True)
        requests_args = {}

        with contextlib.ExitStack() as stack:
            if ca_cert:
                tf = tempfile.NamedTemporaryFile()
                stack.enter_context(tf)
                tf.write(ca_cert.encode('ascii'))
                tf.seek(0)
                requests_args['verify'] = tf.name
            if client_cert:
                tf = tempfile.NamedTemporaryFile()
                tf.write(client_cert.encode('ascii'))
                tf.seek(0)
                stack.enter_context(tf)
                requests_args['cert'] = tf.name
            yield requests_args

    def api_req_unauth(self, path, method, data=None, params=None, headers=None):
        headers = headers.copy() if headers is not None else {}
        headers.update({
            'clientUniqueId': self.get_setting('client_id'),
            'language': 'English'
        })
        verify_tls = self.get_setting('verify_tls')
        url = '%s/%s' % (self.get_setting('api_url'), path)
        self.logger.info('%s: %s' % (method, url))
        args = dict(headers=headers, verify=verify_tls)
        if method == 'POST':
            args['json'] = data
        elif method == 'PUT':
            args['json'] = data
        elif method == 'GET':
            args['params'] = params
        elif method == 'DELETE':
            args['params'] = params
        else:
            raise Exception("Invalid method")

        with self._generate_ca_files() as ca_args:
            args.update(ca_args)
            resp = requests.request(method, url, timeout=REQUESTS_TIMEOUT, **args)

        if resp.status_code not in (200, 201, 204):
            if resp.content:
                try:
                    data = resp.json()
                    err_code = data.get('ErrorCode')
                    err_str = data.get('Message')
                except Exception:
                    err_code = ''
                    err_str = ''
                status_code = resp.status_code
                self.logger.error(f"SiPass API error [HTTP {status_code}] [{err_code}] {err_str}")

            # Clear the object cache just in case, something might've
            # changed in the API.
            self._nuke_object_cache()

            if resp.status_code == 401:
                # If the server responds with "unauthorized", it's possible that
                # the access token is expired (even though it's not supposed to just yet).
                # We nuke the access token and rely on the upper-layer retry mechanism
                # to try again later.
                self.update_driver_data(dict(token=None))

            resp.raise_for_status()

        if not resp.content:
            return None
        return resp.json()

    def api_get(self, path, params=None):
        with self.ensure_token() as token:
            resp = self.api_req_unauth(path, 'GET', params=params, headers={
                'Authorization': token.value
            })
        return resp

    def api_post(self, path, data):
        with self.ensure_token() as token:
            resp = self.api_req_unauth(path, 'POST', data, headers={
                'Authorization': token.value
            })
        return resp

    def api_put(self, path, data):
        with self.ensure_token() as token:
            resp = self.api_req_unauth(path, 'PUT', data, headers={
                'Authorization': token.value
            })
        return resp

    def api_delete(self, path):
        with self.ensure_token() as token:
            resp = self.api_req_unauth(path, 'DELETE', headers={
                'Authorization': token.value
            })
        return resp

    def get_cardholders(self):
        params = {
            'searchString': '',
            'appId': 'Cardholders',
            'fields': 'FirstName,LastName,Status',
            'sortingOrder': '{"FieldName":"LastName","Value":"","SortingOrder":0}',
            'filterExpression': "{'Identifier': ''}",
            'startIndex': 0,
            'endIndex': 100,
        }
        resp = self.api_get('Cardholders', params=params)
        return [dict(
            id=d['Token'],
            status=d['Status'],
            first_name=d['FirstName'],
            last_name=d['LastName']
        ) for d in resp['Records']]

    def get_cardholder(self, cardholder_id):
        return self.api_get('Cardholders/%s' % cardholder_id)

    def get_access_levels(self):
        resp = self.api_get('AccessLevels')
        records = [dict(name=r['Name'], id=r['Token'], type='access_level') for r in resp['Records']]
        return records

    def get_access_point_groups(self):
        resp = self.api_get('AccessPointGroups')
        records = [dict(name=r['Name'], id=r['Token'], type='access_point_group') for r in resp['Records']]
        return records

    def get_workgroups(self):
        resp = self.api_get('WorkGroups')
        records = [dict(name=r['Name'], id=r['Token']) for r in resp['Records']]
        return records

    def get_time_schedules(self):
        resp = self.api_get('TimeSchedules')
        if isinstance(resp, dict) and 'Records' in resp:
            resp = resp['Records']
        return [dict(id=d['Token'], name=d['Name']) for d in resp]

    def get_card_technologies(self):
        resp = self.api_get('CardTechnologies')
        if isinstance(resp, dict) and 'Records' in resp:
            resp = resp['Records']
        return [dict(
            id=d['TechnologyCode'],
            name=d['Name'],
            facility_code_digits=d.get('FacilityCodeDigits', None),
            card_number_digits=d.get('CardNumberDigits', None)
        ) for d in resp]

    def get_credential_profiles(self):
        resp = self.api_get('CredentialProfiles')
        if isinstance(resp, dict) and 'Records' in resp:
            resp = resp['Records']
        return [dict(
            id=d.pop('Token'),
            name=d.pop('Name'),
            card_technology_id=d.pop('CardTechnologyCode'),
            card_technology_name=d.pop('CardTechnology'),
            **d
        ) for d in resp]

    def _refresh_objects(self, object_type):
        """Loads named objects from SiPass API and saves them in the database"""
        object_get_func = getattr(self, 'get_%s' % object_type)
        objs = {x['name']: x for x in object_get_func()}

        with self.system_lock() as system:
            driver_data = system.driver_data or {}
            object_cache = driver_data.setdefault('object_cache', {})
            object_cache[object_type] = objs
            system.driver_data = driver_data
            system.save(update_fields=['driver_data'])
        return objs

    def _nuke_object_cache(self):
        with self.system_lock() as system:
            driver_data = system.driver_data or {}
            if driver_data.get('object_cache'):
                driver_data['object_cache'] = {}
                system.save(update_fields=['driver_data'])

    def get_object_by_id(self, grant, object_type, setting_name):
        """Returns the API object corresponding to an object name

        The object name is taken from either the resource config, or if not found,
        the system-level config. If the object is not in the system-level object
        cache, the objects are refreshed from the API.
        """
        obj_name = grant.resource.driver_config.get(setting_name, None)
        if not obj_name:
            obj_name = self.get_setting(setting_name)

        objs = self.get_driver_data().get('object_cache', {}).get(object_type, {})
        if obj_name not in objs:
            objs = self._refresh_objects(object_type)
        obj = objs.get(obj_name)
        if obj is None:
            raise RemoteError("[%s] Invalid %s name: %s" % (grant, object_type, obj_name))
        return obj

    def remove_cardholder(self, cardholder_id):
        self.api_delete('Cardholders/%s' % cardholder_id)

    def _generate_access_rule(self, access_rule):
        ar = access_rule
        ar_start = ar.start_time
        if ar_start:
            ar_start = ar_start.replace(microsecond=0).isoformat()
        ar_end = ar.end_time
        if ar_end:
            ar_end = ar_end.replace(microsecond=0).isoformat()

        target = ar.target
        out = {
            'ArmingRightsId': None,
            'ControlModeId': None,
            'EndDate': ar_end,
            'ObjectName': target['name'],
            'ObjectToken': '-1',
            'RuleToken': target['id'],
            'RuleType': ar.type,
            'StartDate': ar_start,
            'Side': 0,
            'TimeScheduleToken': ar.time_schedule_id,
        }
        return out

    def api_create_cardholder(self, data):
        required_keys = [
            'first_name', 'last_name', 'card_number', 'pin', 'credential_profile',
            'workgroup'
        ]
        for key in required_keys:
            assert key in data, "Key '%s' needed" % key

        # Compensate for possible clock drift
        now = timezone.now() - timedelta(minutes=10)

        ar_list = data.get('access_rules', [])
        if ar_list:
            start_time = min([x.start_time for x in ar_list])
            end_time = max([x.end_time for x in ar_list])
        else:
            start_time = data.get('start_time', now)
            end_time = start_time + timedelta(days=1)

        access_rules = []
        for ar in ar_list:
            access_rules.append(self._generate_access_rule(ar))

        start_time = start_time.replace(microsecond=0)
        end_time = end_time.replace(microsecond=0)

        credentials = {
            'CardNumber': data['card_number'],
            'CardTechnologyCode': data['card_technology_id'],
            'EndDate': end_time.isoformat(),
            'FacilityCode': 0,
            'Pin': data['pin'],
            'PinMode': data['credential_profile']['PINModeValue']['Type'],
            'ProfileId': data['credential_profile']['id'],
            'ProfileName': data['credential_profile']['name'],
            'StartDate': start_time.isoformat(),
            'RevisionNumber': 0,
        }

        # All of these keys must be present, otherwise the POST request
        # will fail with HTTP 500...
        cardholder_data = {
            'AccessRules': access_rules,
            'ApbWorkgroupId': data['workgroup']['id'],
            'Attributes': {
                'Accessibility': False,
                'ApbExclusion': False,
                'ApbReEntryExclusion': False,
                'Isolate': False,
                'SelfAuthorize': False,
                'Supervisor': False,
                'Visitor': False,
                'Void': False,
            },
            'BaseCardNumber': None,
            'Credentials': [credentials],
            'EmployeeNumber': '',
            'EmployeeName': None,
            'EndDate': end_time.isoformat(),
            'FingerPrints': [],
            'FirstName': data['first_name'],
            'GeneralInformation': '',
            'LastName': data['last_name'],
            'NonPartitionWorkGroups': [],
            'NonPartitionWorkgroupAccessRules': [],
            'PersonalDetails': {
                'Address': '',
                'ContactDetails': {
                    'Email': '',
                    'MobileNumber': '',
                    'MobileServiceProviderId': '0',
                    'PagerNumber': '',
                    'PagerServiceProviderId': '0',
                    'PhoneNumber': '',
                    'ManagerEmailAddress': ''
                },
                'DateOfBirth': '',
                'PayrollNumber': '',
                'Title': '',
                'UserDetails': {
                    'Password': '',
                    'UserName': ''
                }
            },
            'Potrait': None,
            'PrimaryWorkgroupId': data['workgroup']['id'],
            'PrimaryWorkgroupName': data['workgroup']['name'],
            'SmartCardProfileId': '0',
            'SmartCardProfileName': None,
            'StartDate': start_time.isoformat(),
            'Status': 62,  # 62 means Valid
            'Token': <PASSWORD>',
            'TraceDetails': {
                'CardLastUsed': None,
                'CardNumberLastUsed': None,
                'LastApbLocation': None,
                'PointName': None,
                'TraceCard': False
            },
            'Vehicle1': {
                'CarColor': '',
                'CarModelNumber': '',
                'CarRegistrationNumber': ''
            },
            'Vehicle2': {
                'CarColor': '',
                'CarModelNumber': '',
                'CarRegistrationNumber': ''
            },
            'VisitorDetails': {
                'VisitedEmployeeFirstName': '',
                'VisitedEmployeeLastName': '',
                'VisitorCardStatus': 0,
                'VisitorCustomValues': {}
            }
        }
        resp = self.api_post('Cardholders', data=cardholder_data)
        cardholder_id = resp['Token']

        return cardholder_id

    def create_access_user(self, grant):
        user = grant.reservation.user
        # Make sure there is something in first_name and last_name
        first_name = user.first_name or 'Kulkunen'
        last_name = user.last_name or 'Kulkunen'

        # We lock the access control instance through the database to protect
        # against race conditions.
        with self.system_lock():
            # The PIN also serves as the cardholder identifier (they must
            # be identical). Try at most 20 times to generate an unused PIN,
            # and if that fails, we probably have other problems. Upper layers
            # will take care of retrying later in case the unlikely false positive
            # happens.
            for i in range(20):
                pin = get_random_string(1, '123456789') + get_random_string(3, '0123456789')
                if not self.system.users.active().filter(identifier=pin).exists():
                    break
            else:
                raise RemoteError("Unable to find a PIN code for grant")

            user_attrs = dict(identifier=pin, first_name=first_name, last_name=last_name, user=user)
            user = self.system.users.create(**user_attrs)

        return user

    def create_cardholder(self, grant, user):
        start_time = grant.starts_at
        end_time = grant.ends_at

        access_point_group = self.get_object_by_id(grant, 'access_point_groups', 'access_point_group_name')
        access_rule = SiPassAccessRule(access_point_group, start_time, end_time)

        credential_profile = self.get_object_by_id(grant, 'credential_profiles', 'credential_profile_name')
        workgroup = self.get_object_by_id(grant, 'workgroups', 'cardholder_workgroup_name')
        card_technology_id = credential_profile['card_technology_id']

        cardholder_id = self.api_create_cardholder(dict(
            first_name=user.first_name,
            last_name=user.last_name,
            card_number=user.identifier,
            pin=user.identifier,
            access_rules=[access_rule],
            card_technology_id=card_technology_id,
            credential_profile=credential_profile,
            workgroup=workgroup,
        ))

        return cardholder_id

    def install_grant(self, grant):
        self.logger.info('[%s] Installing SiPass grant' % grant)

        assert grant.state == grant.INSTALLING

        user = self.create_access_user(grant)

        try:
            cardholder_id = self.create_cardholder(grant, user)
        except Exception as e:
            user.delete()
            raise

        self.logger.info('[%s] Cardholder created with ID %s and PIN %s' % (grant, cardholder_id, user.identifier))

        user.driver_data = dict(cardholder_id=cardholder_id)
        user.save(update_fields=['driver_data'])
        grant.access_code = user.identifier
        grant.user = user
        grant.state = grant.INSTALLED
        grant.remove_at = grant.ends_at
        grant.save(update_fields=['user', 'state', 'access_code', 'remove_at'])
        grant.notify_access_code()

    def remove_grant(self, grant):
        self.logger.info('[%s] Removing SiPass grant' % grant)

        assert grant.state == grant.REMOVING

        user = grant.user
        cardholder_id = user.driver_data['cardholder_id']
        self.remove_cardholder(user.driver_data['cardholder_id'])

        user.state = user.REMOVED
        user.removed_at = timezone.now()
        user.save(update_fields=['state', 'removed_at'])

        grant.state = grant.REMOVED
        grant.removed_at = user.removed_at
        grant.save(update_fields=['state', 'removed_at'])

        self.logger.info('[%s] Cardholder with ID %s and PIN %s removed' % (grant, cardholder_id, user.identifier))

    def prepare_install_grant(self, grant):
        # Because of a bug in SiPass API, the changes are not synchronized
        # to the building units automatically. We install the grants one day
        # before their start time and schedule a re-install of the units
        # every nightly.
        grant.install_at = grant.starts_at - timedelta(days=1)
        grant.save(update_fields=['install_at'])

    def save_respa_resource(self, resource, respa_resource):
        # SiPass driver generates access codes by itself, so we need to
        # make sure Respa doesn't generate them.
        if not respa_resource.generate_access_codes:
            return
        respa_resource.generate_access_codes = False

    def save_resource(self, resource):
        # SiPass driver generates access codes by itself, so we need to
        # make sure Respa doesn't generate them.
        respa_resource = resource.resource
        if not respa_resource.generate_access_codes:
            return
        respa_resource.generate_access_codes = False
        respa_resource.save(update_fields=['generate_access_codes'])

import contextlib
import tempfile
from datetime import datetime, timedelta

import jsonschema
import requests
from cryptography import x509
from cryptography.hazmat.backends import default_backend as crypto_backend
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from django.core.exceptions import ValidationError
from django.utils import timezone
from django.utils.crypto import get_random_string

from .base import AccessControlDriver, RemoteError

ACCESS_RULE_TYPES = {
    'access_point_group': 1,
    'access_point': 2,
    'access_level': 3,
    'access_group': 4,
    'venue_booking': 12,
}

DEFAULT_TIME_SCHEDULE_ID = '1'  # Usually (?) maps to "Always"

REQUESTS_TIMEOUT = 30  # seconds


class UnauthorizedError(RemoteError):
    pass


class SiPassAccessRule:
    def __init__(self, target, start_time=None, end_time=None,
                 time_schedule_id=DEFAULT_TIME_SCHEDULE_ID):
        self.target = target
        self.start_time = start_time
        self.end_time = end_time
        self.type = ACCESS_RULE_TYPES[target['type']]
        self.time_schedule_id = time_schedule_id


class SiPassToken:
    value: str
    expires_at: datetime

    def __init__(self, value, expires_at):
        self.value = value
        self.expires_at = expires_at

    def has_expired(self):
        if self.expires_at is None:
            return False
        now = datetime.now()
        if now > self.expires_at + timedelta(seconds=30):
            return True
        return False

    def refresh(self, expiration_time):
        self.expires_at = datetime.now() + timedelta(seconds=expiration_time)

    def serialize(self):
        return dict(value=self.value, expires_at=self.expires_at.timestamp())

    @classmethod
    def deserialize(cls, data):
        try:
            value = data['value']
            expires_at = datetime.fromtimestamp(data['expires_at'])
        except Exception:
            return None
        return SiPassToken(value=value, expires_at=expires_at)


class SiPassDriver(AccessControlDriver):
    token: SiPassToken
    token_expiration_time: int

    SYSTEM_CONFIG_SCHEMA = {
        "type": "object",
        "properties": {
            "api_url": {
                "type": "string",
                "format": "uri",
                "pattern": "^https?://",
            },
            "username": {
                "type": "string",
            },
            "password": {
                "type": "string",
            },
            "credential_profile_name": {
                "type": "string",
            },
            "cardholder_workgroup_name": {
                "type": "string",
            },
            "client_id": {
                "type": "string",
            },
            "verify_tls": {
                "type": "boolean",
            },
            "tls_ca_cert": {
                "type": "string",
                "format": "textarea",
            },
            "tls_client_cert": {
                "type": "string",
                "format": "textarea",
            },
        },
        "required": [
            "api_url", "username", "password", "credential_profile_name",
            "cardholder_workgroup_name",
        ],
    }
    RESOURCE_CONFIG_SCHEMA = {
        "type": "object",
        "properties": {
            "access_point_group_name": {
                "type": "string",
            },
            "credential_profile_name": {
                "type": "string",
            }
        },
        "required": [
            "access_point_group_name"
        ]
    }

    DEFAULT_CONFIG = {
        "client_id": "kulkunen",
        "verify_tls": True
    }

    def get_system_config_schema(self):
        return self.SYSTEM_CONFIG_SCHEMA

    def get_resource_config_schema(self):
        return self.RESOURCE_CONFIG_SCHEMA

    def get_resource_identifier(self, resource):
        config = resource.driver_config or {}
        return config.get('access_point_group_name', '')

    def _validate_tls_cert_config(self, config):
        ca_cert = config.get('tls_ca_cert', None)
        if ca_cert:
            try:
                x509.load_pem_x509_certificate(ca_cert.encode('ascii'), crypto_backend())
            except Exception as e:
                raise ValidationError("'tls_ca_cert' must include the certificate in PEM format: \"%s\"" % e)

        client_cert = config.get('tls_client_cert', None)
        if client_cert:
            cert_data = client_cert.encode('ascii')
            try:
                x509.load_pem_x509_certificate(cert_data, crypto_backend())
            except Exception as e:
                raise ValidationError("'tls_client_cert' must include the certificate in PEM format: \"%s\"" % e)
            try:
                load_pem_private_key(cert_data, password=None, backend=crypto_backend())
            except Exception as e:
                raise ValidationError("'tls_client_cert' must include a PEM-encoded private key: \"%s\"" % e)

    def validate_system_config(self, config):
        try:
            jsonschema.validate(config, self.SYSTEM_CONFIG_SCHEMA)
        except jsonschema.exceptions.ValidationError as e:
            raise ValidationError(e.message)

        self._validate_tls_cert_config(config)

    def validate_resource_config(self, resource, config):
        try:
            jsonschema.validate(config, self.RESOURCE_CONFIG_SCHEMA)
        except jsonschema.exceptions.ValidationError as e:
            raise ValidationError(e.message)

    def _save_token(self, token):
        self.update_driver_data(dict(token=token.serialize()))

    def _load_token(self):
        data = self.get_driver_data().get('token')
        return SiPassToken.deserialize(data)

    def api_get_token(self):
        username = self.get_setting('username')
        password = self.get_setting('password')

        data = dict(Username=username, Password=password)
        resp = self.api_req_unauth('authentication', 'POST', data=data)
        if not resp['Token']:
            raise Exception("Username or password incorrect")
        token = SiPassToken(value=resp['Token'], expires_at=None)
        self.logger.info("Token: %s" % token.value)
        return token

    def api_renew_session(self):
        self.api_get('authentication')

    @contextlib.contextmanager
    def ensure_token(self):
        driver_data = self.get_driver_data()
        token_expiration_time = driver_data.get('token_expiration_time')
        token = self._load_token()
        if not token or token.has_expired():
            token = self.api_get_token()

        if not token_expiration_time:
            resp = self.api_req_unauth('authentication/sessiontimeout', 'GET', headers={
                'Authorization': token.value
            })
            if isinstance(resp, int):
                token_expiration_time = resp
            else:
                token_expiration_time = 360  # default
            self.update_driver_data(dict(token_expiration_time=token_expiration_time))

        token.refresh(token_expiration_time)
        self._save_token(token)

        try:
            yield token
        except Exception as e:
            raise

    @contextlib.contextmanager
    def _generate_ca_files(self):
        ca_cert = self.get_setting('tls_ca_cert', True)
        client_cert = self.get_setting('tls_client_cert', True)
        requests_args = {}

        with contextlib.ExitStack() as stack:
            if ca_cert:
                tf = tempfile.NamedTemporaryFile()
                stack.enter_context(tf)
                tf.write(ca_cert.encode('ascii'))
                tf.seek(0)
                requests_args['verify'] = tf.name
            if client_cert:
                tf = tempfile.NamedTemporaryFile()
                tf.write(client_cert.encode('ascii'))
                tf.seek(0)
                stack.enter_context(tf)
                requests_args['cert'] = tf.name
            yield requests_args

    def api_req_unauth(self, path, method, data=None, params=None, headers=None):
        headers = headers.copy() if headers is not None else {}
        headers.update({
            'clientUniqueId': self.get_setting('client_id'),
            'language': 'English'
        })
        verify_tls = self.get_setting('verify_tls')
        url = '%s/%s' % (self.get_setting('api_url'), path)
        self.logger.info('%s: %s' % (method, url))
        args = dict(headers=headers, verify=verify_tls)
        if method == 'POST':
            args['json'] = data
        elif method == 'PUT':
            args['json'] = data
        elif method == 'GET':
            args['params'] = params
        elif method == 'DELETE':
            args['params'] = params
        else:
            raise Exception("Invalid method")

        with self._generate_ca_files() as ca_args:
            args.update(ca_args)
            resp = requests.request(method, url, timeout=REQUESTS_TIMEOUT, **args)

        if resp.status_code not in (200, 201, 204):
            if resp.content:
                try:
                    data = resp.json()
                    err_code = data.get('ErrorCode')
                    err_str = data.get('Message')
                except Exception:
                    err_code = ''
                    err_str = ''
                status_code = resp.status_code
                self.logger.error(f"SiPass API error [HTTP {status_code}] [{err_code}] {err_str}")

            # Clear the object cache just in case, something might've
            # changed in the API.
            self._nuke_object_cache()

            if resp.status_code == 401:
                # If the server responds with "unauthorized", it's possible that
                # the access token is expired (even though it's not supposed to just yet).
                # We nuke the access token and rely on the upper-layer retry mechanism
                # to try again later.
                self.update_driver_data(dict(token=None))

            resp.raise_for_status()

        if not resp.content:
            return None
        return resp.json()

    def api_get(self, path, params=None):
        with self.ensure_token() as token:
            resp = self.api_req_unauth(path, 'GET', params=params, headers={
                'Authorization': token.value
            })
        return resp

    def api_post(self, path, data):
        with self.ensure_token() as token:
            resp = self.api_req_unauth(path, 'POST', data, headers={
                'Authorization': token.value
            })
        return resp

    def api_put(self, path, data):
        with self.ensure_token() as token:
            resp = self.api_req_unauth(path, 'PUT', data, headers={
                'Authorization': token.value
            })
        return resp

    def api_delete(self, path):
        with self.ensure_token() as token:
            resp = self.api_req_unauth(path, 'DELETE', headers={
                'Authorization': token.value
            })
        return resp

    def get_cardholders(self):
        params = {
            'searchString': '',
            'appId': 'Cardholders',
            'fields': 'FirstName,LastName,Status',
            'sortingOrder': '{"FieldName":"LastName","Value":"","SortingOrder":0}',
            'filterExpression': "{'Identifier': ''}",
            'startIndex': 0,
            'endIndex': 100,
        }
        resp = self.api_get('Cardholders', params=params)
        return [dict(
            id=d['Token'],
            status=d['Status'],
            first_name=d['FirstName'],
            last_name=d['LastName']
        ) for d in resp['Records']]

    def get_cardholder(self, cardholder_id):
        return self.api_get('Cardholders/%s' % cardholder_id)

    def get_access_levels(self):
        resp = self.api_get('AccessLevels')
        records = [dict(name=r['Name'], id=r['Token'], type='access_level') for r in resp['Records']]
        return records

    def get_access_point_groups(self):
        resp = self.api_get('AccessPointGroups')
        records = [dict(name=r['Name'], id=r['Token'], type='access_point_group') for r in resp['Records']]
        return records

    def get_workgroups(self):
        resp = self.api_get('WorkGroups')
        records = [dict(name=r['Name'], id=r['Token']) for r in resp['Records']]
        return records

    def get_time_schedules(self):
        resp = self.api_get('TimeSchedules')
        if isinstance(resp, dict) and 'Records' in resp:
            resp = resp['Records']
        return [dict(id=d['Token'], name=d['Name']) for d in resp]

    def get_card_technologies(self):
        resp = self.api_get('CardTechnologies')
        if isinstance(resp, dict) and 'Records' in resp:
            resp = resp['Records']
        return [dict(
            id=d['TechnologyCode'],
            name=d['Name'],
            facility_code_digits=d.get('FacilityCodeDigits', None),
            card_number_digits=d.get('CardNumberDigits', None)
        ) for d in resp]

    def get_credential_profiles(self):
        resp = self.api_get('CredentialProfiles')
        if isinstance(resp, dict) and 'Records' in resp:
            resp = resp['Records']
        return [dict(
            id=d.pop('Token'),
            name=d.pop('Name'),
            card_technology_id=d.pop('CardTechnologyCode'),
            card_technology_name=d.pop('CardTechnology'),
            **d
        ) for d in resp]

    def _refresh_objects(self, object_type):
        """Loads named objects from SiPass API and saves them in the database"""
        object_get_func = getattr(self, 'get_%s' % object_type)
        objs = {x['name']: x for x in object_get_func()}

        with self.system_lock() as system:
            driver_data = system.driver_data or {}
            object_cache = driver_data.setdefault('object_cache', {})
            object_cache[object_type] = objs
            system.driver_data = driver_data
            system.save(update_fields=['driver_data'])
        return objs

    def _nuke_object_cache(self):
        with self.system_lock() as system:
            driver_data = system.driver_data or {}
            if driver_data.get('object_cache'):
                driver_data['object_cache'] = {}
                system.save(update_fields=['driver_data'])

    def get_object_by_id(self, grant, object_type, setting_name):
        """Returns the API object corresponding to an object name

        The object name is taken from either the resource config, or if not found,
        the system-level config. If the object is not in the system-level object
        cache, the objects are refreshed from the API.
        """
        obj_name = grant.resource.driver_config.get(setting_name, None)
        if not obj_name:
            obj_name = self.get_setting(setting_name)

        objs = self.get_driver_data().get('object_cache', {}).get(object_type, {})
        if obj_name not in objs:
            objs = self._refresh_objects(object_type)
        obj = objs.get(obj_name)
        if obj is None:
            raise RemoteError("[%s] Invalid %s name: %s" % (grant, object_type, obj_name))
        return obj

    def remove_cardholder(self, cardholder_id):
        self.api_delete('Cardholders/%s' % cardholder_id)

    def _generate_access_rule(self, access_rule):
        ar = access_rule
        ar_start = ar.start_time
        if ar_start:
            ar_start = ar_start.replace(microsecond=0).isoformat()
        ar_end = ar.end_time
        if ar_end:
            ar_end = ar_end.replace(microsecond=0).isoformat()

        target = ar.target
        out = {
            'ArmingRightsId': None,
            'ControlModeId': None,
            'EndDate': ar_end,
            'ObjectName': target['name'],
            'ObjectToken': '-1',
            'RuleToken': target['id'],
            'RuleType': ar.type,
            'StartDate': ar_start,
            'Side': 0,
            'TimeScheduleToken': ar.time_schedule_id,
        }
        return out

    def api_create_cardholder(self, data):
        required_keys = [
            'first_name', 'last_name', 'card_number', 'pin', 'credential_profile',
            'workgroup'
        ]
        for key in required_keys:
            assert key in data, "Key '%s' needed" % key

        # Compensate for possible clock drift
        now = timezone.now() - timedelta(minutes=10)

        ar_list = data.get('access_rules', [])
        if ar_list:
            start_time = min([x.start_time for x in ar_list])
            end_time = max([x.end_time for x in ar_list])
        else:
            start_time = data.get('start_time', now)
            end_time = start_time + timedelta(days=1)

        access_rules = []
        for ar in ar_list:
            access_rules.append(self._generate_access_rule(ar))

        start_time = start_time.replace(microsecond=0)
        end_time = end_time.replace(microsecond=0)

        credentials = {
            'CardNumber': data['card_number'],
            'CardTechnologyCode': data['card_technology_id'],
            'EndDate': end_time.isoformat(),
            'FacilityCode': 0,
            'Pin': data['pin'],
            'PinMode': data['credential_profile']['PINModeValue']['Type'],
            'ProfileId': data['credential_profile']['id'],
            'ProfileName': data['credential_profile']['name'],
            'StartDate': start_time.isoformat(),
            'RevisionNumber': 0,
        }

        # All of these keys must be present, otherwise the POST request
        # will fail with HTTP 500...
        cardholder_data = {
            'AccessRules': access_rules,
            'ApbWorkgroupId': data['workgroup']['id'],
            'Attributes': {
                'Accessibility': False,
                'ApbExclusion': False,
                'ApbReEntryExclusion': False,
                'Isolate': False,
                'SelfAuthorize': False,
                'Supervisor': False,
                'Visitor': False,
                'Void': False,
            },
            'BaseCardNumber': None,
            'Credentials': [credentials],
            'EmployeeNumber': '',
            'EmployeeName': None,
            'EndDate': end_time.isoformat(),
            'FingerPrints': [],
            'FirstName': data['first_name'],
            'GeneralInformation': '',
            'LastName': data['last_name'],
            'NonPartitionWorkGroups': [],
            'NonPartitionWorkgroupAccessRules': [],
            'PersonalDetails': {
                'Address': '',
                'ContactDetails': {
                    'Email': '',
                    'MobileNumber': '',
                    'MobileServiceProviderId': '0',
                    'PagerNumber': '',
                    'PagerServiceProviderId': '0',
                    'PhoneNumber': '',
                    'ManagerEmailAddress': ''
                },
                'DateOfBirth': '',
                'PayrollNumber': '',
                'Title': '',
                'UserDetails': {
                    'Password': '',
                    'UserName': ''
                }
            },
            'Potrait': None,
            'PrimaryWorkgroupId': data['workgroup']['id'],
            'PrimaryWorkgroupName': data['workgroup']['name'],
            'SmartCardProfileId': '0',
            'SmartCardProfileName': None,
            'StartDate': start_time.isoformat(),
            'Status': 62,  # 62 means Valid
            'Token': <PASSWORD>',
            'TraceDetails': {
                'CardLastUsed': None,
                'CardNumberLastUsed': None,
                'LastApbLocation': None,
                'PointName': None,
                'TraceCard': False
            },
            'Vehicle1': {
                'CarColor': '',
                'CarModelNumber': '',
                'CarRegistrationNumber': ''
            },
            'Vehicle2': {
                'CarColor': '',
                'CarModelNumber': '',
                'CarRegistrationNumber': ''
            },
            'VisitorDetails': {
                'VisitedEmployeeFirstName': '',
                'VisitedEmployeeLastName': '',
                'VisitorCardStatus': 0,
                'VisitorCustomValues': {}
            }
        }
        resp = self.api_post('Cardholders', data=cardholder_data)
        cardholder_id = resp['Token']

        return cardholder_id

    def create_access_user(self, grant):
        user = grant.reservation.user
        # Make sure there is something in first_name and last_name
        first_name = user.first_name or 'Kulkunen'
        last_name = user.last_name or 'Kulkunen'

        # We lock the access control instance through the database to protect
        # against race conditions.
        with self.system_lock():
            # The PIN also serves as the cardholder identifier (they must
            # be identical). Try at most 20 times to generate an unused PIN,
            # and if that fails, we probably have other problems. Upper layers
            # will take care of retrying later in case the unlikely false positive
            # happens.
            for i in range(20):
                pin = get_random_string(1, '123456789') + get_random_string(3, '0123456789')
                if not self.system.users.active().filter(identifier=pin).exists():
                    break
            else:
                raise RemoteError("Unable to find a PIN code for grant")

            user_attrs = dict(identifier=pin, first_name=first_name, last_name=last_name, user=user)
            user = self.system.users.create(**user_attrs)

        return user

    def create_cardholder(self, grant, user):
        start_time = grant.starts_at
        end_time = grant.ends_at

        access_point_group = self.get_object_by_id(grant, 'access_point_groups', 'access_point_group_name')
        access_rule = SiPassAccessRule(access_point_group, start_time, end_time)

        credential_profile = self.get_object_by_id(grant, 'credential_profiles', 'credential_profile_name')
        workgroup = self.get_object_by_id(grant, 'workgroups', 'cardholder_workgroup_name')
        card_technology_id = credential_profile['card_technology_id']

        cardholder_id = self.api_create_cardholder(dict(
            first_name=user.first_name,
            last_name=user.last_name,
            card_number=user.identifier,
            pin=user.identifier,
            access_rules=[access_rule],
            card_technology_id=card_technology_id,
            credential_profile=credential_profile,
            workgroup=workgroup,
        ))

        return cardholder_id

    def install_grant(self, grant):
        self.logger.info('[%s] Installing SiPass grant' % grant)

        assert grant.state == grant.INSTALLING

        user = self.create_access_user(grant)

        try:
            cardholder_id = self.create_cardholder(grant, user)
        except Exception as e:
            user.delete()
            raise

        self.logger.info('[%s] Cardholder created with ID %s and PIN %s' % (grant, cardholder_id, user.identifier))

        user.driver_data = dict(cardholder_id=cardholder_id)
        user.save(update_fields=['driver_data'])
        grant.access_code = user.identifier
        grant.user = user
        grant.state = grant.INSTALLED
        grant.remove_at = grant.ends_at
        grant.save(update_fields=['user', 'state', 'access_code', 'remove_at'])
        grant.notify_access_code()

    def remove_grant(self, grant):
        self.logger.info('[%s] Removing SiPass grant' % grant)

        assert grant.state == grant.REMOVING

        user = grant.user
        cardholder_id = user.driver_data['cardholder_id']
        self.remove_cardholder(user.driver_data['cardholder_id'])

        user.state = user.REMOVED
        user.removed_at = timezone.now()
        user.save(update_fields=['state', 'removed_at'])

        grant.state = grant.REMOVED
        grant.removed_at = user.removed_at
        grant.save(update_fields=['state', 'removed_at'])

        self.logger.info('[%s] Cardholder with ID %s and PIN %s removed' % (grant, cardholder_id, user.identifier))

    def prepare_install_grant(self, grant):
        # Because of a bug in SiPass API, the changes are not synchronized
        # to the building units automatically. We install the grants one day
        # before their start time and schedule a re-install of the units
        # every nightly.
        grant.install_at = grant.starts_at - timedelta(days=1)
        grant.save(update_fields=['install_at'])

    def save_respa_resource(self, resource, respa_resource):
        # SiPass driver generates access codes by itself, so we need to
        # make sure Respa doesn't generate them.
        if not respa_resource.generate_access_codes:
            return
        respa_resource.generate_access_codes = False

    def save_resource(self, resource):
        # SiPass driver generates access codes by itself, so we need to
        # make sure Respa doesn't generate them.
        respa_resource = resource.resource
        if not respa_resource.generate_access_codes:
            return
        respa_resource.generate_access_codes = False
        respa_resource.save(update_fields=['generate_access_codes'])

# Usually (?) maps to "Always" # seconds # default # Clear the object cache just in case, something might've # changed in the API. # If the server responds with "unauthorized", it's possible that # the access token is expired (even though it's not supposed to just yet). # We nuke the access token and rely on the upper-layer retry mechanism # to try again later. Loads named objects from SiPass API and saves them in the database Returns the API object corresponding to an object name          The object name is taken from either the resource config, or if not found,         the system-level config. If the object is not in the system-level object         cache, the objects are refreshed from the API. # Compensate for possible clock drift # All of these keys must be present, otherwise the POST request # will fail with HTTP 500... # 62 means Valid # Make sure there is something in first_name and last_name # We lock the access control instance through the database to protect # against race conditions. # The PIN also serves as the cardholder identifier (they must # be identical). Try at most 20 times to generate an unused PIN, # and if that fails, we probably have other problems. Upper layers # will take care of retrying later in case the unlikely false positive # happens. # Because of a bug in SiPass API, the changes are not synchronized # to the building units automatically. We install the grants one day # before their start time and schedule a re-install of the units # every nightly. # SiPass driver generates access codes by itself, so we need to # make sure Respa doesn't generate them. # SiPass driver generates access codes by itself, so we need to # make sure Respa doesn't generate them.