#!/usr/bin/python
# -*- coding: utf-8 -*-

# Copyright: Ansible Project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)

DOCUMENTATION = r"""
---
module: opensearch
short_description: Creates OpenSearch or ElasticSearch domain
description:
  - Creates or modify a Amazon OpenSearch Service domain.
version_added: 4.0.0
author:
  - "Sebastien Rosset (@sebastien-rosset)"
options:
  state:
    description:
      - Creates or modifies an existing OpenSearch domain.
      - Deletes an OpenSearch domain.
    required: false
    type: str
    choices: ['present', 'absent']
    default: present
  domain_name:
    description:
      - The name of the Amazon OpenSearch/ElasticSearch Service domain.
      - Domain names are unique across the domains owned by an account within an AWS region.
    required: true
    type: str
  engine_version:
    description:
      ->
        The engine version to use. For example, 'ElasticSearch_7.10' or 'OpenSearch_1.1'.
      ->
        If the currently running version is not equal to I(engine_version),
        a cluster upgrade is triggered.
      ->
        It may not be possible to upgrade directly from the currently running version
        to I(engine_version). In that case, the upgrade is performed incrementally by
        upgrading to the highest compatible version, then repeat the operation until
        the cluster is running at the target version.
      ->
        The upgrade operation fails if there is no path from current version to I(engine_version).
      ->
        See OpenSearch documentation for upgrade compatibility.
    required: false
    type: str
  allow_intermediate_upgrades:
    description:
      - >
        If true, allow OpenSearch domain to be upgraded through one or more intermediate versions.
      - >
        If false, do not allow OpenSearch domain to be upgraded through intermediate versions.
        The upgrade operation fails if it's not possible to ugrade to I(engine_version) directly.
    required: false
    type: bool
    default: true
  cluster_config:
    description:
      - Parameters for the cluster configuration of an OpenSearch Service domain.
    type: dict
    suboptions:
      instance_type:
        description:
          - Type of the instances to use for the domain.
        required: false
        type: str
      instance_count:
        description:
          - Number of instances for the domain.
        required: false
        type: int
      zone_awareness:
        description:
          - A boolean value to indicate whether zone awareness is enabled.
        required: false
        type: bool
      availability_zone_count:
        description:
          - >
            An integer value to indicate the number of availability zones for a domain when zone awareness is enabled.
            This should be equal to number of subnets if VPC endpoints is enabled.
        required: false
        type: int
      dedicated_master:
        description:
          - A boolean value to indicate whether a dedicated master node is enabled.
        required: false
        type: bool
      dedicated_master_instance_type:
        description:
          - The instance type for a dedicated master node.
        required: false
        type: str
      dedicated_master_instance_count:
        description:
          - Total number of dedicated master nodes, active and on standby, for the domain.
        required: false
        type: int
      warm_enabled:
        description:
          - True to enable UltraWarm storage.
        required: false
        type: bool
      warm_type:
        description:
          - The instance type for the OpenSearch domain's warm nodes.
        required: false
        type: str
      warm_count:
        description:
          - The number of UltraWarm nodes in the domain.
        required: false
        type: int
      cold_storage_options:
        description:
          - Specifies the ColdStorageOptions config for a Domain.
        type: dict
        suboptions:
          enabled:
            description:
              - True to enable cold storage. Supported on Elasticsearch 7.9 or above.
            required: false
            type: bool
  ebs_options:
    description:
      - Parameters to configure EBS-based storage for an OpenSearch Service domain.
    type: dict
    suboptions:
      ebs_enabled:
        description:
          - Specifies whether EBS-based storage is enabled.
        required: false
        type: bool
      volume_type:
        description:
          - Specifies the volume type for EBS-based storage. "standard"|"gp2"|"io1"
        required: false
        type: str
      volume_size:
        description:
          - Integer to specify the size of an EBS volume.
        required: false
        type: int
      iops:
        description:
          - The IOPD for a Provisioned IOPS EBS volume (SSD).
        required: false
        type: int
  vpc_options:
    description:
      - Options to specify the subnets and security groups for a VPC endpoint.
    type: dict
    suboptions:
      subnets:
        description:
          - Specifies the subnet ids for VPC endpoint.
        required: false
        type: list
        elements: str
      security_groups:
        description:
          - Specifies the security group ids for VPC endpoint.
        required: false
        type: list
        elements: str
  snapshot_options:
    description:
      - Option to set time, in UTC format, of the daily automated snapshot.
    type: dict
    suboptions:
      automated_snapshot_start_hour:
        description:
          - >
            Integer value from 0 to 23 specifying when the service takes a daily automated snapshot
            of the specified Elasticsearch domain.
        required: false
        type: int
  access_policies:
    description:
      - IAM access policy as a JSON-formatted string.
    required: false
    type: dict
  encryption_at_rest_options:
    description:
      - Parameters to enable encryption at rest.
    type: dict
    suboptions:
      enabled:
        description:
          - Should data be encrypted while at rest.
        required: false
        type: bool
      kms_key_id:
        description:
          - If encryption at rest enabled, this identifies the encryption key to use.
          - The value should be a KMS key ARN. It can also be the KMS key id.
        required: false
        type: str
  node_to_node_encryption_options:
    description:
      - Node-to-node encryption options.
    type: dict
    suboptions:
      enabled:
        description:
            - True to enable node-to-node encryption.
        required: false
        type: bool
  cognito_options:
    description:
      - Parameters to configure OpenSearch Service to use Amazon Cognito authentication for OpenSearch Dashboards.
    type: dict
    suboptions:
      enabled:
        description:
          - The option to enable Cognito for OpenSearch Dashboards authentication.
        required: false
        type: bool
      user_pool_id:
        description:
          - The Cognito user pool ID for OpenSearch Dashboards authentication.
        required: false
        type: str
      identity_pool_id:
        description:
          - The Cognito identity pool ID for OpenSearch Dashboards authentication.
        required: false
        type: str
      role_arn:
        description:
          - The role ARN that provides OpenSearch permissions for accessing Cognito resources.
        required: false
        type: str
  domain_endpoint_options:
    description:
      - Options to specify configuration that will be applied to the domain endpoint.
    type: dict
    suboptions:
      enforce_https:
        description:
          - Whether only HTTPS endpoint should be enabled for the domain.
        type: bool
      tls_security_policy:
        description:
          - Specify the TLS security policy to apply to the HTTPS endpoint of the domain.
        type: str
      custom_endpoint_enabled:
        description:
          - Whether to enable a custom endpoint for the domain.
        type: bool
      custom_endpoint:
        description:
          - The fully qualified domain for your custom endpoint.
        type: str
      custom_endpoint_certificate_arn:
        description:
          - The ACM certificate ARN for your custom endpoint.
        type: str
  advanced_security_options:
    description:
      - Specifies advanced security options.
    type: dict
    suboptions:
      enabled:
        description:
          - True if advanced security is enabled.
          - You must enable node-to-node encryption to use advanced security options.
        type: bool
      internal_user_database_enabled:
        description:
          - True if the internal user database is enabled.
        type: bool
      master_user_options:
        description:
          - Credentials for the master user, username and password, ARN, or both.
        type: dict
        suboptions:
          master_user_arn:
            description:
              - ARN for the master user (if IAM is enabled).
            type: str
          master_user_name:
            description:
              - The username of the master user, which is stored in the Amazon OpenSearch Service domain internal database.
            type: str
          master_user_password:
            description:
              - The password of the master user, which is stored in the Amazon OpenSearch Service domain internal database.
            type: str
      saml_options:
        description:
          - The SAML application configuration for the domain.
        type: dict
        suboptions:
          enabled:
            description:
              - True if SAML is enabled.
              - To use SAML authentication, you must enable fine-grained access control.
              - You can only enable SAML authentication for OpenSearch Dashboards on existing domains,
                not during the creation of new ones.
              - Domains only support one Dashboards authentication method at a time.
                If you have Amazon Cognito authentication for OpenSearch Dashboards enabled,
                you must disable it before you can enable SAML.
            type: bool
          idp:
            description:
              - The SAML Identity Provider's information.
            type: dict
            suboptions:
              metadata_content:
                description:
                  - The metadata of the SAML application in XML format.
                type: str
              entity_id:
                description:
                  - The unique entity ID of the application in SAML identity provider.
                type: str
          master_user_name:
            description:
              - The SAML master username, which is stored in the Amazon OpenSearch Service domain internal database.
            type: str
          master_backend_role:
            description:
              - The backend role that the SAML master user is mapped to.
            type: str
          subject_key:
            description:
              - Element of the SAML assertion to use for username. Default is NameID.
            type: str
          roles_key:
            description:
              - Element of the SAML assertion to use for backend roles. Default is roles.
            type: str
          session_timeout_minutes:
            description:
              - The duration, in minutes, after which a user session becomes inactive. Acceptable values are between 1 and 1440, and the default value is 60.
            type: int
  auto_tune_options:
    description:
      - Specifies Auto-Tune options.
    type: dict
    suboptions:
      desired_state:
        description:
          - The Auto-Tune desired state. Valid values are ENABLED and DISABLED.
        type: str
        choices: ['ENABLED', 'DISABLED']
      maintenance_schedules:
        description:
          - A list of maintenance schedules.
        type: list
        elements: dict
        suboptions:
          start_at:
            description:
              - The timestamp at which the Auto-Tune maintenance schedule starts.
            type: str
          duration:
            description:
              - Specifies maintenance schedule duration, duration value and duration unit.
            type: dict
            suboptions:
              value:
                description:
                  - Integer to specify the value of a maintenance schedule duration.
                type: int
              unit:
                description:
                  - The unit of a maintenance schedule duration. Valid value is HOURS.
                choices: ['HOURS']
                type: str
          cron_expression_for_recurrence:
            description:
              - A cron expression for a recurring maintenance schedule.
            type: str
  wait:
    description:
      - Whether or not to wait for completion of OpenSearch creation, modification or deletion.
    type: bool
    default: false
  wait_timeout:
    description:
      - how long before wait gives up, in seconds.
    default: 300
    type: int
extends_documentation_fragment:
  - amazon.aws.common.modules
  - amazon.aws.region.modules
  - amazon.aws.tags
  - amazon.aws.boto3
"""

