#!/usr/bin/python

# Copyright (c) Ansible project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later

from __future__ import annotations

DOCUMENTATION = r"""
module: keycloak_user_federation

short_description: Allows administration of Keycloak user federations using Keycloak API

version_added: 3.7.0

description:
  - This module allows you to add, remove or modify Keycloak user federations using the Keycloak REST API. It requires access
    to the REST API using OpenID Connect; the user connecting and the client being used must have the requisite access rights.
    In a default Keycloak installation, admin-cli and an admin user would work, as would a separate client definition with
    the scope tailored to your needs and a user having the expected roles.
  - The names of module options are snake_cased versions of the camelCase ones found in the Keycloak API and its documentation
    at U(https://www.keycloak.org/docs-api/20.0.2/rest-api/index.html).
attributes:
  check_mode:
    support: full
  diff_mode:
    support: full
  action_group:
    version_added: 10.2.0

options:
  state:
    description:
      - State of the user federation.
      - On V(present), the user federation is created if it does not yet exist, or updated with the parameters you provide.
      - On V(absent), the user federation is removed if it exists.
    default: 'present'
    type: str
    choices:
      - present
      - absent

  realm:
    description:
      - The Keycloak realm under which this user federation resides.
    default: 'master'
    type: str

  id:
    description:
      - The unique ID for this user federation. If left empty, the user federation is searched by its O(name).
    type: str

  name:
    description:
      - Display name of provider when linked in admin console.
    type: str

  provider_id:
    description:
      - Provider for this user federation. Built-in providers are V(ldap), V(kerberos), and V(sssd). Custom user storage providers
        can also be used.
    aliases:
      - providerId
    type: str

  provider_type:
    description:
      - Component type for user federation (only supported value is V(org.keycloak.storage.UserStorageProvider)).
    aliases:
      - providerType
    default: org.keycloak.storage.UserStorageProvider
    type: str

  parent_id:
    description:
      - Unique ID for the parent of this user federation. Realm ID is automatically used if left blank.
    aliases:
      - parentId
    type: str

  remove_unspecified_mappers:
    description:
      - Remove mappers that are not specified in the configuration for this federation.
      - Set to V(false) to keep mappers that are not listed in O(mappers).
    type: bool
    default: true
    version_added: 9.4.0

  bind_credential_update_mode:
    description:
      - The value of the config parameter O(config.bindCredential) is redacted in the Keycloak responses. Comparing the redacted
        value with the desired value always evaluates to not equal. This means the before and desired states are never equal
        if the parameter is set.
      - Set to V(always) to include O(config.bindCredential) in the comparison of before and desired state. Because of the
        redacted value returned by Keycloak the module always detects a change and make an update if a O(config.bindCredential)
        value is set.
      - Set to V(only_indirect) to exclude O(config.bindCredential) when comparing the before state with the desired state.
        The value of O(config.bindCredential) is only updated if there are other changes to the user federation that require
        an update.
    type: str
    default: always
    choices:
      - always
      - only_indirect
    version_added: 9.5.0

  config:
    description:
      - Dict specifying the configuration options for the provider; the contents differ depending on the value of O(provider_id).
        Examples are given below for V(ldap), V(kerberos) and V(sssd). It is easiest to obtain valid config values by dumping
        an already-existing user federation configuration through check-mode in the RV(existing) field.
      - The value V(sssd) has been supported since community.general 4.2.0.
    type: dict
    suboptions:
      enabled:
        description:
          - Enable/disable this user federation.
        default: true
        type: bool

      priority:
        description:
          - Priority of provider when doing a user lookup. Lowest first.
        default: 0
        type: int

      importEnabled:
        description:
          - If V(true), LDAP users are imported into Keycloak DB and synced by the configured sync policies.
        default: true
        type: bool

      editMode:
        description:
          - V(READ_ONLY) is a read-only LDAP store. V(WRITABLE) means data is synced back to LDAP on demand. V(UNSYNCED) means
            user data is imported, but not synced back to LDAP.
        type: str
        choices:
          - READ_ONLY
          - WRITABLE
          - UNSYNCED

      syncRegistrations:
        description:
          - Should newly created users be created within LDAP store? Priority effects which provider is chosen to sync the
            new user.
        default: false
        type: bool

      vendor:
        description:
          - LDAP vendor (provider).
          - Use short name. For instance, write V(rhds) for "Red Hat Directory Server".
        type: str

      usernameLDAPAttribute:
        description:
          - Name of LDAP attribute, which is mapped as Keycloak username. For many LDAP server vendors it can be V(uid). For
            Active directory it can be V(sAMAccountName) or V(cn). The attribute should be filled for all LDAP user records
            you want to import from LDAP to Keycloak.
        type: str

      rdnLDAPAttribute:
        description:
          - Name of LDAP attribute, which is used as RDN (top attribute) of typical user DN. Usually it is the same as Username
            LDAP attribute, however it is not required. For example for Active directory, it is common to use V(cn) as RDN
            attribute when username attribute might be V(sAMAccountName).
        type: str

      uuidLDAPAttribute:
        description:
          - Name of LDAP attribute, which is used as unique object identifier (UUID) for objects in LDAP. For many LDAP server
            vendors, it is V(entryUUID); however some are different. For example for Active directory it should be V(objectGUID).
            If your LDAP server does not support the notion of UUID, you can use any other attribute that is supposed to be
            unique among LDAP users in tree.
        type: str

      userObjectClasses:
        description:
          - All values of LDAP objectClass attribute for users in LDAP divided by comma. For example V(inetOrgPerson, organizationalPerson).
            Newly created Keycloak users are written to LDAP with all those object classes and existing LDAP user records
            are found just if they contain all those object classes.
        type: str

      connectionUrl:
        description:
          - Connection URL to your LDAP server.
        type: str

      usersDn:
        description:
          - Full DN of LDAP tree where your users are. This DN is the parent of LDAP users.
        type: str

      customUserSearchFilter:
        description:
          - Additional LDAP Filter for filtering searched users. Leave this empty if you do not need additional filter.
        type: str

      searchScope:
        description:
          - For one level, the search applies only for users in the DNs specified by User DNs. For subtree, the search applies
            to the whole subtree. See LDAP documentation for more details.
        default: '1'
        type: str
        choices:
          - '1'
          - '2'

      authType:
        description:
          - Type of the Authentication method used during LDAP Bind operation. It is used in most of the requests sent to
            the LDAP server.
        default: 'none'
        type: str
        choices:
          - none
          - simple

      bindDn:
        description:
          - DN of LDAP user which is used by Keycloak to access LDAP server.
        type: str

      bindCredential:
        description:
          - Password of LDAP admin.
        type: str

      startTls:
        description:
          - Encrypts the connection to LDAP using STARTTLS, which disables connection pooling.
        default: false
        type: bool

      usePasswordModifyExtendedOp:
        description:
          - Use the LDAPv3 Password Modify Extended Operation (RFC-3062). The password modify extended operation usually requires
            that LDAP user already has password in the LDAP server. So when this is used with 'Sync Registrations', it can
            be good to add also 'Hardcoded LDAP attribute mapper' with randomly generated initial password.
        default: false
        type: bool

      validatePasswordPolicy:
        description:
          - Determines if Keycloak should validate the password with the realm password policy before updating it.
        default: false
        type: bool

      trustEmail:
        description:
          - If enabled, email provided by this provider is not verified even if verification is enabled for the realm.
        default: false
        type: bool

      useTruststoreSpi:
        description:
          - Specifies whether LDAP connection uses the truststore SPI with the truststore configured in standalone.xml/domain.xml.
            V(always) means that it always uses it. V(never) means that it does not use it. V(ldapsOnly) means that it uses
            if your connection URL use ldaps.
          - Note even if standalone.xml/domain.xml is not configured, the default Java cacerts or certificate specified by
            C(javax.net.ssl.trustStore) property is used.
        default: ldapsOnly
        type: str
        choices:
          - always
          - ldapsOnly
          - never

      connectionTimeout:
        description:
          - LDAP Connection Timeout in milliseconds.
        type: int

      readTimeout:
        description:
          - LDAP Read Timeout in milliseconds. This timeout applies for LDAP read operations.
        type: int

      pagination:
        description:
          - Does the LDAP server support pagination.
        default: true
        type: bool

      connectionPooling:
        description:
          - Determines if Keycloak should use connection pooling for accessing LDAP server.
        default: true
        type: bool

      connectionPoolingAuthentication:
        description:
          - A list of space-separated authentication types of connections that may be pooled.
        type: str
        choices:
          - none
          - simple
          - DIGEST-MD5

      connectionPoolingDebug:
        description:
          - A string that indicates the level of debug output to produce. Example valid values are V(fine) (trace connection
            creation and removal) and V(all) (all debugging information).
        type: str

      connectionPoolingInitSize:
        description:
          - The number of connections per connection identity to create when initially creating a connection for the identity.
        type: int

      connectionPoolingMaxSize:
        description:
          - The maximum number of connections per connection identity that can be maintained concurrently.
        type: int

      connectionPoolingPrefSize:
        description:
          - The preferred number of connections per connection identity that should be maintained concurrently.
        type: int

      connectionPoolingProtocol:
        description:
          - A list of space-separated protocol types of connections that may be pooled. Valid types are V(plain) and V(ssl).
        type: str

      connectionPoolingTimeout:
        description:
          - The number of milliseconds that an idle connection may remain in the pool without being closed and removed from
            the pool.
        type: int

      allowKerberosAuthentication:
        description:
          - Enable/disable HTTP authentication of users with SPNEGO/Kerberos tokens. The data about authenticated users is
            provisioned from this LDAP server.
        default: false
        type: bool

      kerberosRealm:
        description:
          - Name of kerberos realm.
        type: str

      krbPrincipalAttribute:
        description:
          - Name of the LDAP attribute, which refers to Kerberos principal. This is used to lookup appropriate LDAP user after
            successful Kerberos/SPNEGO authentication in Keycloak. When this is empty, the LDAP user is looked up based on
            LDAP username corresponding to the first part of his Kerberos principal. For instance, for principal C(john@KEYCLOAK.ORG),
            it assumes that LDAP username is V(john).
        type: str
        version_added: 8.1.0

      serverPrincipal:
        description:
          - Full name of server principal for HTTP service including server and domain name. For example V(HTTP/host.foo.org@FOO.ORG).
            Use V(*) to accept any service principal in the KeyTab file.
        type: str

      keyTab:
        description:
          - Location of Kerberos KeyTab file containing the credentials of server principal. For example V(/etc/krb5.keytab).
        type: str

      debug:
        description:
          - Enable/disable debug logging to standard output for Krb5LoginModule.
        type: bool

      useKerberosForPasswordAuthentication:
        description:
          - Use Kerberos login module for authenticate username/password against Kerberos server instead of authenticating
            against LDAP server with Directory Service API.
        default: false
        type: bool

      allowPasswordAuthentication:
        description:
          - Enable/disable possibility of username/password authentication against Kerberos database.
        type: bool

      batchSizeForSync:
        description:
          - Count of LDAP users to be imported from LDAP to Keycloak within a single transaction.
        default: 1000
        type: int

      fullSyncPeriod:
        description:
          - Period for full synchronization in seconds.
        default: -1
        type: int

      changedSyncPeriod:
        description:
          - Period for synchronization of changed or newly created LDAP users in seconds.
        default: -1
        type: int

      updateProfileFirstLogin:
        description:
          - Update profile on first login.
        type: bool

      cachePolicy:
        description:
          - Cache Policy for this storage provider.
        type: str
        default: 'DEFAULT'
        choices:
          - DEFAULT
          - EVICT_DAILY
          - EVICT_WEEKLY
          - MAX_LIFESPAN
          - NO_CACHE

      evictionDay:
        description:
          - Day of the week the entry is set to become invalid on.
        type: str

      evictionHour:
        description:
          - Hour of day the entry is set to become invalid on.
        type: str

      evictionMinute:
        description:
          - Minute of day the entry is set to become invalid on.
        type: str

      maxLifespan:
        description:
          - Max lifespan of cache entry in milliseconds.
        type: int

      referral:
        description:
          - Specifies if LDAP referrals should be followed or ignored. Please note that enabling referrals can slow down authentication
            as it allows the LDAP server to decide which other LDAP servers to use. This could potentially include untrusted
            servers.
        type: str
        choices:
          - ignore
          - follow
        version_added: 9.5.0

  mappers:
    description:
      - A list of dicts defining mappers associated with this Identity Provider.
    type: list
    elements: dict
    suboptions:
      id:
        description:
          - Unique ID of this mapper.
        type: str

      name:
        description:
          - Name of the mapper. If no ID is given, the mapper is searched by name.
        type: str

      parentId:
        description:
          - Unique ID for the parent of this mapper. ID of the user federation is automatically used if left blank.
        type: str

      providerId:
        description:
          - The mapper type for this mapper (for instance V(user-attribute-ldap-mapper)).
        type: str

      providerType:
        description:
          - Component type for this mapper.
        type: str
        default: org.keycloak.storage.ldap.mappers.LDAPStorageMapper

      config:
        description:
          - Dict specifying the configuration options for the mapper; the contents differ depending on the value of I(identityProviderMapper).
        type: dict

extends_documentation_fragment:
  - community.general._keycloak
  - community.general._keycloak.actiongroup_keycloak
  - community.general._attributes

author:
  - Laurent Paumier (@laurpaum)
"""

