#!/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_userprofile

short_description: Allows managing Keycloak User Profiles

description:
  - This module allows you to create, update, or delete Keycloak User Profiles using the Keycloak API. You can also customize
    the "Unmanaged Attributes" with it.
  - 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/24.0.5/rest-api/index.html). For compatibility reasons, the module also accepts
    the camelCase versions of the options.
version_added: "9.4.0"

attributes:
  check_mode:
    support: full
  diff_mode:
    support: full
  action_group:
    version_added: 10.2.0

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

  parent_id:
    description:
      - The parent ID of the realm key. In practice the ID (name) of the realm.
    aliases:
      - parentId
      - realm
    type: str
    required: true

  provider_id:
    description:
      - The name of the provider ID for the key (supported value is V(declarative-user-profile)).
    aliases:
      - providerId
    choices: ['declarative-user-profile']
    default: 'declarative-user-profile'
    type: str

  provider_type:
    description:
      - Component type for User Profile (only supported value is V(org.keycloak.userprofile.UserProfileProvider)).
    aliases:
      - providerType
    choices: ['org.keycloak.userprofile.UserProfileProvider']
    default: org.keycloak.userprofile.UserProfileProvider
    type: str

  config:
    description:
      - The configuration of the User Profile Provider.
    type: dict
    suboptions:
      kc_user_profile_config:
        description:
          - Define a declarative User Profile. See EXAMPLES for more context.
        aliases:
          - kcUserProfileConfig
        type: list
        elements: dict
        suboptions:
          attributes:
            description:
              - A list of attributes to be included in the User Profile.
            type: list
            elements: dict
            suboptions:
              name:
                description:
                  - The name of the attribute.
                type: str
                required: true

              display_name:
                description:
                  - The display name of the attribute.
                aliases:
                  - displayName
                type: str
                required: true

              validations:
                description:
                  - The validations to be applied to the attribute.
                type: dict
                suboptions:
                  length:
                    description:
                      - The length validation for the attribute.
                    type: dict
                    suboptions:
                      min:
                        description:
                          - The minimum length of the attribute.
                        type: int
                      max:
                        description:
                          - The maximum length of the attribute.
                        type: int
                        required: true

                  email:
                    description:
                      - The email validation for the attribute.
                    type: dict

                  username_prohibited_characters:
                    description:
                      - The prohibited characters validation for the username attribute.
                    type: dict
                    aliases:
                      - usernameProhibitedCharacters

                  up_username_not_idn_homograph:
                    description:
                      - The validation to prevent IDN homograph attacks in usernames.
                    type: dict
                    aliases:
                      - upUsernameNotIdnHomograph

                  person_name_prohibited_characters:
                    description:
                      - The prohibited characters validation for person name attributes.
                    type: dict
                    aliases:
                      - personNameProhibitedCharacters

                  uri:
                    description:
                      - The URI validation for the attribute.
                    type: dict

                  pattern:
                    description:
                      - The pattern validation for the attribute using regular expressions.
                    type: dict

                  options:
                    description:
                      - Validation to ensure the attribute matches one of the provided options.
                    type: dict

                  integer:
                    description:
                      - The integer validation for the attribute.
                    type: dict
                    version_added: 12.2.0

                  double:
                    description:
                      - The double validation for the attribute.
                    type: dict
                    version_added: 12.2.0

                  iso_date:
                    description:
                      - The iso-date validation for the attribute.
                    type: dict
                    aliases:
                      - isoDate
                    version_added: 12.2.0

                  local_date:
                    description:
                      - The local-date validation for the attribute.
                    type: dict
                    aliases:
                      - localDate
                    version_added: 12.2.0

                  multivalued:
                    description:
                      - The multivalued validation for the attribute.
                    type: dict
                    suboptions:
                      min:
                        description:
                          - The minimum amount of values of the attribute.
                        type: int
                      max:
                        description:
                          - The maximum amount of values of the attribute.
                        type: int
                        required: true
                    version_added: 12.2.0

              annotations:
                description:
                  - Annotations for the attribute.
                type: dict

              group:
                description:
                  - Specifies the User Profile group where this attribute is added.
                type: str

              permissions:
                description:
                  - The permissions for viewing and editing the attribute.
                type: dict
                suboptions:
                  view:
                    description:
                      - The roles that can view the attribute.
                      - Supported values are V(admin) and V(user).
                    type: list
                    elements: str
                    default:
                      - admin
                      - user

                  edit:
                    description:
                      - The roles that can edit the attribute.
                      - Supported values are V(admin) and V(user).
                    type: list
                    elements: str
                    default:
                      - admin
                      - user

              multivalued:
                description:
                  - Whether the attribute can have multiple values.
                type: bool
                default: false

              required:
                description:
                  - The roles that require this attribute.
                type: dict
                suboptions:
                  roles:
                    description:
                      - The roles for which this attribute is required.
                      - Supported values are V(admin) and V(user).
                    type: list
                    elements: str
                    default:
                      - user

              selector:
                description:
                  - Selector when the attribute should be added.
                type: dict
                version_added: 12.2.0
                suboptions:
                  scopes:
                    description:
                      - Scopes to which the attribute should be added.
                    type: list
                    elements: str

          groups:
            description:
              - A list of attribute groups to be included in the User Profile.
            type: list
            elements: dict
            suboptions:
              name:
                description:
                  - The name of the group.
                type: str
                required: true

              display_header:
                description:
                  - The display header for the group.
                aliases:
                  - displayHeader
                type: str
                required: true

              display_description:
                description:
                  - The display description for the group.
                aliases:
                  - displayDescription
                type: str

              annotations:
                description:
                  - The annotations included in the group.
                type: dict

          unmanaged_attribute_policy:
            description:
              - Policy for unmanaged attributes.
            aliases:
              - unmanagedAttributePolicy
            type: str
            choices:
              - ENABLED
              - ADMIN_EDIT
              - ADMIN_VIEW