RETURN = r""" # """

EXAMPLES = r"""

- name: Create OpenSearch domain for dev environment, no zone awareness, no dedicated masters
  community.aws.opensearch:
    domain_name: "dev-cluster"
    engine_version: Elasticsearch_1.1
    cluster_config:
      instance_type: "t2.small.search"
      instance_count: 2
      zone_awareness: false
      dedicated_master: false
    ebs_options:
      ebs_enabled: true
      volume_type: "gp2"
      volume_size: 10
    access_policies: "{{ lookup('file', 'policy.json') | from_json }}"

- name: Create OpenSearch domain with dedicated masters
  community.aws.opensearch:
    domain_name: "my-domain"
    engine_version: OpenSearch_1.1
    cluster_config:
      instance_type: "t2.small.search"
      instance_count: 12
      dedicated_master: true
      zone_awareness: true
      availability_zone_count: 2
      dedicated_master_instance_type: "t2.small.search"
      dedicated_master_instance_count: 3
      warm_enabled: true
      warm_type: "ultrawarm1.medium.search"
      warm_count: 1
      cold_storage_options:
        enabled: false
    ebs_options:
      ebs_enabled: true
      volume_type: "io1"
      volume_size: 10
      iops: 1000
    vpc_options:
      subnets:
        - "subnet-e537d64a"
        - "subnet-e537d64b"
      security_groups:
        - "sg-dd2f13cb"
        - "sg-dd2f13cc"
    snapshot_options:
      automated_snapshot_start_hour: 13
    access_policies: "{{ lookup('file', 'policy.json') | from_json }}"
    encryption_at_rest_options:
      enabled: false
    node_to_node_encryption_options:
      enabled: false
    auto_tune_options:
      enabled: true
      maintenance_schedules:
        - start_at: "2025-01-12"
          duration:
            value: 1
            unit: "HOURS"
          cron_expression_for_recurrence: "cron(0 12 * * ? *)"
        - start_at: "2032-01-12"
          duration:
            value: 2
            unit: "HOURS"
          cron_expression_for_recurrence: "cron(0 12 * * ? *)"
    tags:
      Environment: Development
      Application: Search
    wait: true

- name: Increase size of EBS volumes for existing cluster
  community.aws.opensearch:
    domain_name: "my-domain"
    ebs_options:
      volume_size: 5
    wait: true

- name: Increase instance count for existing cluster
  community.aws.opensearch:
    domain_name: "my-domain"
    cluster_config:
      instance_count: 40
    wait: true
"""