EXAMPLES = r"""
- name: Create LDAP user federation
  community.general.keycloak_user_federation:
    auth_keycloak_url: https://keycloak.example.com/auth
    auth_realm: master
    auth_username: admin
    auth_password: password
    realm: my-realm
    name: my-ldap
    state: present
    provider_id: ldap
    provider_type: org.keycloak.storage.UserStorageProvider
    config:
      priority: 0
      enabled: true
      cachePolicy: DEFAULT
      batchSizeForSync: 1000
      editMode: READ_ONLY
      importEnabled: true
      syncRegistrations: false
      vendor: other
      usernameLDAPAttribute: uid
      rdnLDAPAttribute: uid
      uuidLDAPAttribute: entryUUID
      userObjectClasses: inetOrgPerson, organizationalPerson
      connectionUrl: ldaps://ldap.example.com:636
      usersDn: ou=Users,dc=example,dc=com
      authType: simple
      bindDn: cn=directory reader
      bindCredential: password
      searchScope: 1
      validatePasswordPolicy: false
      trustEmail: false
      useTruststoreSpi: ldapsOnly
      connectionPooling: true
      pagination: true
      allowKerberosAuthentication: false
      debug: false
      useKerberosForPasswordAuthentication: false
    mappers:
      - name: "full name"
        providerId: "full-name-ldap-mapper"
        providerType: "org.keycloak.storage.ldap.mappers.LDAPStorageMapper"
        config:
          ldap.full.name.attribute: cn
          read.only: true
          write.only: false

- name: Create Kerberos user federation
  community.general.keycloak_user_federation:
    auth_keycloak_url: https://keycloak.example.com/auth
    auth_realm: master
    auth_username: admin
    auth_password: password
    realm: my-realm
    name: my-kerberos
    state: present
    provider_id: kerberos
    provider_type: org.keycloak.storage.UserStorageProvider
    config:
      priority: 0
      enabled: true
      cachePolicy: DEFAULT
      kerberosRealm: EXAMPLE.COM
      serverPrincipal: HTTP/host.example.com@EXAMPLE.COM
      keyTab: keytab
      allowPasswordAuthentication: false
      updateProfileFirstLogin: false

- name: Create sssd user federation
  community.general.keycloak_user_federation:
    auth_keycloak_url: https://keycloak.example.com/auth
    auth_realm: master
    auth_username: admin
    auth_password: password
    realm: my-realm
    name: my-sssd
    state: present
    provider_id: sssd
    provider_type: org.keycloak.storage.UserStorageProvider
    config:
      priority: 0
      enabled: true
      cachePolicy: DEFAULT

- name: Delete user federation
  community.general.keycloak_user_federation:
    auth_keycloak_url: https://keycloak.example.com/auth
    auth_realm: master
    auth_username: admin
    auth_password: password
    realm: my-realm
    name: my-federation
    state: absent
"""