notes:
  - Currently, only a single V(declarative-user-profile) entry is supported for O(provider_id) (design of the Keyckoak API).
    However, there can be multiple O(config.kc_user_profile_config[].attributes[]) entries.
extends_documentation_fragment:
  - community.general._keycloak
  - community.general._keycloak.actiongroup_keycloak
  - community.general._attributes

author:
  - Eike Waldt (@yeoldegrove)
"""

EXAMPLES = r"""
- name: Create a Declarative User Profile with default settings
  community.general.keycloak_userprofile:
    state: present
    parent_id: master
    config:
      kc_user_profile_config:
        - attributes:
            - name: username
              displayName: ${username}
              validations:
                length:
                  min: 3
                  max: 255
                username_prohibited_characters: {}
                up_username_not_idn_homograph: {}
              annotations: {}
              permissions:
                view:
                  - admin
                  - user
                edit: []
              multivalued: false
            - name: email
              displayName: ${email}
              validations:
                email: {}
                length:
                  max: 255
              annotations: {}
              required:
                roles:
                  - user
              permissions:
                view:
                  - admin
                  - user
                edit: []
              multivalued: false
            - name: firstName
              displayName: ${firstName}
              validations:
                length:
                  max: 255
                person_name_prohibited_characters: {}
              annotations: {}
              required:
                roles:
                  - user
              permissions:
                view:
                  - admin
                  - user
                edit: []
              multivalued: false
            - name: lastName
              displayName: ${lastName}
              validations:
                length:
                  max: 255
                person_name_prohibited_characters: {}
              annotations: {}
              required:
                roles:
                  - user
              permissions:
                view:
                  - admin
                  - user
                edit: []
              multivalued: false
            - name: testAttribute
              displayName: ${testAttribute}
              validations:
                integer:
                  min: 0
                  max: 255
              annotations: {}
              required:
                roles:
                  - user
              permissions:
                view:
                  - admin
                  - user
                edit: []
              multivalued: false
          groups:
            - name: user-metadata
              displayHeader: User metadata
              displayDescription: Attributes, which refer to user metadata
              annotations: {}

- name: Delete a Keycloak User Profile Provider
  keycloak_userprofile:
    state: absent
    parent_id: master

# Unmanaged attributes are user attributes not explicitly defined in the User Profile
# configuration. By default, unmanaged attributes are "Disabled" and are not
# available from any context such as registration, account, and the
# administration console. By setting "Enabled", unmanaged attributes are fully
# recognized by the server and accessible through all contexts, useful if you are
# starting migrating an existing realm to the declarative User Profile
# and you don't have yet all user attributes defined in the User Profile configuration.
- name: Enable Unmanaged Attributes
  community.general.keycloak_userprofile:
    state: present
    parent_id: master
    config:
      kc_user_profile_config:
        - unmanagedAttributePolicy: ENABLED