import datetime
import json
from copy import deepcopy

try:
    import botocore
except ImportError:
    pass  # handled by AnsibleAWSModule


from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code
from ansible_collections.amazon.aws.plugins.module_utils.policy import compare_policies
from ansible_collections.amazon.aws.plugins.module_utils.retries import AWSRetry
from ansible_collections.amazon.aws.plugins.module_utils.tagging import boto3_tag_list_to_ansible_dict

from ansible_collections.community.aws.plugins.module_utils.modules import AnsibleCommunityAWSModule as AnsibleAWSModule
from ansible_collections.community.aws.plugins.module_utils.opensearch import compare_domain_versions
from ansible_collections.community.aws.plugins.module_utils.opensearch import ensure_tags
from ansible_collections.community.aws.plugins.module_utils.opensearch import get_domain_config
from ansible_collections.community.aws.plugins.module_utils.opensearch import get_domain_status
from ansible_collections.community.aws.plugins.module_utils.opensearch import get_target_increment_version
from ansible_collections.community.aws.plugins.module_utils.opensearch import normalize_opensearch
from ansible_collections.community.aws.plugins.module_utils.opensearch import parse_version
from ansible_collections.community.aws.plugins.module_utils.opensearch import wait_for_domain_status


def ensure_domain_absent(client, module):
    domain_name = module.params.get("domain_name")
    changed = False

    domain = get_domain_status(client, module, domain_name)
    if module.check_mode:
        module.exit_json(changed=True, msg="Would have deleted domain if not in check mode")
    try:
        client.delete_domain(DomainName=domain_name)
        changed = True
    except is_boto3_error_code("ResourceNotFoundException"):
        # The resource does not exist, or it has already been deleted
        return dict(changed=False)
    except (
        botocore.exceptions.ClientError,
        botocore.exceptions.BotoCoreError,
    ) as e:  # pylint: disable=duplicate-except
        module.fail_json_aws(e, msg="trying to delete domain")

    # If we're not waiting for a delete to complete then we're all done
    # so just return
    if not domain or not module.params.get("wait"):
        return dict(changed=changed)
    try:
        wait_for_domain_status(client, module, domain_name, "domain_deleted")
        return dict(changed=changed)
    except is_boto3_error_code("ResourceNotFoundException"):
        return dict(changed=changed)
    except (
        botocore.exceptions.ClientError,
        botocore.exceptions.BotoCoreError,
    ) as e:  # pylint: disable=duplicate-except
        module.fail_json_aws(e, "awaiting domain deletion")


def upgrade_domain(client, module, source_version, target_engine_version):
    domain_name = module.params.get("domain_name")
    # Determine if it's possible to upgrade directly from source version
    # to target version, or if it's necessary to upgrade through intermediate major versions.
    next_version = target_engine_version
    # When perform_check_only is true, indicates that an upgrade eligibility check needs
    # to be performed. Does not actually perform the upgrade.
    perform_check_only = False
    if module.check_mode:
        perform_check_only = True
    current_version = source_version
    while current_version != target_engine_version:
        v = get_target_increment_version(client, module, domain_name, target_engine_version)
        if v is None:
            # There is no compatible version, according to the get_compatible_versions() API.
            # The upgrade should fail, but try anyway.
            next_version = target_engine_version
        if next_version != target_engine_version:
            # It's not possible to upgrade directly to the target version.
            # Check the module parameters to determine if this is allowed or not.
            if not module.params.get("allow_intermediate_upgrades"):
                module.fail_json(
                    msg=f"Cannot upgrade from {source_version} to version {target_engine_version}. The highest compatible version is {next_version}"
                )

        parameters = {
            "DomainName": domain_name,
            "TargetVersion": next_version,
            "PerformCheckOnly": perform_check_only,
        }

        if not module.check_mode:
            # If background tasks are in progress, wait until they complete.
            # This can take several hours depending on the cluster size and the type of background tasks
            # (maybe an upgrade is already in progress).
            # It's not possible to upgrade a domain that has background tasks are in progress,
            # the call to client.upgrade_domain would fail.
            wait_for_domain_status(client, module, domain_name, "domain_available")

        try:
            client.upgrade_domain(**parameters)
        except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e:
            # In check mode (=> PerformCheckOnly==True), a ValidationException may be
            # raised if it's not possible to upgrade to the target version.
            module.fail_json_aws(
                e,
                msg=f"Couldn't upgrade domain {domain_name} from {current_version} to {next_version}",
            )

        if module.check_mode:
            module.exit_json(
                changed=True,
                msg=f"Would have upgraded domain from {current_version} to {next_version} if not in check mode",
            )
        current_version = next_version

    if module.params.get("wait"):
        wait_for_domain_status(client, module, domain_name, "domain_available")