RETURN = r"""
msg:
  description: Message as to what action was taken.
  returned: always
  type: str
  sample: "No changes required to user federation 164bb483-c613-482e-80fe-7f1431308799."

proposed:
  description: Representation of proposed user federation.
  returned: always
  type: dict
  sample:
    {
      "config": {
        "allowKerberosAuthentication": "false",
        "authType": "simple",
        "batchSizeForSync": "1000",
        "bindCredential": "**********",
        "bindDn": "cn=directory reader",
        "cachePolicy": "DEFAULT",
        "connectionPooling": "true",
        "connectionUrl": "ldaps://ldap.example.com:636",
        "debug": "false",
        "editMode": "READ_ONLY",
        "enabled": "true",
        "importEnabled": "true",
        "pagination": "true",
        "priority": "0",
        "rdnLDAPAttribute": "uid",
        "searchScope": "1",
        "syncRegistrations": "false",
        "trustEmail": "false",
        "useKerberosForPasswordAuthentication": "false",
        "useTruststoreSpi": "ldapsOnly",
        "userObjectClasses": "inetOrgPerson, organizationalPerson",
        "usernameLDAPAttribute": "uid",
        "usersDn": "ou=Users,dc=example,dc=com",
        "uuidLDAPAttribute": "entryUUID",
        "validatePasswordPolicy": "false",
        "vendor": "other"
      },
      "name": "ldap",
      "providerId": "ldap",
      "providerType": "org.keycloak.storage.UserStorageProvider"
    }

existing:
  description: Representation of existing user federation.
  returned: always
  type: dict
  sample:
    {
      "config": {
        "allowKerberosAuthentication": "false",
        "authType": "simple",
        "batchSizeForSync": "1000",
        "bindCredential": "**********",
        "bindDn": "cn=directory reader",
        "cachePolicy": "DEFAULT",
        "changedSyncPeriod": "-1",
        "connectionPooling": "true",
        "connectionUrl": "ldaps://ldap.example.com:636",
        "debug": "false",
        "editMode": "READ_ONLY",
        "enabled": "true",
        "fullSyncPeriod": "-1",
        "importEnabled": "true",
        "pagination": "true",
        "priority": "0",
        "rdnLDAPAttribute": "uid",
        "searchScope": "1",
        "syncRegistrations": "false",
        "trustEmail": "false",
        "useKerberosForPasswordAuthentication": "false",
        "useTruststoreSpi": "ldapsOnly",
        "userObjectClasses": "inetOrgPerson, organizationalPerson",
        "usernameLDAPAttribute": "uid",
        "usersDn": "ou=Users,dc=example,dc=com",
        "uuidLDAPAttribute": "entryUUID",
        "validatePasswordPolicy": "false",
        "vendor": "other"
      },
      "id": "01122837-9047-4ae4-8ca0-6e2e891a765f",
      "mappers": [
        {
          "config": {
            "always.read.value.from.ldap": "false",
            "is.mandatory.in.ldap": "false",
            "ldap.attribute": "mail",
            "read.only": "true",
            "user.model.attribute": "email"
          },
          "id": "17d60ce2-2d44-4c2c-8b1f-1fba601b9a9f",
          "name": "email",
          "parentId": "01122837-9047-4ae4-8ca0-6e2e891a765f",
          "providerId": "user-attribute-ldap-mapper",
          "providerType": "org.keycloak.storage.ldap.mappers.LDAPStorageMapper"
        }
      ],
      "name": "myfed",
      "parentId": "myrealm",
      "providerId": "ldap",
      "providerType": "org.keycloak.storage.UserStorageProvider"
    }

end_state:
  description: Representation of user federation after module execution.
  returned: on success
  type: dict
  sample:
    {
      "config": {
        "allowPasswordAuthentication": "false",
        "cachePolicy": "DEFAULT",
        "enabled": "true",
        "kerberosRealm": "EXAMPLE.COM",
        "keyTab": "/etc/krb5.keytab",
        "priority": "0",
        "serverPrincipal": "HTTP/host.example.com@EXAMPLE.COM",
        "updateProfileFirstLogin": "false"
      },
      "id": "cf52ae4f-4471-4435-a0cf-bb620cadc122",
      "mappers": [],
      "name": "kerberos",
      "parentId": "myrealm",
      "providerId": "kerberos",
      "providerType": "org.keycloak.storage.UserStorageProvider"
    }
"""