# By setting "Only administrators can write", unmanaged attributes can be managed
# only through the administration console and API, useful if you have already
# defined any custom attribute that can be managed by users but you are unsure
# about adding other attributes that should only be managed by administrators.
- name: Enable ADMIN_EDIT on Unmanaged Attributes
  community.general.keycloak_userprofile:
    state: present
    parent_id: master
    config:
      kc_user_profile_config:
        - unmanagedAttributePolicy: ADMIN_EDIT

# By setting `Only administrators can view`, unmanaged attributes are read-only
# and only available through the administration console and API.
- name: Enable ADMIN_VIEW on Unmanaged Attributes
  community.general.keycloak_userprofile:
    state: present
    parent_id: master
    config:
      kc_user_profile_config:
        - unmanagedAttributePolicy: ADMIN_VIEW
"""

RETURN = r"""
msg:
  description: The output message generated by the module.
  returned: always
  type: str
  sample: UserProfileProvider created successfully
data:
  description: The data returned by the Keycloak API.
  returned: when state is present
  type: dict
"""

import json
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 remove_null_values(data):
    if isinstance(data, dict):
        # Recursively remove null values from dictionaries
        return {k: remove_null_values(v) for k, v in data.items() if v is not None}
    elif isinstance(data, list):
        # Recursively remove null values from lists
        return [remove_null_values(item) for item in data if item is not None]
    else:
        # Return the data if it is neither a dictionary nor a list
        return data


def camel_recursive(data):
    if isinstance(data, dict):
        # Convert keys to camelCase and apply recursively
        return {camel(k): camel_recursive(v) for k, v in data.items()}
    elif isinstance(data, list):
        # Apply camelCase conversion to each item in the list
        return [camel_recursive(item) for item in data]
    else:
        # Return the data as-is if it is not a dict or list
        return data


def main():
    argument_spec = keycloak_argument_spec()

    meta_args = dict(
        state=dict(type="str", choices=["present", "absent"], default="present"),
        parent_id=dict(type="str", aliases=["parentId", "realm"], required=True),
        provider_id=dict(
            type="str", aliases=["providerId"], default="declarative-user-profile", choices=["declarative-user-profile"]
        ),
        provider_type=dict(
            type="str",
            aliases=["providerType"],
            default="org.keycloak.userprofile.UserProfileProvider",
            choices=["org.keycloak.userprofile.UserProfileProvider"],
        ),
        config=dict(
            type="dict",
            options={
                "kc_user_profile_config": dict(
                    type="list",
                    aliases=["kcUserProfileConfig"],
                    elements="dict",
                    options={
                        "attributes": dict(
                            type="list",
                            elements="dict",
                            options={
                                "name": dict(type="str", required=True),
                                "display_name": dict(type="str", aliases=["displayName"], required=True),
                                "validations": dict(
                                    type="dict",
                                    options={
                                        "length": dict(
                                            type="dict",
                                            options={"min": dict(type="int"), "max": dict(type="int", required=True)},
                                        ),
                                        "email": dict(type="dict"),
                                        "username_prohibited_characters": dict(
                                            type="dict", aliases=["usernameProhibitedCharacters"]
                                        ),
                                        "up_username_not_idn_homograph": dict(
                                            type="dict", aliases=["upUsernameNotIdnHomograph"]
                                        ),
                                        "person_name_prohibited_characters": dict(
                                            type="dict", aliases=["personNameProhibitedCharacters"]
                                        ),
                                        "uri": dict(type="dict"),
                                        "pattern": dict(type="dict"),
                                        "options": dict(type="dict"),
                                        "integer": dict(type="dict"),
                                        "double": dict(type="dict"),
                                        "iso_date": dict(type="dict", aliases=["isoDate"]),
                                        "local_date": dict(type="dict", aliases=["localDate"]),
                                        "multivalued": dict(
                                            type="dict",
                                            options={
                                                "min": dict(type="int", required=False),
                                                "max": dict(type="int", required=True),
                                            },
                                        ),
                                    },
                                ),
                                "annotations": dict(type="dict"),
                                "group": dict(type="str"),
                                "permissions": dict(
                                    type="dict",
                                    options={
                                        "view": dict(type="list", elements="str", default=["admin", "user"]),
                                        "edit": dict(type="list", elements="str", default=["admin", "user"]),
                                    },
                                ),
                                "multivalued": dict(type="bool", default=False),
                                "required": dict(
                                    type="dict", options={"roles": dict(type="list", elements="str", default=["user"])}
                                ),
                                "selector": dict(type="dict", options={"scopes": dict(type="list", elements="str")}),
                            },
                        ),
                        "groups": dict(
                            type="list",
                            elements="dict",
                            options={
                                "name": dict(type="str", required=True),
                                "display_header": dict(type="str", aliases=["displayHeader"], required=True),
                                "display_description": dict(type="str", aliases=["displayDescription"]),
                                "annotations": dict(type="dict"),
                            },
                        ),
                        "unmanaged_attribute_policy": dict(
                            type="str",
                            aliases=["unmanagedAttributePolicy"],
                            choices=["ENABLED", "ADMIN_EDIT", "ADMIN_VIEW"],
                        ),
                    },
                )
            },
        ),
    )

    argument_spec.update(meta_args)

    module = AnsibleModule(
        argument_spec=argument_spec,
        supports_check_mode=True,
        required_one_of=(
            [["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"},
    )

    # Initialize the result object. Only "changed" seems to have special
    # meaning for Ansible.
    result = dict(changed=False, msg="", end_state={}, diff=dict(before={}, after={}))

    # This will include the current state of the realm userprofile if it is already
    # present. This is only used for diff-mode.
    before_realm_userprofile = {}
    before_realm_userprofile["config"] = {}

    # 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)

    params_to_ignore = list(keycloak_argument_spec().keys()) + ["state"]

    # Filter and map the parameters names that apply to the role
    component_params = [x for x in module.params if x not in params_to_ignore and module.params.get(x) is not None]

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

    # Build the changeset with proper JSON serialization for kc_user_profile_config
    config = module.params.get("config")
    changeset["config"] = {}

    # Generate a JSON payload for Keycloak Admin API from the module
    # parameters.  Parameters that do not belong to the JSON payload (e.g.
    # "state" or "auth_keycloal_url") have been filtered away earlier (see
    # above).
    #
    # This loop converts Ansible module parameters (snake-case) into
    # Keycloak-compatible format (camel-case). For example proider_id
    # becomes providerId. It also handles some special cases, e.g. aliases.
    for component_param in component_params:
        # realm/parent_id parameter
        if component_param == "realm" or component_param == "parent_id":
            changeset["parent_id"] = module.params.get(component_param)
            changeset.pop(component_param, None)
        # complex parameters in config suboptions
        elif component_param == "config":
            for config_param in config:
                # special parameter kc_user_profile_config
                if config_param in ("kcUserProfileConfig", "kc_user_profile_config"):
                    config_param_org = config_param
                    # rename parameter to be accepted by Keycloak API
                    config_param = "kc.user.profile.config"
                    # make sure no null values are passed to Keycloak API
                    kc_user_profile_config = remove_null_values(config[config_param_org])
                    changeset[camel(component_param)][config_param] = []
                    if len(kc_user_profile_config) > 0:
                        # convert aliases to camelCase
                        kc_user_profile_config = camel_recursive(kc_user_profile_config)
                        # rename validations to be accepted by Keycloak API
                        if "attributes" in kc_user_profile_config[0]:
                            for attribute in kc_user_profile_config[0]["attributes"]:
                                if "validations" in attribute:
                                    if "usernameProhibitedCharacters" in attribute["validations"]:
                                        attribute["validations"]["username-prohibited-characters"] = attribute[
                                            "validations"
                                        ].pop("usernameProhibitedCharacters")
                                    if "upUsernameNotIdnHomograph" in attribute["validations"]:
                                        attribute["validations"]["up-username-not-idn-homograph"] = attribute[
                                            "validations"
                                        ].pop("upUsernameNotIdnHomograph")
                                    if "personNameProhibitedCharacters" in attribute["validations"]:
                                        attribute["validations"]["person-name-prohibited-characters"] = attribute[
                                            "validations"
                                        ].pop("personNameProhibitedCharacters")
                                    if "isoDate" in attribute["validations"]:
                                        attribute["validations"]["iso-date"] = attribute["validations"].pop("isoDate")
                                    if "localDate" in attribute["validations"]:
                                        attribute["validations"]["local-date"] = attribute["validations"].pop(
                                            "localDate"
                                        )
                        changeset[camel(component_param)][config_param].append(kc_user_profile_config[0])
                # usual camelCase parameters
                else:
                    changeset[camel(component_param)][camel(config_param)] = []
                    raw_value = module.params.get(component_param)[config_param]
                    if isinstance(raw_value, bool):
                        value = str(raw_value).lower()
                    else:
                        value = raw_value  # Directly use the raw value
                    changeset[camel(component_param)][camel(config_param)].append(value)
        # usual parameters
        else:
            new_param_value = module.params.get(component_param)
            changeset[camel(component_param)] = new_param_value

    # Make it easier to refer to current module parameters
    state = module.params.get("state")
    parent_id = module.params.get("parent_id")
    provider_type = module.params.get("provider_type")
    provider_id = module.params.get("provider_id")

    # Make a deep copy of the changeset. This is use when determining
    # changes to the current state.
    changeset_copy = deepcopy(changeset)

    # Get a list of all Keycloak components that are of userprofile provider type.
    realm_userprofiles = kc.get_components(urlencode(dict(type=provider_type)), parent_id)

    # If this component is present get its userprofile ID. Confusingly the userprofile ID is
    # also known as the Provider ID.
    userprofile_id = None

    # Track individual parameter changes
    changes = ""

    # This tells Ansible whether the userprofile was changed (added, removed, modified)
    result["changed"] = False

    # Loop through the list of components. If we encounter a component whose
    # name matches the value of the name parameter then assume the userprofile is
    # already present.
    for userprofile in realm_userprofiles:
        if provider_id == "declarative-user-profile":
            userprofile_id = userprofile["id"]
            changeset["id"] = userprofile_id
            changeset_copy["id"] = userprofile_id

            # keycloak returns kc.user.profile.config as a single JSON formatted string, so we have to deserialize it
            if "config" in userprofile and "kc.user.profile.config" in userprofile["config"]:
                userprofile["config"]["kc.user.profile.config"][0] = json.loads(
                    userprofile["config"]["kc.user.profile.config"][0]
                )

            # Compare top-level parameters
            for param in changeset:
                before_realm_userprofile[param] = userprofile[param]

                if changeset_copy[param] != userprofile[param] and param != "config":
                    changes += f"{param}: {userprofile[param]} -> {changeset_copy[param]}, "
                    result["changed"] = True

            # Compare parameters under the "config" userprofile
            for p, v in changeset_copy["config"].items():
                before_realm_userprofile["config"][p] = userprofile["config"][p]
                if v != userprofile["config"][p]:
                    changes += f"config.{p}: {userprofile['config'][p]} -> {v}, "
                    result["changed"] = True

    # Check all the possible states of the resource and do what is needed to
    # converge current state with desired state (create, update or delete
    # the userprofile).

    # keycloak expects kc.user.profile.config as a single JSON formatted string, so we have to serialize it
    if "config" in changeset and "kc.user.profile.config" in changeset["config"]:
        changeset["config"]["kc.user.profile.config"][0] = json.dumps(changeset["config"]["kc.user.profile.config"][0])
    if userprofile_id and state == "present":
        if result["changed"]:
            if module._diff:
                result["diff"] = dict(before=before_realm_userprofile, after=changeset_copy)

            if module.check_mode:
                result["msg"] = f"Userprofile {provider_id} would be changed: {changes.strip(', ')}"
            else:
                kc.update_component(changeset, parent_id)
                result["msg"] = f"Userprofile {provider_id} changed: {changes.strip(', ')}"
        else:
            result["msg"] = f"Userprofile {provider_id} was in sync"

        result["end_state"] = changeset_copy
    elif userprofile_id and state == "absent":
        if module._diff:
            result["diff"] = dict(before=before_realm_userprofile, after={})

        if module.check_mode:
            result["changed"] = True
            result["msg"] = f"Userprofile {provider_id} would be deleted"
        else:
            kc.delete_component(userprofile_id, parent_id)
            result["changed"] = True
            result["msg"] = f"Userprofile {provider_id} deleted"

        result["end_state"] = {}
    elif not userprofile_id and state == "present":
        if module._diff:
            result["diff"] = dict(before={}, after=changeset_copy)

        if module.check_mode:
            result["changed"] = True
            result["msg"] = f"Userprofile {provider_id} would be created"
        else:
            kc.create_component(changeset, parent_id)
            result["changed"] = True
            result["msg"] = f"Userprofile {provider_id} created"

        result["end_state"] = changeset_copy
    elif not userprofile_id and state == "absent":
        result["changed"] = False
        result["msg"] = f"Userprofile {provider_id} not present"
        result["end_state"] = {}

    module.exit_json(**result)


if __name__ == "__main__":
    main()