def set_cluster_config(module, current_domain_config, desired_domain_config, change_set):
    changed = False

    cluster_config = desired_domain_config["ClusterConfig"]
    cluster_opts = module.params.get("cluster_config")
    if cluster_opts is not None:
        if cluster_opts.get("instance_type") is not None:
            cluster_config["InstanceType"] = cluster_opts.get("instance_type")
        if cluster_opts.get("instance_count") is not None:
            cluster_config["InstanceCount"] = cluster_opts.get("instance_count")
        if cluster_opts.get("zone_awareness") is not None:
            cluster_config["ZoneAwarenessEnabled"] = cluster_opts.get("zone_awareness")
        if cluster_config["ZoneAwarenessEnabled"]:
            if cluster_opts.get("availability_zone_count") is not None:
                cluster_config["ZoneAwarenessConfig"] = {
                    "AvailabilityZoneCount": cluster_opts.get("availability_zone_count"),
                }

        if cluster_opts.get("dedicated_master") is not None:
            cluster_config["DedicatedMasterEnabled"] = cluster_opts.get("dedicated_master")
        if cluster_config["DedicatedMasterEnabled"]:
            if cluster_opts.get("dedicated_master_instance_type") is not None:
                cluster_config["DedicatedMasterType"] = cluster_opts.get("dedicated_master_instance_type")
            if cluster_opts.get("dedicated_master_instance_count") is not None:
                cluster_config["DedicatedMasterCount"] = cluster_opts.get("dedicated_master_instance_count")

        if cluster_opts.get("warm_enabled") is not None:
            cluster_config["WarmEnabled"] = cluster_opts.get("warm_enabled")
        if cluster_config["WarmEnabled"]:
            if cluster_opts.get("warm_type") is not None:
                cluster_config["WarmType"] = cluster_opts.get("warm_type")
            if cluster_opts.get("warm_count") is not None:
                cluster_config["WarmCount"] = cluster_opts.get("warm_count")

    cold_storage_opts = None
    if cluster_opts is not None:
        cold_storage_opts = cluster_opts.get("cold_storage_options")
    if compare_domain_versions(desired_domain_config["EngineVersion"], "Elasticsearch_7.9") < 0:
        # If the engine version is ElasticSearch < 7.9, cold storage is not supported.
        # When querying a domain < 7.9, the AWS API indicates cold storage is disabled (Enabled: False),
        # which makes sense. However, trying to do HTTP POST with Enable: False causes an API error.
        # The 'ColdStorageOptions' attribute should not be present in HTTP POST.
        if cold_storage_opts is not None and cold_storage_opts.get("enabled"):
            module.fail_json(msg="Cold Storage is not supported")
        cluster_config.pop("ColdStorageOptions", None)
        if current_domain_config is not None and "ClusterConfig" in current_domain_config:
            # Remove 'ColdStorageOptions' from the current domain config, otherwise the actual vs desired diff
            # will indicate a change must be done.
            current_domain_config["ClusterConfig"].pop("ColdStorageOptions", None)
    else:
        # Elasticsearch 7.9 and above support ColdStorageOptions.
        if cold_storage_opts is not None and cold_storage_opts.get("enabled") is not None:
            cluster_config["ColdStorageOptions"] = {
                "Enabled": cold_storage_opts.get("enabled"),
            }

    if current_domain_config is not None and current_domain_config["ClusterConfig"] != cluster_config:
        change_set.append(f"ClusterConfig changed from {current_domain_config['ClusterConfig']} to {cluster_config}")
        changed = True
    return changed


def set_ebs_options(module, current_domain_config, desired_domain_config, change_set):
    changed = False
    ebs_config = desired_domain_config["EBSOptions"]
    ebs_opts = module.params.get("ebs_options")
    if ebs_opts is None:
        return changed
    if ebs_opts.get("ebs_enabled") is not None:
        ebs_config["EBSEnabled"] = ebs_opts.get("ebs_enabled")

    if not ebs_config["EBSEnabled"]:
        desired_domain_config["EBSOptions"] = {
            "EBSEnabled": False,
        }
    else:
        if ebs_opts.get("volume_type") is not None:
            ebs_config["VolumeType"] = ebs_opts.get("volume_type")
        if ebs_opts.get("volume_size") is not None:
            ebs_config["VolumeSize"] = ebs_opts.get("volume_size")
        if ebs_opts.get("iops") is not None:
            ebs_config["Iops"] = ebs_opts.get("iops")

    if current_domain_config is not None and current_domain_config["EBSOptions"] != ebs_config:
        change_set.append(f"EBSOptions changed from {current_domain_config['EBSOptions']} to {ebs_config}")
        changed = True
    return changed


def set_encryption_at_rest_options(module, current_domain_config, desired_domain_config, change_set):
    changed = False
    encryption_at_rest_config = desired_domain_config["EncryptionAtRestOptions"]
    encryption_at_rest_opts = module.params.get("encryption_at_rest_options")
    if encryption_at_rest_opts is None:
        return False
    if encryption_at_rest_opts.get("enabled") is not None:
        encryption_at_rest_config["Enabled"] = encryption_at_rest_opts.get("enabled")
    if not encryption_at_rest_config["Enabled"]:
        desired_domain_config["EncryptionAtRestOptions"] = {
            "Enabled": False,
        }
    else:
        if encryption_at_rest_opts.get("kms_key_id") is not None:
            encryption_at_rest_config["KmsKeyId"] = encryption_at_rest_opts.get("kms_key_id")

    if (
        current_domain_config is not None
        and current_domain_config["EncryptionAtRestOptions"] != encryption_at_rest_config
    ):
        change_set.append(
            f"EncryptionAtRestOptions changed from {current_domain_config['EncryptionAtRestOptions']} to"
            f" {encryption_at_rest_config}"
        )
        changed = True
    return changed