from copy import deepcopy
from urllib.parse import urlencode

from ansible.module_utils.basic import AnsibleModule

from ansible_collections.community.general.plugins.module_utils._keycloak import (
    KeycloakAPI,
    KeycloakError,
    camel,
    get_token,
    keycloak_argument_spec,
)


def normalize_kc_comp(comp):
    if "config" in comp:
        # kc completely removes the parameter `krbPrincipalAttribute` if it is set to `''`; the unset kc parameter is equivalent to `''`;
        # to make change detection and diff more accurate we set it again in the kc responses
        if "krbPrincipalAttribute" not in comp["config"]:
            comp["config"]["krbPrincipalAttribute"] = [""]

        # kc stores a timestamp of the last sync in `lastSync` to time the periodic sync, it is removed to minimize diff/changes
        comp["config"].pop("lastSync", None)


def sanitize(comp):
    def sanitize_value(v):
        """Convert list values: single-element lists to strings, multi-element lists sorted alphabetically, others as-is."""
        if isinstance(v, list):
            if len(v) == 0:
                return None
            elif len(v) == 1:
                return v[0]
            else:
                return sorted(v)
        else:
            return v

    compcopy = deepcopy(comp)
    if "config" in compcopy:
        compcopy["config"] = {k: sanitize_value(v) for k, v in compcopy["config"].items()}
        # Remove None values (empty lists converted)
        compcopy["config"] = {k: v for k, v in compcopy["config"].items() if v is not None}
        if "bindCredential" in compcopy["config"]:
            compcopy["config"]["bindCredential"] = "**********"
    if "mappers" in compcopy:
        for mapper in compcopy["mappers"]:
            if "config" in mapper:
                mapper["config"] = {k: sanitize_value(v) for k, v in mapper["config"].items()}
                mapper["config"] = {k: v for k, v in mapper["config"].items() if v is not None}
    return compcopy


