# 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 argparse
import grp
import pwd
import sys

# Get package version
from importlib.metadata import distribution as get_distribution
from pathlib import Path
from urllib.parse import urlparse

import configargparse
import markupsafe
from babel import Locale, dates

try:
    __version__ = get_distribution("udb").version
except Exception:
    __version__ = 'DEV'


def _comma_or_space_separated(value):
    """
    Parse comma or space separated values for argparse.

    Args:
        value (str): Input string containing comma or space separated values

    Returns:
        list: List of parsed values with whitespace stripped

    Raises:
        argparse.ArgumentTypeError: If the input cannot be parsed
    """
    if not isinstance(value, str):
        raise argparse.ArgumentTypeError("Expected string, got %s" % type(value).__name__)

    if not value.strip():
        return []

    # Split by comma first, then by spaces if no commas found
    if ',' in value:
        # Split by comma and strip whitespace from each item
        items = [item.strip() for item in value.split(',')]
    else:
        # Split by any whitespace
        items = value.split()

    # Filter out empty strings
    items = [item for item in items if item]
    return items


def _space_separated(value):
    """
    Space separated values for argparse.

    Args:
        value (str): Input string containing space separated values

    Returns:
        list: List of parsed values with whitespace stripped

    Raises:
        argparse.ArgumentTypeError: If the input cannot be parsed
    """
    if not isinstance(value, str):
        raise argparse.ArgumentTypeError("Expected string, got %s" % type(value).__name__)

    if not value.strip():
        return []

    # Split by any whitespace
    items = value.split()

    # Filter out empty strings
    items = [item for item in items if item]
    return items


def _userid(value):
    """
    Parse the user value attribute that could be either a username or a userid.
    """
    try:
        return int(value)
    except ValueError:
        pass
    try:
        return pwd.getpwnam(value).pw_uid
    except KeyError:
        raise argparse.ArgumentError('Unknown user: %s' % value)


def _groupid(value):
    """
    Parse the group value that could be either a group name or groupid.
    """
    try:
        return int(value)
    except ValueError:
        pass
    try:
        return grp.getgrnam(value).gr_gid
    except KeyError:
        raise argparse.ArgumentError('Unknown group: %s' % value)


def _locale(value):
    """
    Validate locale value. Return string.
    """
    if not value:
        return value
    try:
        return str(Locale.parse(value.replace('-', '_')))
    except Exception:
        raise argparse.ArgumentError('Unknown locale: %s' % value)


def _umask(value):
    """
    Validate the umask value. Read it as octal and return integer.
    """
    try:
        return int(value, base=8)
    except ValueError:
        raise argparse.ArgumentTypeError('Invalid umask: %s' % value)


def _url(value):
    """
    Validate the URL.
    """
    parsed = urlparse(value)
    if parsed.scheme not in ('http', 'https'):
        raise argparse.ArgumentTypeError('Invalid URL: %s' % value)
    return value


def _markup(value):
    """
    HTML markup.
    """
    return markupsafe.Markup(value)


def _timezone(value):
    """
    Validate timezone.
    """
    if not value:
        return value
    try:
        dates.get_timezone(value)
    except Exception:
        raise argparse.ArgumentError('Unknown timezone: %s' % value)
    return value


def _file_or_content(value):
    try:
        path = Path(value)
        # Only treat as file if it actually exists
        if path.exists() and path.is_file():
            return path.read_text()
    except (OSError, ValueError):
        pass
    # Treat as literal content
    return value