def set_node_to_node_encryption_options(module, current_domain_config, desired_domain_config, change_set):
    changed = False
    node_to_node_encryption_config = desired_domain_config["NodeToNodeEncryptionOptions"]
    node_to_node_encryption_opts = module.params.get("node_to_node_encryption_options")
    if node_to_node_encryption_opts is None:
        return changed
    if node_to_node_encryption_opts.get("enabled") is not None:
        node_to_node_encryption_config["Enabled"] = node_to_node_encryption_opts.get("enabled")

    if (
        current_domain_config is not None
        and current_domain_config["NodeToNodeEncryptionOptions"] != node_to_node_encryption_config
    ):
        change_set.append(
            f"NodeToNodeEncryptionOptions changed from {current_domain_config['NodeToNodeEncryptionOptions']} to"
            f" {node_to_node_encryption_config}"
        )
        changed = True
    return changed


def set_vpc_options(module, current_domain_config, desired_domain_config, change_set):
    changed = False
    vpc_config = None
    if "VPCOptions" in desired_domain_config:
        vpc_config = desired_domain_config["VPCOptions"]
    vpc_opts = module.params.get("vpc_options")
    if vpc_opts is None:
        return changed
    vpc_subnets = vpc_opts.get("subnets")
    if vpc_subnets is not None:
        if vpc_config is None:
            vpc_config = {}
            desired_domain_config["VPCOptions"] = vpc_config
        # OpenSearch cluster is attached to VPC
        if isinstance(vpc_subnets, str):
            vpc_subnets = [x.strip() for x in vpc_subnets.split(",")]
        vpc_config["SubnetIds"] = vpc_subnets

    vpc_security_groups = vpc_opts.get("security_groups")
    if vpc_security_groups is not None:
        if vpc_config is None:
            vpc_config = {}
            desired_domain_config["VPCOptions"] = vpc_config
        if isinstance(vpc_security_groups, str):
            vpc_security_groups = [x.strip() for x in vpc_security_groups.split(",")]
        vpc_config["SecurityGroupIds"] = vpc_security_groups

    if current_domain_config is not None:
        # Modify existing cluster.
        current_cluster_is_vpc = False
        desired_cluster_is_vpc = False
        if (
            "VPCOptions" in current_domain_config
            and "SubnetIds" in current_domain_config["VPCOptions"]
            and len(current_domain_config["VPCOptions"]["SubnetIds"]) > 0
        ):
            current_cluster_is_vpc = True
        if (
            "VPCOptions" in desired_domain_config
            and "SubnetIds" in desired_domain_config["VPCOptions"]
            and len(desired_domain_config["VPCOptions"]["SubnetIds"]) > 0
        ):
            desired_cluster_is_vpc = True
        if current_cluster_is_vpc != desired_cluster_is_vpc:
            # AWS does not allow changing the type. Don't fail here so we return the AWS API error.
            change_set.append("VPCOptions changed between Internet and VPC")
            changed = True
        elif desired_cluster_is_vpc is False:
            # There are no VPCOptions to configure.
            pass
        else:
            # Note the subnets may be the same but be listed in a different order.
            if set(current_domain_config["VPCOptions"]["SubnetIds"]) != set(vpc_config["SubnetIds"]):
                change_set.append(
                    f"SubnetIds changed from {current_domain_config['VPCOptions']['SubnetIds']} to"
                    f" {vpc_config['SubnetIds']}"
                )
                changed = True
            if set(current_domain_config["VPCOptions"]["SecurityGroupIds"]) != set(vpc_config["SecurityGroupIds"]):
                change_set.append(
                    f"SecurityGroup changed from {current_domain_config['VPCOptions']['SecurityGroupIds']} to"
                    f" {vpc_config['SecurityGroupIds']}"
                )
                changed = True
    return changed


def set_snapshot_options(module, current_domain_config, desired_domain_config, change_set):
    changed = False
    snapshot_config = desired_domain_config["SnapshotOptions"]
    snapshot_opts = module.params.get("snapshot_options")
    if snapshot_opts is None:
        return changed
    if snapshot_opts.get("automated_snapshot_start_hour") is not None:
        snapshot_config["AutomatedSnapshotStartHour"] = snapshot_opts.get("automated_snapshot_start_hour")
    if current_domain_config is not None and current_domain_config["SnapshotOptions"] != snapshot_config:
        change_set.append("SnapshotOptions changed")
        changed = True
    return changed


def set_cognito_options(module, current_domain_config, desired_domain_config, change_set):
    changed = False
    cognito_config = desired_domain_config["CognitoOptions"]
    cognito_opts = module.params.get("cognito_options")
    if cognito_opts is None:
        return changed
    if cognito_opts.get("enabled") is not None:
        cognito_config["Enabled"] = cognito_opts.get("enabled")
    if not cognito_config["Enabled"]:
        desired_domain_config["CognitoOptions"] = {
            "Enabled": False,
        }
    else:
        if cognito_opts.get("cognito_user_pool_id") is not None:
            cognito_config["UserPoolId"] = cognito_opts.get("cognito_user_pool_id")
        if cognito_opts.get("cognito_identity_pool_id") is not None:
            cognito_config["IdentityPoolId"] = cognito_opts.get("cognito_identity_pool_id")
        if cognito_opts.get("cognito_role_arn") is not None:
            cognito_config["RoleArn"] = cognito_opts.get("cognito_role_arn")

    if current_domain_config is not None and current_domain_config["CognitoOptions"] != cognito_config:
        change_set.append(f"CognitoOptions changed from {current_domain_config['CognitoOptions']} to {cognito_config}")
        changed = True
    return changed


