# BFH Science Self-Service Portal
# Copyright (C) 2025-2026 IKUS Software
#
# This program 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 this program.  If not, see <https://www.gnu.org/licenses/>.

import base64
import hashlib
import logging
import re
from collections import namedtuple
from functools import cached_property

import cherrypy
from cherrypy_foundation.plugins.ldap import _safe, all_attribute, first_attribute
from cherrypy_foundation.tools.i18n import gettext_lazy as _
from ldap3.core.exceptions import LDAPOperationResult

logger = logging.getLogger(__name__)

AccessStatus = namedtuple('AccessStatus', 'value,color,label,description')


class User:
    """
    User object.
    """

    # Define all SSH public key status
    SSH_ENABLED = AccessStatus('enabled', 'success', _('enabled'), _('An active SSH key is in place.'))
    SSH_DISABLED = AccessStatus('disabled', 'danger', _('disabled'), _('SSH access disabled by an administrator.'))
    SSH_UNSET = AccessStatus('unset', 'secondary', _('unset'), _('No SSH key is set.'))
    SSH_INVALID = AccessStatus('invalid', 'warning', _('invalid'), _('The SSH key appears to be invalid.'))

    # Define all MFA status
    MFA_ENABLED = AccessStatus('enabled', 'success', _('enabled'), None)
    MFA_DISABLED = AccessStatus('disabled', 'gray', _('disabled'), None)
    MFA_NOT_SYNC = AccessStatus(
        'notsync',
        'gray',
        _('not synced'),
        _(
            'This account has not been synced to Microsoft Entra ID. MFA can only be configured for users with a cloud identity.'
        ),
    )
    MFA_UNKNOWN = AccessStatus(
        'unknown', 'gray', _('unknown'), _('Unable to retrieve MFA status from Microsoft Entra ID.')
    )

    # Define all VPN status
    VPN_ENABLED = AccessStatus('enabled', 'success', _('enabled'), None)
    VPN_DISABLED = AccessStatus('disabled', 'gray', _('disabled'), None)
    VPN_UNAVAILABLE = AccessStatus(
        'unavailable',
        'gray',
        _('unavailable'),
        _(
            "This account is not synced to Microsoft Entra ID. VPN access can only be enabled for users with a cloud identity."
        ),
    )
    VPN_MFA_REQUIRED = AccessStatus(
        'missing mfa', 'warning', _('disabled'), _("VPN is enable, but MFA is not configured.")
    )

    # Users roles
    ROLE_GUEST = 'guest'
    ROLE_USERS = 'users'
    ROLE_ADMIN = 'admin'

    # Account time const
    TYPE_STAFF = 'Staff'
    TYPE_STUD = 'Stud'
    TYPE_EXT = 'Ext'
    TYPE_GUEST = 'Guest'

    def __init__(self, dn, attrs):
        assert dn
        assert attrs
        self.dn = dn
        self.attrs = dict(attrs)

    @classmethod
    def _get_minimal_user_attributes(cls):
        """
        Return a list of minimal user attributes to create a User Object.
        """
        attributes = list(cherrypy.ldap.username_attribute)
        if cherrypy.ldap.fullname_attribute:
            attributes.extend(cherrypy.ldap.fullname_attribute)
        if cherrypy.ldap.firstname_attribute:
            attributes.extend(cherrypy.ldap.firstname_attribute)
        if cherrypy.ldap.lastname_attribute:
            attributes.extend(cherrypy.ldap.lastname_attribute)
        if cherrypy.ldap.email_attribute:
            attributes.extend(cherrypy.ldap.email_attribute)
        return attributes

    @classmethod
    def search(cls, term, limit=25):
        """
        Allow to search for users in LDAP.
        """
        assert limit > 0
        if not term:
            return []
        # Get list of attributes to be queried.
        attributes = cls._get_minimal_user_attributes()
        # Construct a search query to lookup in username, email, firstname or lastname
        words = [_safe(word) for word in term.split(' ') if word]
        filters = [''.join(f'({attr}=*{word}*)' for attr in attributes) for word in words]
        filters = [f'(|{filter})' for filter in filters]
        search_criteria = f"(&{''.join(filters)})"
        search_filter = f'(&{cherrypy.ldap.user_filter}(|{search_criteria}))'
        # Search for object.
        response = False
        try:
            response = cherrypy.ldap.search(search_filter, attributes=attributes, paged_size=limit)
        except LDAPOperationResult:
            logger.warning('failed to query users', exc_info=1)
            return []
        if not response:
            return []
        # Return list of User objects.
        return [User(dn=r['dn'], attrs=r['attributes']) for r in response]

    @classmethod
    def _query_ldap_attributes(cls, *ldap_dn, attributes=None):
        """
        Query LDAP attributes for specific DN.
        """
        # Merge all groups attributes together.
        return_value = []
        for dn in ldap_dn:
            assert isinstance(dn, str)
            try:
                response = cherrypy.ldap.search(filter="(objectClass=*)", search_base=dn, attributes=attributes)
            except LDAPOperationResult:
                logger.warning(f'failed to retrieve ldap attributes {attributes} for dn={dn}', exc_info=1)
                continue
            if not response:
                continue
            attrs = response[0]['attributes']
            attrs['dn'] = response[0]['dn']
            return_value.append(attrs)
        return return_value

    @classmethod
    def _query_managers(cls, *groups_dn, groups_details=None):
        """
        Return list of managers for the given LDAP groups.
        """
        # Start by querying the managers for each groups.
        if groups_details is None:
            groups_details = User._query_ldap_attributes(*groups_dn, attributes=['bfhGroupManager'])
        # Group manager contains a comma separated list of common name
        # Let flatten the list of managers
        managers_dn = set()
        for group in groups_details:
            if group.get('bfhGroupManager'):
                value = group['bfhGroupManager']
                if isinstance(value, list):
                    value = (', '.join(value)).split(', ')
                elif isinstance(value, str):
                    value = value.split(', ')
                else:
                    raise ValueError('unexpected data type for bfhGroupManager attribute: %s' % type(value))
                managers_dn.update(value)
        if not managers_dn:
            return []
        # Query details for all managers.
        user_attributes = cls._get_minimal_user_attributes()
        managers_details = User._query_ldap_attributes(*sorted(managers_dn), attributes=user_attributes)
        # Cast everything into User object.
        return [User(dn=manager['dn'], attrs=manager) for manager in managers_details]

    @classmethod
    def from_username(cls, username):
        """
        Create User from LDAP attributes for the given username.
        """
        # Reconstruct the same construct base on configuration.
        search_filter = "(&{}({}={}))".format(
            cherrypy.ldap.user_filter, _safe(cherrypy.ldap.username_attribute[0]), _safe(username)
        )
        # Search for object.
        try:
            response = cherrypy.ldap.search(search_filter)
        except LDAPOperationResult:
            logger.warning(f'failed to find user for username={username}' % username, exc_info=1)
            return None
        if response:
            return User(dn=response[0]['dn'], attrs=response[0]['attributes'])

    def as_owner_dict(self):
        return {
            'name': self.name,
            'username': self.username,
            'email': self.email,
        }

    @property
    def username(self):
        return first_attribute(self.attrs, cherrypy.ldap.username_attribute)

    @property
    def account_type(self):
        return first_attribute(self.attrs, 'bfhAccountType')

    @property
    def ceph_directory(self):
        return first_attribute(self.attrs, 'bfhCephDirectory')

    @property
    def departement(self):
        """
        Return the user's section.
        """
        # Do not use the bfhDepartment. It's not reliable, while "o" for organization, is always defined.
        return first_attribute(self.attrs, 'o')

    def deduplicate_groups(self, pattern, permission_priority=None):
        """
        Deduplicate groups
        """

        # Convert pattern to regex by replacing placeholders
        regex_pattern = re.escape(pattern)
        regex_pattern = regex_pattern.replace(r'<NAME>', r'(?P<name>[^,\.]+)')
        regex_pattern = regex_pattern.replace(r'<PERMISSION>', r'(?P<permission>[^,\.]+)')
        regex_pattern = f'^{regex_pattern}$'
        compiled_pattern = re.compile(regex_pattern)

        # Lookup matching groups
        # key = name, value = (group_cn, name, permission)
        groups_map = {}
        for group_dn in self.groups:
            match = compiled_pattern.match(group_dn)
            if not match:
                continue

            name = match.group('name')
            permission = match.group('permission')
            if permission_priority and permission not in permission_priority:
                # Permission not in priority list, ignore group.
                continue

            # Extract CN (everything between "cn=" and first comma)
            cn_match = re.match(r'^cn=([^,]+)', group_dn)
            group_cn = cn_match.group(1) if cn_match else None

            # Handle deduplication with permission priority
            if name not in groups_map:
                groups_map[name] = (group_cn, name, permission)
            elif permission_priority:
                _group_cn, _name, existing_permission = groups_map[name]

                try:
                    current_idx = permission_priority.index(permission)
                    existing_idx = permission_priority.index(existing_permission)

                    # Lower index = higher priority
                    if current_idx < existing_idx:
                        groups_map[name] = (group_cn, name, permission)
                except ValueError:
                    # Permission not in priority list, keep first match
                    pass

        return list(groups_map.values())

    @property
    def email(self):
        """Return primary email"""
        return first_attribute(self.attrs, cherrypy.ldap.email_attribute)

    @property
    def email_alias(self):
        """Return all user's email including alias."""
        return all_attribute(self.attrs, cherrypy.ldap.email_attribute + ['bfhMailAlias'], default=[])

    @property
    def groups(self):
        return self.attrs.get('memberOf', [])

    @property
    def linux_containers(self):
        """Multi-value list of containers."""
        return all_attribute(self.attrs, 'bfhLinuxContainer', default=[])

    @property
    def name(self):
        """Return user's fullname."""
        return_value = first_attribute(self.attrs, cherrypy.ldap.fullname_attribute)
        if not return_value:
            firstname = first_attribute(self.attrs, cherrypy.ldap.firstname_attribute, default='')
            lastname = first_attribute(self.attrs, cherrypy.ldap.lastname_attribute, default='')
            if firstname or lastname:
                return_value = f'{firstname} {lastname}'
        return return_value

    @property
    def name_with_title(self):
        """Return user's fullname."""
        title = first_attribute(self.attrs, 'bfhAcademicTitle')
        if title:
            return f'{title} {self.name}'
        return self.name

    @property
    def gid(self):
        return first_attribute(self.attrs, 'gidNumber')

    def _is_member_of(self, groups):
        """
        Check if the current user is member of any of the listed group OR match account_type
        """

        def _equals(str1, str2):
            return str1 is not None and str2 is not None and str1.lower() == str2.lower()

        # Run all comparaison case-insensitive.
        user_groups = [g.lower() for g in self.groups]
        return groups and any((x.lower() in user_groups or _equals(x, self.account_type)) for x in groups)

    @property
    def lang(self):
        return first_attribute(self.attrs, 'preferredLanguage')

    @property
    def location(self):
        parts = []
        street = first_attribute(self.attrs, 'street')
        if street:
            parts.append(street)
        # Combine postal code + city only if at least one exists
        postal_code = first_attribute(self.attrs, 'postalCode')
        city = first_attribute(self.attrs, 'l')
        city_block = " ".join(x for x in [postal_code, city] if x)
        if city_block:
            parts.append(city_block)
        return ", ".join(parts)

    @property
    def location_office(self):
        """
        If defined, this is the office number.
        """
        return first_attribute(self.attrs, 'physicalDeliveryOfficeName', None)

    @cached_property
    def mfa_status(self):
        """
        Query MFA status from Microsoft Graph API. Use AD UPN.
        """
        try:
            methods = cherrypy.microsoft_mfa.get_authentication_methods(self.upn)
        except Exception as e:
            if 'Not Found' in str(e):
                return User.MFA_NOT_SYNC
            logger.warning(f'failed to query MFA status for upn={self.upn}', exc_info=1)
            return User.MFA_UNKNOWN
        # Check if methods are MFA.
        if any(m for m in methods if cherrypy.microsoft_mfa.is_method_mfa(m)):
            return User.MFA_ENABLED
        return User.MFA_DISABLED

    @property
    def nextcloud_enabled(self):
        cfg = cherrypy.tree.apps[''].cfg
        return self._is_member_of(cfg.ldap_nextcloud_enabled_group)

    @property
    def organisational_unit(self):
        return first_attribute(self.attrs, 'ou')

    @property
    def phone(self):
        return all_attribute(self.attrs, 'telephoneNumber', default=[])

    @property
    def role(self):
        """
        Return the user's role: guest, users, admin.
        """
        cfg = cherrypy.tree.apps[''].cfg
        if self._is_member_of(cfg.ldap_admin_group):
            return User.ROLE_ADMIN
        if self._is_member_of(cfg.ldap_users_group):
            return User.ROLE_USERS
        return User.ROLE_GUEST  # Guest and Ext

    @property
    def section(self):
        """Return the user's section."""
        return first_attribute(self.attrs, 'bfhSection')

    @property
    def state(self):
        return first_attribute(self.attrs, 'bfhState')

    @property
    def ssh_key(self):
        """
        Return the public key
        """
        value = first_attribute(self.attrs, 'sshPublicKey')
        if isinstance(value, bytes):
            value = value.decode('ascii', errors='replace')
        return value

    @property
    def ssh_key_status(self):
        public_key = self.ssh_key
        if not public_key:
            return User.SSH_UNSET
        elif public_key in [b'disabled', 'disabled']:
            return User.SSH_DISABLED
        elif self.ssh_key_fingerprint is None:
            return User.SSH_INVALID
        return User.SSH_ENABLED

    @property
    def ssh_jumphosts_enabled(self):
        cfg = cherrypy.tree.apps[''].cfg
        # Check if user should have access based on group.
        jumphosts = []
        if self._is_member_of(cfg.ldap_ssh_bfh_science_group):
            jumphosts.append('ssh.bfh.science')
        if self._is_member_of(cfg.ldap_ssh_bfh_info_group):
            jumphosts.append('ssh.bfh.info')
        return jumphosts

    @property
    def ssh_key_fingerprint(self):
        """
        Return SHA256 fingerprint of ssh key.
        """
        public_key = self.ssh_key
        if not public_key:
            return None
        try:
            # Extract base64 part (the second field)
            key_parts = public_key.strip().split()
            if len(key_parts) < 2:
                return None
            key_b64 = key_parts[1]
            # Decode base64 to raw bytes
            key_bytes = base64.b64decode(key_b64)
            # Compute SHA256 fingerprint (like OpenSSH default)
            fp = base64.b64encode(hashlib.sha256(key_bytes).digest()).decode('utf-8').rstrip('=')
            return f'SHA256:{fp}'
        except Exception:
            # Return nothing if the key is invalid.
            logger.warning(f'failed to create SSH fingerprint for public_key={public_key}')
            return None

    @property
    def title(self):
        return first_attribute(self.attrs, 'title')

    @property
    def uid(self):
        return first_attribute(self.attrs, 'uidNumber')

    @property
    def upn(self):
        return first_attribute(self.attrs, 'bfhUPN')

    @property
    def vpn_status(self):

        mfa_status = self.mfa_status
        if self.mfa_status == User.MFA_NOT_SYNC:
            # Account is not in Microsoft Entra ID
            return User.VPN_UNAVAILABLE

        cfg = cherrypy.tree.apps[''].cfg
        vpn_enabled = self._is_member_of(cfg.ldap_vpn_enabled_group)
        if vpn_enabled:
            if mfa_status == User.MFA_ENABLED:
                return User.VPN_ENABLED
            else:
                # VPN is enabled, but account is missing MFA.
                return User.VPN_MFA_REQUIRED
        else:
            return User.VPN_DISABLED

    @property
    def wlan_enabled(self):
        cfg = cherrypy.tree.apps[''].cfg
        return self._is_member_of(cfg.ldap_wlan_enabled_group)