def get_parser():
    parser = configargparse.ArgumentParser(
        description='%(prog)s - BFH Science Self-Service Portal is a web-based self-service portal for user resource management. It supports LDAP authentication, OpenStack integration, and automation.',
        default_config_files=['/etc/bfh-ssp/bfh-ssp.conf', '/etc/bfh-ssp/bfh-ssp.conf.d/*.conf'],
        add_config_file_help=True,
        add_env_var_help=True,
        auto_env_var_prefix='SSP_',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    group = parser.add_argument_group("Server Configuration")
    group.add_argument(
        '-d',
        '--debug',
        action='store_true',
        help='Enable debug mode - change the log level to DEBUG, print exception stack trace to the web interface.',
    )
    group.add_argument('-f', '--config', is_config_file=True, help='Configuration file path')
    group.add_argument('-v', '--version', action='version', version='udb ' + __version__)
    group.add_argument(
        '--server-host',
        metavar='IP',
        help='Define the IP address to listen on.',
        default='127.0.0.1',
    )
    group.add_argument('--server-port', metavar='PORT', help='Define the port to listen on.', default='8080', type=int)
    group.add_argument(
        '--umask',
        help="Force a specific umask value. Usually expressed in octal format, for example, ``0022``. Default value is inherited from parent process.",
        metavar='UMASK',
        type=_umask,
    )
    group.add_argument(
        '--user',
        help="User under which the server will handle requests. In order to use this directive, the process must be run initially as root. If you start the server as a non-root user, it will fail to change to the lesser privileged user, and will instead continue to run as that original user.",
        metavar='USER',
        type=_userid,
    )
    group.add_argument(
        '--group',
        help="Group under which the server will handle requests. In order to use this directive, the process must be run initially as root.",
        metavar='GROUP',
        type=_groupid,
    )
    group.add_argument(
        '--external-url',
        metavar='URL',
        help='URL that should be used to reach this service. You can use the IP of your server, but a Fully Qualified Domain Name (FQDN) is preferred.',
    )
    group.add_argument('--log-file', metavar='FILE', help='Define the location of the log file.')
    group.add_argument('--log-access-file', metavar='FILE', help='Define the location of the access log file.')
    group.add_argument(
        '--log-level',
        help='Define the log level.',
        choices=['ERROR', 'WARN', 'INFO', 'DEBUG'],
        default='INFO',
    )
    group.add_argument(
        '--default-lang',
        help='Default application locale. e.g.: `fr` or with territory `fr_CA`.',
        default='en_US',
        type=_locale,
    )
    group.add_argument(
        '--default-timezone',
        help='Default application timezone. e.g.: `America/toronto`. Uses server timezone when undefined.',
        type=_timezone,
    )
    group.add_argument(
        '--rate-limit',
        metavar='LIMIT',
        type=int,
        default=20,
        help='Maximum number of requests per hour that can be made on sensitive endpoint. When this limit is reached, an HTTP 429 message is returned to the user or user gets logged out. This security measure is used to limit brute force attacks on the login page and the RESTful API.',
    )
    group.add_argument(
        '--rate-limit-delay',
        metavar='SECONDS',
        type=int,
        default=3600,
        help='Time window in seconds for rate limit enforcement. After this period expires, the request counter resets and users can make requests again. Default is 3600 seconds (1 hour).',
    )
    #
    # Session
    #
    group = parser.add_argument_group("Session Management")
    group.add_argument(
        '--session-dir',
        metavar='FOLDER',
        help='Location where user session and rate-limit information are stored. When undefined, the data are kept in memory.',
    )
    group.add_argument(
        '--session-idle-timeout',
        metavar='MINUTES',
        help='This timeout defines the amount of time a session will remain active in case there is no activity in the session. User sessions will be revoked after this period of inactivity, unless the user selected "remember me". Default 60 minutes.',
        default=60,
    )
    group.add_argument(
        '--session-absolute-timeout',
        metavar='MINUTES',
        help='This timeout defines the maximum amount of time a session can be active. After this period, the user is forced to (re)authenticate, unless the user selected "remember me". Default 1440 minutes or 1 day.',
        default=1440,
    )
    group.add_argument(
        '--session-persistent-timeout',
        metavar='MINUTES',
        help='This timeout defines the maximum amount of time to remember and trust a user device. This timeout is used when user select "remember me". Default 30 days.',
        default=43200,
    )

    #
    # LDAP
    #
    group = parser.add_argument_group("LDAP")
    group.add_argument(
        '--ldap-uri',
        help='URL to the LDAP server used to validate user credentials. e.g.: ldap://localhost:389.',
    )
    group.add_argument(
        '--ldap-base-dn',
        metavar='DN',
        help='DN of the branch of the directory where all searches should start from. e.g.: dc=my,dc=domain.',
    )
    group.add_argument(
        '--ldap-scope',
        help='Scope of the search. Can be either base, onelevel, or subtree.',
        choices=['base', 'onelevel', 'subtree'],
        default="subtree",
    )
    group.add_argument('--ldap-tls', action='store_true', help='Enable TLS.')
    group.add_argument(
        '--ldap-login-attribute',
        # FIXME --ldap-username-attribute: for backward compatibility bfh-ssp<=0.4.2
        '--ldap-username-attribute',
        metavar='ATTRIBUTE',
        help="The attribute to search login. If no attributes are provided, the default is to use `uid`. It's a good idea to choose an attribute that will be unique across all entries in the subtree you will be using.",
        type=_comma_or_space_separated,
        default='uid',
    )
    group.add_argument(
        '--ldap-user-filter',
        help="Search filter to limit LDAP lookup. If not provided, defaults to `(objectClass=*)`, which searches for all objects in the tree. For improved performance it's recommended to narrow the search to your user object class. e.g.: `(objectClass=posixAccount)`.",
        default='(objectClass=*)',
    )
    group.add_argument(
        '--ldap-group-filter',
        help="Search filter to limit LDAP lookup of groups. If not provided, defaults to `(objectClass=*)`, which searches for all objects in the tree. For improved performance it's recommended to narrow the search to your group object class. e.g.: `(objectClass=posixGroup)`.",
        default='(objectClass=*)',
    )
    group.add_argument(
        '--ldap-group-attribute',
        metavar='ATTRIBUTE',
        help="Name of the attribute on the group that holds the list of members. Common value is: `memberUid`.",
        default='member',
    )
    group.add_argument(
        '--ldap-group-attribute-is-dn',
        help="True if the group contains a list of users defined by DN instead of username.",
        action='store_true',
    )
    group.add_argument(
        '--ldap-bind-dn',
        metavar='DN',
        help="Optional DN used to bind to the server when searching for entries. If not provided, will use an anonymous bind.",
    )
    group.add_argument(
        '--ldap-bind-password',
        metavar='PASSWORD',
        help="Password to use in conjunction with `--ldap-bind-dn`. Note that the bind password is probably sensitive data, and should be properly protected. You should only use the `--ldap-bind-dn` and `--ldap-bind-password` if you absolutely need them to search the directory.",
    )
    group.add_argument(
        '--ldap-version',
        help="Version of LDAP to use either 2 or 3.",
        default=3,
        type=int,
        choices=[2, 3],
    )
    group.add_argument(
        '--ldap-network-timeout',
        metavar='SECONDS',
        help="Timeout in seconds used for LDAP connection.",
        default=5,
        type=int,
    )
    group.add_argument(
        '--ldap-timeout',
        metavar='SECONDS',
        help="Timeout in seconds used for LDAP request.",
        default=5,
        type=int,
    )
    group.add_argument(
        '--ldap-encoding',
        metavar='ENCODING',
        help="Encoding used by your LDAP server.",
        default="utf-8",
    )
    group.add_argument(
        '--ldap-fullname-attribute',
        help="LDAP attribute for user display name. If `fullname` is blank, the fullname is taken from the `firstname` and `lastname`. Attributes 'cn', or 'displayName' commonly carry full names.",
        type=_comma_or_space_separated,
    )
    group.add_argument(
        '--ldap-firstname-attribute',
        help="LDAP attribute for user first name. Used when the attribute configured for name does not exist. e.g.: `givenName`.",
        type=_comma_or_space_separated,
        default='givenName',
    )
    group.add_argument(
        '--ldap-lastname-attribute',
        help="LDAP attribute for user last name. Used when the attribute configured for name does not exist. e.g.: `sn`.",
        type=_comma_or_space_separated,
        default='sn',
    )
    group.add_argument(
        '--ldap-email-attribute',
        help="LDAP attribute for user email. e.g.: mail, email, userPrincipalName.",
        type=_comma_or_space_separated,
        default='mail,email',
    )
    group.add_argument(
        '--ldap-pool-size',
        help="Size of the LDAP connection pool used in a pooled connection strategy.",
        type=int,
        default=10,
    )

    #
    # Email
    #
    group = parser.add_argument_group("Email")
    group.add_argument('--smtp-server', metavar='SERVER', help='SMTP server used to send email.')
    group.add_argument(
        '--smtp-username', metavar='USERNAME', help='Username used to authenticate with the SMTP server.'
    )
    group.add_argument(
        '--smtp-password', metavar='PASSWORD', help='Password used to authenticated with the SMTP server.'
    )
    group.add_argument('--smtp-from', metavar='SERVER', help='Email address used to send email.')
    group.add_argument(
        '--smtp-encryption',
        default='none',
        choices=['none', 'ssl', 'starttls'],
        help='Type of encryption to be used when establishing communication with SMTP server: none, ssl, starttls',
    )
    group.add_argument(
        '--smtp-bcc',
        metavar='EMAIL',
        help='When defined, all email will be sent to this email address.',
    )

    #
    # Branding
    #
    group = parser.add_argument_group("Branding")
    group.add_argument(
        '--favicon',
        dest='favicon',
        help='Location of an icon to be used as a favicon displayed in web browser.',
    )
    group.add_argument(
        '--header-name',
        metavar='NAME',
        default='<b>BFH</b>.science Self&#8209;Service&nbsp;Portal',
        help='Name used in the title for this application.',
        type=_markup,
    )
    group.add_argument(
        '--footer-name',
        metavar='NAME',
        default='Self-Service Portal',
        help='Text displayed in the footer alongside "powered by".',
    )
    group.add_argument(
        '--footer-url',
        metavar='URL',
        default="https://gitlab.com/ikus-soft/bfh-ssp",
        help='URL used in the footer alongside "powered by".',
    )

    #
    # Roles
    #
    group = parser.add_argument_group("User Access")
    group.add_argument(
        '--ldap-admin-group',
        default="cn=IDM.perm.infrastructure.ssp.admin,ou=Security,ou=Groups,dc=bfh cn=IDM.ser-its.pers-vma,ou=Security,ou=Groups,dc=bfh",
        help="A space-separated list of LDAP group or account type entries given access to view other users resources.",
        metavar='DN',
        type=_space_separated,
    )

    group.add_argument(
        '--ldap-users-group',
        default="Staff Stud cn=IDM.perm.infrastructure.ssp.users,ou=Security,ou=Groups,dc=bfh",
        help="A space-separated list of LDAP group or account type entries given access to create resources automatically (e.g., create storage).",
        metavar='DN',
        type=_space_separated,
    )

    group.add_argument(
        '--ldap-nextcloud-enabled-group',
        default="Staff Stud cn=IDM.perm.compute.nextcloud.access,ou=Security,ou=Groups,dc=bfh",
        help="A space-separated list of LDAP group or account type entries given access to Nextcloud.",
        metavar='DN',
        type=_space_separated,
    )

    group.add_argument(
        '--ldap-ssh-bfh-science-group',
        default="Staff Stud cn=IDM.perm.infrastructure.ssh.access,ou=Security,ou=Groups,dc=bfh",
        help="A space-separated list of LDAP group or account type entries given access to ssh.bfh.science.",
        metavar='DN',
        type=_space_separated,
    )

    group.add_argument(
        '--ldap-ssh-bfh-info-group',
        default="cn=IDM.infra.infrastructure.ssh.access,ou=Security,ou=Groups,dc=bfh",
        help="A space-separated list of LDAP group or account type entries given access to ssh.bfh.info.",
        metavar='DN',
        type=_space_separated,
    )

    group.add_argument(
        '--ldap-vpn-enabled-group',
        default="Staff Stud cn=IDM.perm.infrastructure.vpn.access,ou=Security,ou=Groups,dc=bfh",
        help="A space-separated list of LDAP group or account type entries given access to VPN.",
        metavar='DN',
        type=_space_separated,
    )

    group.add_argument(
        '--ldap-wlan-enabled-group',
        default="Staff Stud cn=IDM.perm.network.wlan.access,ou=Security,ou=Groups,dc=bfh",
        help="A space-separated list of LDAP group or account type entries given access to WLAN.",
        metavar='DN',
        type=_space_separated,
    )

    #
    # OpenStack
    #
    group = parser.add_argument_group("OpenStack Connection")
    group.add_argument(
        '--openstack-username',
        default="bfh-ssp",
        help="Username used to authenticate with OpenStack Keystone API.",
        metavar='USERNAME',
    )
    group.add_argument(
        '--openstack-password',
        help="Password used to authenticate with OpenStack Keystone API.",
        metavar='PASSWORD',
    )
    group.add_argument(
        '--openstack-auth-url',
        help="OpenStack authentication endpoint (URL) of the Keystone Identity service.",
        metavar='URL',
        type=_url,
    )

    #
    # Znuny
    #
    group = parser.add_argument_group("Znuny ITS Connection")
    group.add_argument(
        '--znuny-userlogin',
        help="Userlogin used to authenticate with Znuny ITS REST API.",
        metavar='USERLOGIN',
    )
    group.add_argument(
        '--znuny-password',
        help="Password used to authenticate with Znuny ITS REST API.",
        metavar='PASSWORD',
    )
    group.add_argument(
        '--znuny-ticket-create-url',
        help="Configure the fully qualified Znuny GenericInterface URL for the TicketCreate operation. "
        "This URL is defined in the Znuny Admin console and is highly customizable. In addition to "
        "selecting the TicketCreate operation, you must specify the Webservice name. "
        "Pattern: https://<host>/otrs/nph-genericinterface.pl/Webservice/<WEBSERVICE-NAME>/<OPERATION>"
        "Example: https://example.com/otrs/nph-genericinterface.pl/Webservice/MyWebservice/TicketCreate",
        metavar='URL',
        type=_url,
    )

    #
    # Microsoft Entra ID
    #
    group = parser.add_argument_group("Microsoft Entra ID")
    group.add_argument(
        '--microsoft-tenant-id',
        default=None,
        help="Microsoft Entra tenant ID (GUID) or domain name (e.g., contoso.onmicrosoft.com). "
        "Find this in Azure Portal > Microsoft Entra ID > Overview.",
        metavar='GUID',
    )
    group.add_argument(
        '--microsoft-client-id',
        default=None,
        help="Application (client) ID from your app registration in Microsoft Entra admin center "
        "with at least the `Privileged Authentication Administrator` or `Authentication Administrator`. "
        "Required to authenticate with Microsoft Graph API. ",
        metavar='GUID',
    )
    group.add_argument(
        '--microsoft-client-secret',
        default=None,
        help="Client secret value (not the secret ID) created in Certificates & secrets section. "
        "Used for application authentication. Keep this confidential.",
        metavar='SECRET',
    )
    group.add_argument(
        '--microsoft-client-private-key',
        default=None,
        help="Private key in PEM format, or path to a file containing the key.",
        metavar='KEY',
        type=_file_or_content,
    )
    group.add_argument(
        '--microsoft-client-cert-thumbprint',
        default=None,
        help="The thumbprint is available in your app's registration in Azure Portal.",
        metavar='THUMBPRINT',
    )

    #
    # Application specific
    #
    group = parser.add_argument_group("Application")
    group.add_argument(
        '--change-password-url',
        default="https://selfhelp.bfh.ch",
        help="URL used to redirect users to the password change page.",
        metavar='URL',
        type=_url,
    )
    group.add_argument(
        '--enable-mfa-url',
        default="https://bfh.ch/mfa",
        help="URL used to redirect users to enable MFA.",
        metavar='URL',
        type=_url,
    )
    group.add_argument(
        '--user-image-url',
        default="https://img.bfh.ch/?id=%s&size=small",
        help="URL used to fetch user's picture. Use %%s as a place holder for username.",
        metavar='URL',
        type=_url,
    )
    group.add_argument(
        '--ssh-info-url',
        default="https://bfh.info/services/infrastructure/ssh/",
        help="URL to the SSH information.",
        metavar='URL',
        type=_url,
    )
    group.add_argument(
        '--vpn-info-url',
        default="https://bfh.info/services/infrastructure/vpn/",
        help="URL to the VPN information.",
        metavar='URL',
        type=_url,
    )
    group.add_argument(
        '--wlan-info-url',
        default="https://bfh.info/services/network/wlan/",
        help="URL to the WLAN information.",
        metavar='URL',
        type=_url,
    )
    group.add_argument(
        '--nextcloud-info-url',
        default="https://nextcloud.bfh.science/",
        help="URL to the Nextcloud application.",
        metavar='URL',
        type=_url,
    )
    group.add_argument(
        '--ssp-info-url',
        default="https://bfh.science/",
        help="URL to the Self-Service portal application.",
        metavar='URL',
        type=_url,
    )
    group.add_argument(
        '--new-storage-cmd',
        metavar='COMMAND',
        help="Command line to create new storage for a user. Environment variables available: SSP_USER_USERNAME, SSP_USER_DN, SSP_USER_EMAIL, SSP_STORAGE_NAME, SSP_STORAGE_TYPE, SSP_STORAGE_SIZE, SSP_MESSAGE.",
    )
    group.add_argument(
        '--replace-ssh-key-cmd',
        metavar='COMMAND',
        help="Command line to replace user's SSH Key. Environment variables available: SSP_USER_USERNAME, SSP_USER_DN, SSP_USER_EMAIL, SSP_SSH_KEY.",
    )
    group.add_argument(
        '--enable-openstack-cmd',
        metavar='COMMAND',
        help="Command line to create new storage for a user. Environment variables available: SSP_USER_USERNAME, SSP_USER_DN, SSP_USER_EMAIL.",
    )
    group.add_argument(
        '--accepted-ssh-key-type',
        metavar='TYPE',
        help="Comma- or space-separated list of accepted SSH key types. e.g.: ssh-rsa, ssh-ed25519.",
        default='ssh-ed25519',
        type=_comma_or_space_separated,
    )

    return parser


def parse_args(args=None, config_file_contents=None):
    """
    Load application configuration using program's arguments or environment variables.
    """
    parser = get_parser()
    args = sys.argv[1:] if args is None else args
    return parser.parse_args(args, config_file_contents=config_file_contents)