def set_advanced_security_options(module, current_domain_config, desired_domain_config, change_set):
    changed = False
    advanced_security_config = desired_domain_config["AdvancedSecurityOptions"]
    advanced_security_opts = module.params.get("advanced_security_options")
    if advanced_security_opts is None:
        return changed
    if advanced_security_opts.get("enabled") is not None:
        advanced_security_config["Enabled"] = advanced_security_opts.get("enabled")
    if not advanced_security_config["Enabled"]:
        desired_domain_config["AdvancedSecurityOptions"] = {
            "Enabled": False,
        }
    else:
        if advanced_security_opts.get("internal_user_database_enabled") is not None:
            advanced_security_config["InternalUserDatabaseEnabled"] = advanced_security_opts.get(
                "internal_user_database_enabled"
            )
        master_user_opts = advanced_security_opts.get("master_user_options")
        if master_user_opts is not None:
            advanced_security_config.setdefault("MasterUserOptions", {})
            if master_user_opts.get("master_user_arn") is not None:
                advanced_security_config["MasterUserOptions"]["MasterUserARN"] = master_user_opts.get("master_user_arn")
            if master_user_opts.get("master_user_name") is not None:
                advanced_security_config["MasterUserOptions"]["MasterUserName"] = master_user_opts.get(
                    "master_user_name"
                )
            if master_user_opts.get("master_user_password") is not None:
                advanced_security_config["MasterUserOptions"]["MasterUserPassword"] = master_user_opts.get(
                    "master_user_password"
                )
        saml_opts = advanced_security_opts.get("saml_options")
        if saml_opts is not None:
            if saml_opts.get("enabled") is not None:
                advanced_security_config["SamlOptions"]["Enabled"] = saml_opts.get("enabled")
            idp_opts = saml_opts.get("idp")
            if idp_opts is not None:
                if idp_opts.get("metadata_content") is not None:
                    advanced_security_config["SamlOptions"]["Idp"]["MetadataContent"] = idp_opts.get("metadata_content")
                if idp_opts.get("entity_id") is not None:
                    advanced_security_config["SamlOptions"]["Idp"]["EntityId"] = idp_opts.get("entity_id")
            if saml_opts.get("master_user_name") is not None:
                advanced_security_config["SamlOptions"]["MasterUserName"] = saml_opts.get("master_user_name")
            if saml_opts.get("master_backend_role") is not None:
                advanced_security_config["SamlOptions"]["MasterBackendRole"] = saml_opts.get("master_backend_role")
            if saml_opts.get("subject_key") is not None:
                advanced_security_config["SamlOptions"]["SubjectKey"] = saml_opts.get("subject_key")
            if saml_opts.get("roles_key") is not None:
                advanced_security_config["SamlOptions"]["RolesKey"] = saml_opts.get("roles_key")
            if saml_opts.get("session_timeout_minutes") is not None:
                advanced_security_config["SamlOptions"]["SessionTimeoutMinutes"] = saml_opts.get(
                    "session_timeout_minutes"
                )

    if (
        current_domain_config is not None
        and current_domain_config["AdvancedSecurityOptions"] != advanced_security_config
    ):
        change_set.append(
            f"AdvancedSecurityOptions changed from {current_domain_config['AdvancedSecurityOptions']} to"
            f" {advanced_security_config}"
        )
        changed = True
    return changed


def set_domain_endpoint_options(module, current_domain_config, desired_domain_config, change_set):
    changed = False
    domain_endpoint_config = desired_domain_config["DomainEndpointOptions"]
    domain_endpoint_opts = module.params.get("domain_endpoint_options")
    if domain_endpoint_opts is None:
        return changed
    if domain_endpoint_opts.get("enforce_https") is not None:
        domain_endpoint_config["EnforceHTTPS"] = domain_endpoint_opts.get("enforce_https")
    if domain_endpoint_opts.get("tls_security_policy") is not None:
        domain_endpoint_config["TLSSecurityPolicy"] = domain_endpoint_opts.get("tls_security_policy")
    if domain_endpoint_opts.get("custom_endpoint_enabled") is not None:
        domain_endpoint_config["CustomEndpointEnabled"] = domain_endpoint_opts.get("custom_endpoint_enabled")
    if domain_endpoint_config["CustomEndpointEnabled"]:
        if domain_endpoint_opts.get("custom_endpoint") is not None:
            domain_endpoint_config["CustomEndpoint"] = domain_endpoint_opts.get("custom_endpoint")
        if domain_endpoint_opts.get("custom_endpoint_certificate_arn") is not None:
            domain_endpoint_config["CustomEndpointCertificateArn"] = domain_endpoint_opts.get(
                "custom_endpoint_certificate_arn"
            )

    if current_domain_config is not None and current_domain_config["DomainEndpointOptions"] != domain_endpoint_config:
        change_set.append(
            f"DomainEndpointOptions changed from {current_domain_config['DomainEndpointOptions']} to"
            f" {domain_endpoint_config}"
        )
        changed = True
    return changed


def set_auto_tune_options(module, current_domain_config, desired_domain_config, change_set):
    changed = False
    auto_tune_config = desired_domain_config["AutoTuneOptions"]
    auto_tune_opts = module.params.get("auto_tune_options")
    if auto_tune_opts is None:
        return changed
    schedules = auto_tune_opts.get("maintenance_schedules")
    if auto_tune_opts.get("desired_state") is not None:
        auto_tune_config["DesiredState"] = auto_tune_opts.get("desired_state")
    if auto_tune_config["DesiredState"] != "ENABLED":
        desired_domain_config["AutoTuneOptions"] = {
            "DesiredState": "DISABLED",
        }
    elif schedules is not None:
        auto_tune_config["MaintenanceSchedules"] = []
        for s in schedules:
            schedule_entry = {}
            start_at = s.get("start_at")
            if start_at is not None:
                if isinstance(start_at, datetime.datetime):
                    # The property was parsed from yaml to datetime, but the AWS API wants a string
                    start_at = start_at.strftime("%Y-%m-%d")
                schedule_entry["StartAt"] = start_at
            duration_opt = s.get("duration")
            if duration_opt is not None:
                schedule_entry["Duration"] = {}
                if duration_opt.get("value") is not None:
                    schedule_entry["Duration"]["Value"] = duration_opt.get("value")
                if duration_opt.get("unit") is not None:
                    schedule_entry["Duration"]["Unit"] = duration_opt.get("unit")
            if s.get("cron_expression_for_recurrence") is not None:
                schedule_entry["CronExpressionForRecurrence"] = s.get("cron_expression_for_recurrence")
            auto_tune_config["MaintenanceSchedules"].append(schedule_entry)
    if current_domain_config is not None:
        if current_domain_config["AutoTuneOptions"]["DesiredState"] != auto_tune_config["DesiredState"]:
            change_set.append(
                "AutoTuneOptions.DesiredState changed from"
                f" {current_domain_config['AutoTuneOptions']['DesiredState']} to {auto_tune_config['DesiredState']}"
            )
            changed = True
        if auto_tune_config["MaintenanceSchedules"] != current_domain_config["AutoTuneOptions"]["MaintenanceSchedules"]:
            change_set.append(
                "AutoTuneOptions.MaintenanceSchedules changed from"
                f" {current_domain_config['AutoTuneOptions']['MaintenanceSchedules']} to"
                f" {auto_tune_config['MaintenanceSchedules']}"
            )
            changed = True
    return changed