def main():
    """
    Module execution

    :return:
    """
    argument_spec = keycloak_argument_spec()

    config_spec = dict(
        allowKerberosAuthentication=dict(type="bool", default=False),
        allowPasswordAuthentication=dict(type="bool"),
        authType=dict(type="str", choices=["none", "simple"], default="none"),
        batchSizeForSync=dict(type="int", default=1000),
        bindCredential=dict(type="str", no_log=True),
        bindDn=dict(type="str"),
        cachePolicy=dict(
            type="str",
            choices=["DEFAULT", "EVICT_DAILY", "EVICT_WEEKLY", "MAX_LIFESPAN", "NO_CACHE"],
            default="DEFAULT",
        ),
        changedSyncPeriod=dict(type="int", default=-1),
        connectionPooling=dict(type="bool", default=True),
        connectionPoolingAuthentication=dict(type="str", choices=["none", "simple", "DIGEST-MD5"]),
        connectionPoolingDebug=dict(type="str"),
        connectionPoolingInitSize=dict(type="int"),
        connectionPoolingMaxSize=dict(type="int"),
        connectionPoolingPrefSize=dict(type="int"),
        connectionPoolingProtocol=dict(type="str"),
        connectionPoolingTimeout=dict(type="int"),
        connectionTimeout=dict(type="int"),
        connectionUrl=dict(type="str"),
        customUserSearchFilter=dict(type="str"),
        debug=dict(type="bool"),
        editMode=dict(type="str", choices=["READ_ONLY", "WRITABLE", "UNSYNCED"]),
        enabled=dict(type="bool", default=True),
        evictionDay=dict(type="str"),
        evictionHour=dict(type="str"),
        evictionMinute=dict(type="str"),
        fullSyncPeriod=dict(type="int", default=-1),
        importEnabled=dict(type="bool", default=True),
        kerberosRealm=dict(type="str"),
        keyTab=dict(type="str", no_log=False),
        maxLifespan=dict(type="int"),
        pagination=dict(type="bool", default=True),
        priority=dict(type="int", default=0),
        rdnLDAPAttribute=dict(type="str"),
        readTimeout=dict(type="int"),
        referral=dict(type="str", choices=["ignore", "follow"]),
        searchScope=dict(type="str", choices=["1", "2"], default="1"),
        serverPrincipal=dict(type="str"),
        krbPrincipalAttribute=dict(type="str"),
        startTls=dict(type="bool", default=False),
        syncRegistrations=dict(type="bool", default=False),
        trustEmail=dict(type="bool", default=False),
        updateProfileFirstLogin=dict(type="bool"),
        useKerberosForPasswordAuthentication=dict(type="bool", default=False),
        usePasswordModifyExtendedOp=dict(type="bool", default=False, no_log=False),
        useTruststoreSpi=dict(type="str", choices=["always", "ldapsOnly", "never"], default="ldapsOnly"),
        userObjectClasses=dict(type="str"),
        usernameLDAPAttribute=dict(type="str"),
        usersDn=dict(type="str"),
        uuidLDAPAttribute=dict(type="str"),
        validatePasswordPolicy=dict(type="bool", default=False),
        vendor=dict(type="str"),
    )

    mapper_spec = dict(
        id=dict(type="str"),
        name=dict(type="str"),
        parentId=dict(type="str"),
        providerId=dict(type="str"),
        providerType=dict(type="str", default="org.keycloak.storage.ldap.mappers.LDAPStorageMapper"),
        config=dict(type="dict"),
    )

    meta_args = dict(
        config=dict(type="dict", options=config_spec),
        state=dict(type="str", default="present", choices=["present", "absent"]),
        realm=dict(type="str", default="master"),
        id=dict(type="str"),
        name=dict(type="str"),
        provider_id=dict(type="str", aliases=["providerId"]),
        provider_type=dict(type="str", aliases=["providerType"], default="org.keycloak.storage.UserStorageProvider"),
        parent_id=dict(type="str", aliases=["parentId"]),
        remove_unspecified_mappers=dict(type="bool", default=True),
        bind_credential_update_mode=dict(type="str", default="always", choices=["always", "only_indirect"]),
        mappers=dict(type="list", elements="dict", options=mapper_spec),
    )

    argument_spec.update(meta_args)

    module = AnsibleModule(
        argument_spec=argument_spec,
        supports_check_mode=True,
        required_one_of=(
            [
                ["id", "name"],
                ["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"],
            ]
        ),
        required_together=([["auth_username", "auth_password"]]),
        required_by={"refresh_token": "auth_realm"},
    )

    result = dict(changed=False, msg="", diff={}, proposed={}, existing={}, end_state={})

    # Obtain access token, initialize API
    try:
        connection_header = get_token(module.params)
    except KeycloakError as e:
        module.fail_json(msg=str(e))

    kc = KeycloakAPI(module, connection_header)

    realm = module.params.get("realm")
    state = module.params.get("state")
    config = module.params.get("config")
    mappers = module.params.get("mappers")
    cid = module.params.get("id")
    name = module.params.get("name")

    # Keycloak API expects config parameters to be arrays containing a single string element
    if config is not None:
        module.params["config"] = {
            k: [str(v).lower() if not isinstance(v, str) else v] for k, v in config.items() if config[k] is not None
        }

    if mappers is not None:
        for mapper in mappers:
            if mapper.get("config") is not None:
                new_config = {}
                for k, v in mapper["config"].items():
                    if v is None:
                        continue
                    if isinstance(v, list):
                        new_config[k] = [str(item).lower() if not isinstance(item, str) else item for item in v]
                    else:
                        new_config[k] = [str(v).lower() if not isinstance(v, str) else v]
                mapper["config"] = new_config

    # Filter and map the parameters names that apply
    comp_params = [
        x
        for x in module.params
        if x
        not in list(keycloak_argument_spec().keys())
        + ["state", "realm", "mappers", "remove_unspecified_mappers", "bind_credential_update_mode"]
        and module.params.get(x) is not None
    ]

    # See if it already exists in Keycloak
    if cid is None:
        found = kc.get_components(urlencode(dict(type="org.keycloak.storage.UserStorageProvider", name=name)), realm)
        if len(found) > 1:
            module.fail_json(
                msg=f"No ID given and found multiple user federations with name `{name}`. Cannot continue."
            )
        before_comp = next(iter(found), None)
        if before_comp is not None:
            cid = before_comp["id"]
    else:
        before_comp = kc.get_component(cid, realm)

    if before_comp is None:
        before_comp = {}

    # if user federation exists, get associated mappers
    if cid is not None and before_comp:
        before_comp["mappers"] = sorted(
            kc.get_components(urlencode(dict(parent=cid)), realm), key=lambda x: x.get("name") or ""
        )

    normalize_kc_comp(before_comp)

    # Build a proposed changeset from parameters given to this module
    changeset = {}

    for param in comp_params:
        new_param_value = module.params.get(param)
        old_value = before_comp[camel(param)] if camel(param) in before_comp else None
        if param == "mappers":
            new_param_value = [{k: v for k, v in x.items() if v is not None} for x in new_param_value]
        if new_param_value != old_value:
            changeset[camel(param)] = new_param_value

    # special handling of mappers list to allow change detection
    if module.params.get("mappers") is not None:
        if module.params["provider_id"] in ["kerberos", "sssd"]:
            module.fail_json(msg=f"Cannot configure mappers for {module.params['provider_id']} provider.")
        for change in module.params["mappers"]:
            change = {k: v for k, v in change.items() if v is not None}
            if change.get("id") is None and change.get("name") is None:
                module.fail_json(msg="Either `name` or `id` has to be specified on each mapper.")
            if cid is None:
                old_mapper = {}
            elif change.get("id") is not None:
                old_mapper = next(
                    (
                        before_mapper
                        for before_mapper in before_comp.get("mappers", [])
                        if before_mapper["id"] == change["id"]
                    ),
                    None,
                )
                if old_mapper is None:
                    old_mapper = {}
            else:
                found = [
                    before_mapper
                    for before_mapper in before_comp.get("mappers", [])
                    if before_mapper["name"] == change["name"]
                ]
                if len(found) > 1:
                    module.fail_json(msg=f"Found multiple mappers with name `{change['name']}`. Cannot continue.")
                if len(found) == 1:
                    old_mapper = found[0]
                else:
                    old_mapper = {}
            new_mapper = old_mapper.copy()
            new_mapper.update(change)
            # changeset contains all desired mappers: those existing, to update or to create
            if changeset.get("mappers") is None:
                changeset["mappers"] = list()
            changeset["mappers"].append(new_mapper)
        changeset["mappers"] = sorted(changeset["mappers"], key=lambda x: x.get("name") or "")

        # to keep unspecified existing mappers we add them to the desired mappers list, unless they're already present
        if not module.params["remove_unspecified_mappers"] and "mappers" in before_comp:
            changeset_mapper_ids = [mapper["id"] for mapper in changeset["mappers"] if "id" in mapper]
            changeset["mappers"].extend(
                [mapper for mapper in before_comp["mappers"] if mapper["id"] not in changeset_mapper_ids]
            )

    # Prepare the desired values using the existing values (non-existence results in a dict that is save to use as a basis)
    desired_comp = before_comp.copy()
    desired_comp.update(changeset)

    result["proposed"] = sanitize(changeset)
    result["existing"] = sanitize(before_comp)

    # Cater for when it doesn't exist (an empty dict)
    if not before_comp:
        if state == "absent":
            # Do nothing and exit
            if module._diff:
                result["diff"] = dict(before="", after="")
            result["changed"] = False
            result["end_state"] = {}
            result["msg"] = "User federation does not exist; doing nothing."
            module.exit_json(**result)

        # Process a creation
        result["changed"] = True

        if module.check_mode:
            if module._diff:
                result["diff"] = dict(before="", after=sanitize(desired_comp))
            module.exit_json(**result)

        # create it
        desired_mappers = desired_comp.pop("mappers", [])
        after_comp = kc.create_component(desired_comp, realm)
        cid = after_comp["id"]
        updated_mappers = []
        # when creating a user federation, keycloak automatically creates default mappers
        default_mappers = kc.get_components(urlencode(dict(parent=cid)), realm)

        # create new mappers or update existing default mappers
        for desired_mapper in desired_mappers:
            found = [
                default_mapper for default_mapper in default_mappers if default_mapper["name"] == desired_mapper["name"]
            ]
            if len(found) > 1:
                module.fail_json(msg=f"Found multiple mappers with name `{desired_mapper['name']}`. Cannot continue.")
            if len(found) == 1:
                old_mapper = found[0]
            else:
                old_mapper = {}

            new_mapper = old_mapper.copy()
            new_mapper.update(desired_mapper)

            if new_mapper.get("id") is not None:
                kc.update_component(new_mapper, realm)
                updated_mappers.append(new_mapper)
            else:
                if new_mapper.get("parentId") is None:
                    new_mapper["parentId"] = cid
                updated_mappers.append(kc.create_component(new_mapper, realm))

        if module.params["remove_unspecified_mappers"]:
            # we remove all unwanted default mappers
            # we use ids so we dont accidently remove one of the previously updated default mapper
            for default_mapper in default_mappers:
                if default_mapper["id"] not in [x["id"] for x in updated_mappers]:
                    kc.delete_component(default_mapper["id"], realm)

        after_comp["mappers"] = kc.get_components(urlencode(dict(parent=cid)), realm)
        normalize_kc_comp(after_comp)
        if module._diff:
            result["diff"] = dict(before="", after=sanitize(after_comp))
        result["end_state"] = sanitize(after_comp)
        result["msg"] = f"User federation {cid} has been created"
        module.exit_json(**result)

    else:
        if state == "present":
            # Process an update

            desired_copy = deepcopy(desired_comp)
            before_copy = deepcopy(before_comp)
            # exclude bindCredential when checking wether an update is required, therefore
            # updating it only if there are other changes
            if module.params["bind_credential_update_mode"] == "only_indirect":
                desired_copy.get("config", []).pop("bindCredential", None)
                before_copy.get("config", []).pop("bindCredential", None)
            # no changes
            if desired_copy == before_copy:
                result["changed"] = False
                result["end_state"] = sanitize(desired_comp)
                result["msg"] = f"No changes required to user federation {cid}."
                module.exit_json(**result)

            # doing an update
            result["changed"] = True

            if module._diff:
                result["diff"] = dict(before=sanitize(before_comp), after=sanitize(desired_comp))

            if module.check_mode:
                module.exit_json(**result)

            # do the update
            desired_mappers = desired_comp.pop("mappers", [])
            kc.update_component(desired_comp, realm)

            for before_mapper in before_comp.get("mappers", []):
                # remove unwanted existing mappers that will not be updated
                if before_mapper["id"] not in [x["id"] for x in desired_mappers if "id" in x]:
                    kc.delete_component(before_mapper["id"], realm)

            for mapper in desired_mappers:
                if mapper in before_comp.get("mappers", []):
                    continue
                if mapper.get("id") is not None:
                    kc.update_component(mapper, realm)
                else:
                    if mapper.get("parentId") is None:
                        mapper["parentId"] = desired_comp["id"]
                    kc.create_component(mapper, realm)

            after_comp = kc.get_component(cid, realm)
            after_comp["mappers"] = sorted(
                kc.get_components(urlencode(dict(parent=cid)), realm), key=lambda x: x.get("name") or ""
            )
            normalize_kc_comp(after_comp)
            after_comp_sanitized = sanitize(after_comp)
            before_comp_sanitized = sanitize(before_comp)
            result["end_state"] = after_comp_sanitized
            if module._diff:
                result["diff"] = dict(before=before_comp_sanitized, after=after_comp_sanitized)
            result["changed"] = before_comp_sanitized != after_comp_sanitized
            result["msg"] = f"User federation {cid} has been updated"
            module.exit_json(**result)

        elif state == "absent":
            # Process a deletion
            result["changed"] = True

            if module._diff:
                result["diff"] = dict(before=sanitize(before_comp), after="")

            if module.check_mode:
                module.exit_json(**result)

            # delete it
            kc.delete_component(cid, realm)

            result["end_state"] = {}

            result["msg"] = f"User federation {cid} has been deleted"

    module.exit_json(**result)


if __name__ == "__main__":
    main()
