import re
import time
from datetime import datetime

import requests

from ...Logging.Logger import Severity, log
from ...Machine.MachineInterface import MachineInterface
from ...Machine.PaedMLMachineCollection import LMZ_COMMON_CREDENTIALS, MACHINE_COLLECTION
from ...SetupSteps.Playbooks.Playbook import FileCleanupTask, PlaybookCredentialCollection
from ...SetupSteps.SetupStep import SetupStep
from .ApplyKeycloakSettings import ApplyKeycloakSettings


class MigrateToKeycloak(SetupStep):
    PWDFILE = '/etc/domadmin.secret'
    SSP_BACKUP = '/home/Administrator/SSP_BACKUP'

    def __init__(self):
        super().__init__()
        domainname = LMZ_COMMON_CREDENTIALS.get_domainname()
        self.sso_fqdn = f'login.{domainname}'
        self.cert_path = f'/etc/univention/ssl/{self.sso_fqdn}/cert.pem'
        self.key_path = f'/etc/univention/ssl/{self.sso_fqdn}/private.key'
        self.idp_descriptor = f'https://{self.sso_fqdn}/realms/ucs/protocol/saml/descriptor'

    def _is_migrated(self, server: MachineInterface) -> bool:
        _, _, retval = server.execute_shell_command('univention-keycloak-migration-status')
        return retval == 0

    def _ensure_certificate(self, server: MachineInterface) -> bool:
        if server.check_if_file_exists(self.cert_path):
            return True
        log(f'Generating SSO certificate for {self.sso_fqdn}...', Severity.DEBUG)
        stdout, stderr, retval = server.execute_shell_command('/usr/sbin/univention-certificate', 'new', '-name', self.sso_fqdn)
        if retval != 0:
            log(f'Failed to generate certificate: {stderr if len(stderr) > 0 else stdout}', Severity.ERROR)
            return False
        return True

    def _is_keycloak_installed(self, server: MachineInterface) -> bool:
        stdout, _, _ = server.execute_shell_command('univention-app', 'info')
        for line in stdout.splitlines():
            if re.match(r'^Installed.*keycloak', line):
                return True
        return False

    def _install_keycloak(self, server: MachineInterface) -> bool:
        log('Installing Keycloak...', Severity.INFO)
        stdout, stderr, retval = server.execute_shell_command(
            '/usr/bin/univention-app', 'install', 'keycloak',
            '--set',
            f'keycloak/server/sso/fqdn={self.sso_fqdn}',
            'keycloak/server/sso/autoregistration=true',
            'keycloak/server/sso/virtualhost=true',
            'keycloak/apache/config=true',
            f'keycloak/apache2/ssl/certificate={self.cert_path}',
            f'keycloak/apache2/ssl/key={self.key_path}',
            'keycloak/cookies/samesite=Strict',
            '--noninteractive',
            '--username', 'Administrator',
            '--pwdfile', self.PWDFILE,
        )
        if retval != 0:
            log(f'Failed to install Keycloak: {stderr if len(stderr) > 0 else stdout}', Severity.ERROR)
            return False
        return True

    def _update_umc_saml_client(self, machine: MachineInterface, ldap_base: str) -> bool:
        log(f'Updating UMC SAML client on {machine.get_hostname()}...', Severity.DEBUG)
        stdout, stderr, retval = machine.execute_shell_command('ucr', 'set', f'umc/saml/idp-server={self.idp_descriptor}')
        if retval != 0:
            log(f'Failed to set umc/saml/idp-server on {machine.get_hostname()}: {stderr if len(stderr) > 0 else stdout}', Severity.ERROR)
            return False

        stdout, stderr, retval = machine.execute_shell_command(
            'udm', 'portals/entry', 'modify',
            '--dn', f'cn=login-saml,cn=entry,cn=portals,cn=univention,{ldap_base}',
            '--set', 'activated=TRUE',
        )
        if retval != 0:
            log(f'Failed to modify portal entry on {machine.get_hostname()}: {stderr if len(stderr) > 0 else stdout}', Severity.ERROR)
            return False
        return True

    def _wait_for_keycloak_ready(self, timeout_seconds: int = 180, poll_interval: float = 3.0) -> bool:
        """
        Poll Keycloak's OIDC discovery endpoint until it returns 200, or give up.

        After `univention-app restart keycloak`, Apache (the reverse proxy) starts accepting
        connections again before Keycloak itself is ready to serve requests, so for tens of
        seconds the upstream returns errors and Apache replies with HTTP 502
        ("Error reading from remote server"). Polling a cheap, public endpoint lets us
        proceed the instant Keycloak is genuinely up, instead of betting on a fixed sleep.
        """
        url = f'https://{self.sso_fqdn}/realms/master/.well-known/openid-configuration'
        deadline = time.monotonic() + timeout_seconds
        last_status: str = 'no response yet'
        while time.monotonic() < deadline:
            try:
                response = requests.get(url, timeout=10)
                if response.status_code == 200:
                    log('Keycloak is ready.', Severity.DEBUG)
                    return True
                last_status = f'HTTP {response.status_code}'
            except requests.RequestException as e:
                last_status = f'connection error: {e}'
            log(f'Keycloak not ready yet ({last_status}); waiting...', Severity.TRACE)
            time.sleep(poll_interval)
        log(f'Keycloak did not become ready within {timeout_seconds}s (last: {last_status}).', Severity.ERROR)
        return False

    def _refresh_keycloak_truststore(self, server: MachineInterface) -> bool:
        """
        Rebuild Keycloak's JVM truststore from the current UCS root CA and restart the app.

        The container caches its truststore at startup, and Keycloak runs on the JVM where
        `cacerts` is separate from the system trust bundle (so a plain
        `univention-app update-certificates` is not enough). `univention-app configure`
        re-runs the Keycloak app's settings hooks, which rebuild the JVM truststore from
        the current `/etc/univention/ssl/ucsCA/CAcert.pem`. Without this, LDAPS binds made
        by Keycloak's LDAP federation provider fail with PKIX and the token endpoint
        returns HTTP 500 with `unknown_error`.
        """
        log('Reconfiguring Keycloak to rebuild the JVM truststore...', Severity.INFO)
        stdout, stderr, retval = server.execute_shell_command('univention-app', 'configure', 'keycloak')
        if retval != 0:
            log(f'Failed to reconfigure Keycloak: {stderr if len(stderr) > 0 else stdout}', Severity.ERROR)
            return False

        # configure does not restart the app, so do it explicitly to make Keycloak reread
        # the truststore.
        log('Restarting Keycloak to pick up the refreshed truststore...', Severity.DEBUG)
        stdout, stderr, retval = server.execute_shell_command('univention-app', 'restart', 'keycloak')
        if retval != 0:
            log(f'Failed to restart Keycloak: {stderr if len(stderr) > 0 else stdout}', Severity.ERROR)
            return False

        # Wait for Keycloak to actually be serving requests before returning. Apache will
        # accept connections immediately after restart but proxy a 502 until the upstream
        # is ready, so a fixed sleep here is unreliable.
        return self._wait_for_keycloak_ready()

    def _complete_migration(self, server: MachineInterface) -> bool:
        timestamp = datetime.now().strftime('%Y.%m.%d-%H.%M.%S')
        backup_path = f'{self.SSP_BACKUP}/SSP_backup.{timestamp}'
        stdout, stderr, retval = server.execute_shell_command(
            'univention-keycloak-migration-status',
            '-f', '-d',
            '--backup-path', backup_path,
            '--create-sso-uri-setting',
        )
        if retval != 0:
            log(f'Failed to complete Keycloak migration: {stderr if len(stderr) > 0 else stdout}', Severity.ERROR)
            return False
        return True

    def run(self, file_cleanup_stack: list[FileCleanupTask], credentials: PlaybookCredentialCollection) -> bool:
        super().run(file_cleanup_stack, credentials)
        log('Migrating to Keycloak...', Severity.INFO)

        server = MACHINE_COLLECTION.server
        backup = MACHINE_COLLECTION.backup
        ldap_base = LMZ_COMMON_CREDENTIALS.get_ldap_base()
        apply_settings = ApplyKeycloakSettings(self.sso_fqdn)

        log('Checking Keycloak migration status...', Severity.DEBUG)
        if self._is_migrated(server):
            log('Keycloak migration is already done.', Severity.INFO)
            if not self._refresh_keycloak_truststore(server):
                return False
            return apply_settings.run(file_cleanup_stack, credentials)

        if not self._ensure_certificate(server):
            return False

        log('Checking if Keycloak is installed...', Severity.DEBUG)
        if self._is_keycloak_installed(server):
            log('Keycloak is already installed; ensuring ucs/server/sso/uri is set...', Severity.DEBUG)
            stdout, stderr, retval = server.execute_shell_command(
                'ucr', 'set', f'ucs/server/sso/uri=https://{self.sso_fqdn}/'
            )
            if retval != 0:
                log(f'Failed to set ucs/server/sso/uri: {stderr if len(stderr) > 0 else stdout}', Severity.ERROR)
                return False
            if self._is_migrated(server):
                log('Keycloak migration is already done.', Severity.INFO)
                if not self._refresh_keycloak_truststore(server):
                    return False
                return apply_settings.run(file_cleanup_stack, credentials)
        else:
            if not self._install_keycloak(server):
                return False

        if not self._update_umc_saml_client(server, ldap_base):
            return False

        log('Restarting slapd.service...', Severity.DEBUG)
        stdout, stderr, retval = server.execute_shell_command('systemctl', 'restart', 'slapd.service')
        if retval != 0:
            log(f'Failed to restart slapd.service: {stderr if len(stderr) > 0 else stdout}', Severity.ERROR)
            return False

        if not self._update_umc_saml_client(backup, ldap_base):
            return False

        # Restart of Keycloak forces a fresh truststore read and gives the LDAP federation
        # provider a clean reconnect to the just-restarted slapd. Without this, password
        # validation on the token endpoint can throw PKIX or transient bind errors.
        if not self._refresh_keycloak_truststore(server):
            return False

        if not apply_settings.run(file_cleanup_stack, credentials):
            return False

        if not server.check_if_file_exists(self.SSP_BACKUP):
            log(f'Creating SimpleSAMLphp backup directory {self.SSP_BACKUP}...', Severity.DEBUG)
            server.mkdir(self.SSP_BACKUP)

        if not self._complete_migration(server):
            return False

        log('Keycloak migration completed successfully.', Severity.INFO)
        return True