def set_access_policy(module, current_domain_config, desired_domain_config, change_set):
    access_policy_config = None
    changed = False
    access_policy_opt = module.params.get("access_policies")
    if access_policy_opt is None:
        return changed
    try:
        access_policy_config = json.dumps(access_policy_opt)
    except Exception as e:
        module.fail_json(msg=f"Failed to convert the policy into valid JSON: {str(e)}")
    if current_domain_config is not None:
        # Updating existing domain
        current_access_policy = json.loads(current_domain_config["AccessPolicies"])
        if not compare_policies(current_access_policy, access_policy_opt):
            change_set.append(f"AccessPolicy changed from {current_access_policy} to {access_policy_opt}")
            changed = True
            desired_domain_config["AccessPolicies"] = access_policy_config
    else:
        # Creating new domain
        desired_domain_config["AccessPolicies"] = access_policy_config
    return changed


def ensure_domain_present(client, module):
    domain_name = module.params.get("domain_name")

    # Create default if OpenSearch does not exist. If domain already exists,
    # the data is populated by retrieving the current configuration from the API.
    desired_domain_config = {
        "DomainName": module.params.get("domain_name"),
        "EngineVersion": "OpenSearch_1.1",
        "ClusterConfig": {
            "InstanceType": "t2.small.search",
            "InstanceCount": 2,
            "ZoneAwarenessEnabled": False,
            "DedicatedMasterEnabled": False,
            "WarmEnabled": False,
        },
        # By default create ES attached to the Internet.
        # If the "VPCOptions" property is specified, even if empty, the API server interprets
        # as incomplete VPC configuration.
        # "VPCOptions": {},
        "EBSOptions": {
            "EBSEnabled": False,
        },
        "EncryptionAtRestOptions": {
            "Enabled": False,
        },
        "NodeToNodeEncryptionOptions": {
            "Enabled": False,
        },
        "SnapshotOptions": {
            "AutomatedSnapshotStartHour": 0,
        },
        "CognitoOptions": {
            "Enabled": False,
        },
        "AdvancedSecurityOptions": {
            "Enabled": False,
        },
        "DomainEndpointOptions": {
            "CustomEndpointEnabled": False,
        },
        "AutoTuneOptions": {
            "DesiredState": "DISABLED",
        },
    }
    # Determine if OpenSearch domain already exists.
    # current_domain_config may be None if the domain does not exist.
    (current_domain_config, domain_arn) = get_domain_config(client, module, domain_name)
    if current_domain_config is not None:
        desired_domain_config = deepcopy(current_domain_config)

    if module.params.get("engine_version") is not None:
        # Validate the engine_version
        v = parse_version(module.params.get("engine_version"))
        if v is None:
            module.fail_json("Invalid engine_version. Must be Elasticsearch_X.Y or OpenSearch_X.Y")
        desired_domain_config["EngineVersion"] = module.params.get("engine_version")

    changed = False
    change_set = []  # For check mode purpose

    changed |= set_cluster_config(module, current_domain_config, desired_domain_config, change_set)
    changed |= set_ebs_options(module, current_domain_config, desired_domain_config, change_set)
    changed |= set_encryption_at_rest_options(module, current_domain_config, desired_domain_config, change_set)
    changed |= set_node_to_node_encryption_options(module, current_domain_config, desired_domain_config, change_set)
    changed |= set_vpc_options(module, current_domain_config, desired_domain_config, change_set)
    changed |= set_snapshot_options(module, current_domain_config, desired_domain_config, change_set)
    changed |= set_cognito_options(module, current_domain_config, desired_domain_config, change_set)
    changed |= set_advanced_security_options(module, current_domain_config, desired_domain_config, change_set)
    changed |= set_domain_endpoint_options(module, current_domain_config, desired_domain_config, change_set)
    changed |= set_auto_tune_options(module, current_domain_config, desired_domain_config, change_set)
    changed |= set_access_policy(module, current_domain_config, desired_domain_config, change_set)

    if current_domain_config is not None:
        if desired_domain_config["EngineVersion"] != current_domain_config["EngineVersion"]:
            changed = True
            change_set.append("EngineVersion changed")
            upgrade_domain(
                client,
                module,
                current_domain_config["EngineVersion"],
                desired_domain_config["EngineVersion"],
            )

        if changed:
            if module.check_mode:
                module.exit_json(
                    changed=True,
                    msg=f"Would have updated domain if not in check mode: {change_set}",
                )
            # Remove the "EngineVersion" attribute, the AWS API does not accept this attribute.
            desired_domain_config.pop("EngineVersion", None)
            try:
                client.update_domain_config(**desired_domain_config)
            except (
                botocore.exceptions.BotoCoreError,
                botocore.exceptions.ClientError,
            ) as e:
                module.fail_json_aws(e, msg=f"Couldn't update domain {domain_name}")

    else:
        # Create new OpenSearch cluster
        if module.params.get("access_policies") is None:
            module.fail_json("state is present but the following is missing: access_policies")

        changed = True
        if module.check_mode:
            module.exit_json(changed=True, msg="Would have created a domain if not in check mode")
        try:
            response = client.create_domain(**desired_domain_config)
            domain = response["DomainStatus"]
            domain_arn = domain["ARN"]
        except (
            botocore.exceptions.BotoCoreError,
            botocore.exceptions.ClientError,
        ) as e:
            module.fail_json_aws(e, msg=f"Couldn't update domain {domain_name}")

    try:
        existing_tags = boto3_tag_list_to_ansible_dict(client.list_tags(ARN=domain_arn, aws_retry=True)["TagList"])
    except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
        module.fail_json_aws(e, f"Couldn't get tags for domain {domain_name}")

    desired_tags = module.params["tags"]
    purge_tags = module.params["purge_tags"]
    changed |= ensure_tags(client, module, domain_arn, existing_tags, desired_tags, purge_tags)

    if module.params.get("wait") and not module.check_mode:
        wait_for_domain_status(client, module, domain_name, "domain_available")

    domain = get_domain_status(client, module, domain_name)

    return dict(changed=changed, **normalize_opensearch(client, module, domain))


def main():
    module = AnsibleAWSModule(
        argument_spec=dict(
            state=dict(choices=["present", "absent"], default="present"),
            domain_name=dict(required=True),
            engine_version=dict(),
            allow_intermediate_upgrades=dict(required=False, type="bool", default=True),
            access_policies=dict(required=False, type="dict"),
            cluster_config=dict(
                type="dict",
                default=None,
                options=dict(
                    instance_type=dict(),
                    instance_count=dict(required=False, type="int"),
                    zone_awareness=dict(required=False, type="bool"),
                    availability_zone_count=dict(required=False, type="int"),
                    dedicated_master=dict(required=False, type="bool"),
                    dedicated_master_instance_type=dict(),
                    dedicated_master_instance_count=dict(type="int"),
                    warm_enabled=dict(required=False, type="bool"),
                    warm_type=dict(required=False),
                    warm_count=dict(required=False, type="int"),
                    cold_storage_options=dict(
                        type="dict",
                        default=None,
                        options=dict(
                            enabled=dict(required=False, type="bool"),
                        ),
                    ),
                ),
            ),
            snapshot_options=dict(
                type="dict",
                default=None,
                options=dict(
                    automated_snapshot_start_hour=dict(required=False, type="int"),
                ),
            ),
            ebs_options=dict(
                type="dict",
                default=None,
                options=dict(
                    ebs_enabled=dict(required=False, type="bool"),
                    volume_type=dict(required=False),
                    volume_size=dict(required=False, type="int"),
                    iops=dict(required=False, type="int"),
                ),
            ),
            vpc_options=dict(
                type="dict",
                default=None,
                options=dict(
                    subnets=dict(type="list", elements="str", required=False),
                    security_groups=dict(type="list", elements="str", required=False),
                ),
            ),
            cognito_options=dict(
                type="dict",
                default=None,
                options=dict(
                    enabled=dict(required=False, type="bool"),
                    user_pool_id=dict(required=False),
                    identity_pool_id=dict(required=False),
                    role_arn=dict(required=False, no_log=False),
                ),
            ),
            encryption_at_rest_options=dict(
                type="dict",
                default=None,
                options=dict(
                    enabled=dict(type="bool"),
                    kms_key_id=dict(required=False),
                ),
            ),
            node_to_node_encryption_options=dict(
                type="dict",
                default=None,
                options=dict(
                    enabled=dict(type="bool"),
                ),
            ),
            domain_endpoint_options=dict(
                type="dict",
                default=None,
                options=dict(
                    enforce_https=dict(type="bool"),
                    tls_security_policy=dict(),
                    custom_endpoint_enabled=dict(type="bool"),
                    custom_endpoint=dict(),
                    custom_endpoint_certificate_arn=dict(),
                ),
            ),
            advanced_security_options=dict(
                type="dict",
                default=None,
                options=dict(
                    enabled=dict(type="bool"),
                    internal_user_database_enabled=dict(type="bool"),
                    master_user_options=dict(
                        type="dict",
                        default=None,
                        options=dict(
                            master_user_arn=dict(),
                            master_user_name=dict(),
                            master_user_password=dict(no_log=True),
                        ),
                    ),
                    saml_options=dict(
                        type="dict",
                        default=None,
                        options=dict(
                            enabled=dict(type="bool"),
                            idp=dict(
                                type="dict",
                                default=None,
                                options=dict(
                                    metadata_content=dict(),
                                    entity_id=dict(),
                                ),
                            ),
                            master_user_name=dict(),
                            master_backend_role=dict(),
                            subject_key=dict(no_log=False),
                            roles_key=dict(no_log=False),
                            session_timeout_minutes=dict(type="int"),
                        ),
                    ),
                ),
            ),
            auto_tune_options=dict(
                type="dict",
                default=None,
                options=dict(
                    desired_state=dict(choices=["ENABLED", "DISABLED"]),
                    maintenance_schedules=dict(
                        type="list",
                        elements="dict",
                        default=None,
                        options=dict(
                            start_at=dict(),
                            duration=dict(
                                type="dict",
                                default=None,
                                options=dict(
                                    value=dict(type="int"),
                                    unit=dict(choices=["HOURS"]),
                                ),
                            ),
                            cron_expression_for_recurrence=dict(),
                        ),
                    ),
                ),
            ),
            tags=dict(type="dict", aliases=["resource_tags"]),
            purge_tags=dict(type="bool", default=True),
            wait=dict(type="bool", default=False),
            wait_timeout=dict(type="int", default=300),
        ),
        supports_check_mode=True,
    )

    try:
        client = module.client("opensearch", retry_decorator=AWSRetry.jittered_backoff())
    except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
        module.fail_json_aws(e, msg="Failed to connect to AWS opensearch service")

    if module.params["state"] == "absent":
        ret_dict = ensure_domain_absent(client, module)
    else:
        ret_dict = ensure_domain_present(client, module)

    module.exit_json(**ret_dict)


if __name__ == "__main__":
    main()
