#!/usr/bin/python
#
# PubNub Real-time Cloud-Hosted Push API and Push Notification Client
# Frameworks
# Copyright (C) 2016 PubNub Inc.
# http://www.pubnub.com/
# http://www.pubnub.com/terms
#
# 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: pubnub_blocks
short_description: PubNub blocks management module
description:
  - 'This module allows Ansible to interface with the PubNub BLOCKS infrastructure by providing the following operations:
    create / remove, start / stop and rename for blocks and create / modify / remove for event handlers.'
author:
  - PubNub <support@pubnub.com> (@pubnub)
  - Sergey Mamontov <sergey@pubnub.com> (@parfeon)
requirements:
  - "pubnub_blocks_client >= 1.0"
extends_documentation_fragment:
  - community.general._attributes
attributes:
  check_mode:
    support: full
  diff_mode:
    support: none
options:
  email:
    description:
      - Email from account for which new session should be started.
      - Not required if O(cache) contains result of previous module call (in same play).
    type: str
    default: ''
  password:
    description:
      - Password which match to account to which specified O(email) belong.
      - Not required if O(cache) contains result of previous module call (in same play).
    type: str
    default: ''
  cache:
    description: >-
      In case if single play use blocks management module few times it is preferred to enabled 'caching' by making previous
      module to share gathered artifacts and pass them to this parameter.
    type: dict
    default: {}
  account:
    description:
      - Name of PubNub account for from which O(application) is used to manage blocks.
      - User's account is used if value not set or empty.
    type: str
    default: ''
  application:
    description:
      - Name of target PubNub application for which blocks configuration on specific O(keyset) is done.
    type: str
    required: true
  keyset:
    description:
      - Name of application's keys set which is bound to managed blocks.
    type: str
    required: true
  state:
    description:
      - Intended block state after event handlers creation / update process is completed.
    default: 'present'
    choices: ['started', 'stopped', 'present', 'absent']
    type: str
  name:
    description:
      - Name of managed block which is later visible on admin.pubnub.com.
    required: true
    type: str
  description:
    description:
      - Short block description which is later visible on U(https://admin.pubnub.com).
      - Used only if block does not exists and does not change description for existing block.
    type: str
  event_handlers:
    description:
      - List of event handlers which should be updated for specified block O(name).
      - 'Each entry for new event handler should contain: V(name), V(src), V(channels), V(event). V(name) used as event handler
        name which can be used later to make changes to it.'
      - C(src) is full path to file with event handler code.
      - V(channels) is name of channel from which event handler is waiting for events.
      - 'V(event) is type of event which is able to trigger event handler: V(js-before-publish), V(js-after-publish), V(js-after-presence).'
      - Each entry for existing handlers should contain C(name) (so target handler can be identified). Rest parameters (C(src),
        C(channels) and C(event)) can be added if changes required for them.
      - It is possible to rename event handler by adding C(changes) key to event handler payload and pass dictionary, which
        contains single key C(name), where new name should be passed.
      - To remove particular event handler it is possible to set C(state) for it to C(absent) and it is removed.
    default: []
    type: list
    elements: dict
  changes:
    description:
      - List of fields which should be changed by block itself (does not affect any event handlers).
      - 'Possible options for change is: O(name).'
    default: {}
    type: dict
  validate_certs:
    description:
      - This key allow to try skip certificates check when performing REST API calls. Sometimes host may have issues with
        certificates on it and this causes problems to call PubNub REST API.
      - If check should be ignored V(false) should be passed to this parameter.
    default: true
    type: bool
"""

EXAMPLES = r"""
# Event handler create example.
- name: Create single event handler
  community.general.pubnub_blocks:
    email: '{{ email }}'
    password: '{{ password }}'
    application: '{{ app_name }}'
    keyset: '{{ keyset_name }}'
    name: '{{ block_name }}'
    event_handlers:
      - src: '{{ path_to_handler_source }}'
        name: '{{ handler_name }}'
        event: 'js-before-publish'
        channels: '{{ handler_channel }}'

# Change event handler trigger event type.
- name: Change event handler 'event'
  community.general.pubnub_blocks:
    email: '{{ email }}'
    password: '{{ password }}'
    application: '{{ app_name }}'
    keyset: '{{ keyset_name }}'
    name: '{{ block_name }}'
    event_handlers:
      - name: '{{ handler_name }}'
        event: 'js-after-publish'

# Stop block and event handlers.
- name: Stopping block
  community.general.pubnub_blocks:
    email: '{{ email }}'
    password: '{{ password }}'
    application: '{{ app_name }}'
    keyset: '{{ keyset_name }}'
    name: '{{ block_name }}'
    state: stop

# Multiple module calls with cached result passing
- name: Create '{{ block_name }}' block
  register: module_cache
  community.general.pubnub_blocks:
    email: '{{ email }}'
    password: '{{ password }}'
    application: '{{ app_name }}'
    keyset: '{{ keyset_name }}'
    name: '{{ block_name }}'
    state: present
- name: Add '{{ event_handler_1_name }}' handler to '{{ block_name }}'
  register: module_cache
  community.general.pubnub_blocks:
    cache: '{{ module_cache }}'
    application: '{{ app_name }}'
    keyset: '{{ keyset_name }}'
    name: '{{ block_name }}'
    state: present
    event_handlers:
      - src: '{{ path_to_handler_1_source }}'
        name: '{{ event_handler_1_name }}'
        channels: '{{ event_handler_1_channel }}'
        event: 'js-before-publish'
- name: Add '{{ event_handler_2_name }}' handler to '{{ block_name }}'
  register: module_cache
  community.general.pubnub_blocks:
    cache: '{{ module_cache }}'
    application: '{{ app_name }}'
    keyset: '{{ keyset_name }}'
    name: '{{ block_name }}'
    state: present
    event_handlers:
      - src: '{{ path_to_handler_2_source }}'
        name: '{{ event_handler_2_name }}'
        channels: '{{ event_handler_2_channel }}'
        event: 'js-before-publish'
- name: Start '{{ block_name }}' block
  register: module_cache
  community.general.pubnub_blocks:
    cache: '{{ module_cache }}'
    application: '{{ app_name }}'
    keyset: '{{ keyset_name }}'
    name: '{{ block_name }}'
    state: started
"""

RETURN = r"""
module_cache:
  description:
    - Cached account information. In case if with single play module used few times it is better to pass cached data to next
      module calls to speed up process.
  type: dict
  returned: always
"""
import copy
import os

try:
    # Import PubNub BLOCKS client.
    from pubnub_blocks_client import Block, EventHandler, User, exceptions

    HAS_PUBNUB_BLOCKS_CLIENT = True
except ImportError:
    HAS_PUBNUB_BLOCKS_CLIENT = False
    User = None
    Account = None
    Owner = None
    Application = None
    Keyset = None
    Block = None
    EventHandler = None
    exceptions = None

from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.common.text.converters import to_text


def pubnub_user(module):
    """Create and configure user model if it possible.

    :type module:  AnsibleModule
    :param module: Reference on module which contain module launch
                   information and status report methods.

    :rtype:  User
    :return: Reference on initialized and ready to use user or 'None' in
             case if not all required information has been passed to block.
    """
    user = None
    params = module.params

    if params.get("cache") and params["cache"].get("module_cache"):
        cache = params["cache"]["module_cache"]
        user = User()
        user.restore(cache=copy.deepcopy(cache["pnm_user"]))
    elif params.get("email") and params.get("password"):
        user = User(email=params.get("email"), password=params.get("password"))
    else:
        err_msg = (
            "It looks like not account credentials has been passed or "
            "'cache' field doesn't have result of previous module "
            "call."
        )
        module.fail_json(msg="Missing account credentials.", description=err_msg, changed=False)

    return user


def pubnub_account(module, user):
    """Create and configure account if it is possible.

    :type module:  AnsibleModule
    :param module: Reference on module which contain module launch
                   information and status report methods.
    :type user:    User
    :param user:   Reference on authorized user for which one of accounts
                   should be used during manipulations with block.

    :rtype:  Account
    :return: Reference on initialized and ready to use account or 'None' in
             case if not all required information has been passed to block.
    """
    params = module.params
    if params.get("account"):
        account_name = params.get("account")
        account = user.account(name=params.get("account"))
        if account is None:
            module.fail_json(
                msg="Missing account.",
                description=(
                    f"It looks like there is no '{account_name}' account for authorized user. "
                    "Please make sure what correct name has been passed during module configuration."
                ),
                changed=False,
            )
    else:
        account = user.accounts()[0]

    return account


def pubnub_application(module, account):
    """Retrieve reference on target application from account model.

    NOTE: In case if account authorization will fail or there is no
    application with specified name, module will exit with error.
    :type module:   AnsibleModule
    :param module:  Reference on module which contain module launch
                    information and status report methods.
    :type account:  Account
    :param account: Reference on PubNub account model from which reference
                    on application should be fetched.

    :rtype:  Application
    :return: Reference on initialized and ready to use application model.
    """
    application = None
    params = module.params
    try:
        application = account.application(params["application"])
    except (exceptions.AccountError, exceptions.GeneralPubNubError) as exc:
        exc_msg = _failure_title_from_exception(exc)
        exc_descr = exc.message if hasattr(exc, "message") else exc.args[0]
        module.fail_json(msg=exc_msg, description=exc_descr, changed=account.changed, module_cache=dict(account))

    if application is None:
        email = account.owner.email
        module.fail_json(
            msg=(
                f"There is no '{params['application']}' application for {email}. "
                "Make sure what correct application name has been passed. "
                "If application doesn't exist you can create it on admin.pubnub.com."
            ),
            changed=account.changed,
            module_cache=dict(account),
        )

    return application


def pubnub_keyset(module, account, application):
    """Retrieve reference on target keyset from application model.

    NOTE: In case if there is no keyset with specified name, module will
    exit with error.
    :type module:       AnsibleModule
    :param module:      Reference on module which contain module launch
                        information and status report methods.
    :type account:      Account
    :param account:     Reference on PubNub account model which will be
                        used in case of error to export cached data.
    :type application:  Application
    :param application: Reference on PubNub application model from which
                        reference on keyset should be fetched.

    :rtype:  Keyset
    :return: Reference on initialized and ready to use keyset model.
    """
    params = module.params
    keyset = application.keyset(params["keyset"])
    if keyset is None:
        module.fail_json(
            msg=(
                f"There is no '{params['keyset']}' keyset for '{application.name}' application. "
                "Make sure what correct keyset name has been passed. "
                "If keyset doesn't exist you can create it on admin.pubnub.com."
            ),
            changed=account.changed,
            module_cache=dict(account),
        )

    return keyset


def pubnub_block(module, account, keyset):
    """Retrieve reference on target keyset from application model.

    NOTE: In case if there is no block with specified name and module
    configured to start/stop it, module will exit with error.
    :type module:   AnsibleModule
    :param module:  Reference on module which contain module launch
                    information and status report methods.
    :type account:  Account
    :param account: Reference on PubNub account model which will be used in
                    case of error to export cached data.
    :type keyset:   Keyset
    :param keyset:  Reference on keyset model from which reference on block
                    should be fetched.

    :rtype:  Block
    :return: Reference on initialized and ready to use keyset model.
    """
    block = None
    params = module.params
    try:
        block = keyset.block(params["name"])
    except (exceptions.KeysetError, exceptions.GeneralPubNubError) as exc:
        exc_msg = _failure_title_from_exception(exc)
        exc_descr = exc.message if hasattr(exc, "message") else exc.args[0]
        module.fail_json(msg=exc_msg, description=exc_descr, changed=account.changed, module_cache=dict(account))

    # Report error because block doesn't exists and at the same time
    # requested to start/stop.
    if block is None and params["state"] in ["started", "stopped"]:
        block_name = params.get("name")
        module.fail_json(
            msg=f"'{block_name}' block doesn't exists.", changed=account.changed, module_cache=dict(account)
        )

    if block is None and params["state"] == "present":
        block = Block(name=params.get("name"), description=params.get("description"))
        keyset.add_block(block)

    if block:
        # Update block information if required.
        if params.get("changes") and params["changes"].get("name"):
            block.name = params["changes"]["name"]
        if params.get("description"):
            block.description = params.get("description")

    return block


def pubnub_event_handler(block, data):
    """Retrieve reference on target event handler from application model.

    :type block:  Block
    :param block: Reference on block model from which reference on event
                  handlers should be fetched.
    :type data:   dict
    :param data:  Reference on dictionary which contain information about
                  event handler and whether it should be created or not.

    :rtype:  EventHandler
    :return: Reference on initialized and ready to use event handler model.
             'None' will be returned in case if there is no handler with
             specified name and no request to create it.
    """
    event_handler = block.event_handler(data["name"])

    # Prepare payload for event handler update.
    changed_name = data.pop("changes").get("name") if "changes" in data else None
    name = data.get("name") or changed_name
    channels = data.get("channels")
    event = data.get("event")
    code = _content_of_file_at_path(data.get("src"))
    state = data.get("state") or "present"

    # Create event handler if required.
    if event_handler is None and state == "present":
        event_handler = EventHandler(name=name, channels=channels, event=event, code=code)
        block.add_event_handler(event_handler)

    # Update event handler if required.
    if event_handler is not None and state == "present":
        if name is not None:
            event_handler.name = name
        if channels is not None:
            event_handler.channels = channels
        if event is not None:
            event_handler.event = event
        if code is not None:
            event_handler.code = code

    return event_handler


def _failure_title_from_exception(exception):
    """Compose human-readable title for module error title.

    Title will be based on status codes if they has been provided.
    :type exception:  exceptions.GeneralPubNubError
    :param exception: Reference on exception for which title should be
                      composed.

    :rtype:  str
    :return: Reference on error tile which should be shown on module
             failure.
    """
    title = "General REST API access error."
    if exception.code == exceptions.PN_AUTHORIZATION_MISSING_CREDENTIALS:
        title = "Authorization error: missing credentials."
    elif exception.code == exceptions.PN_AUTHORIZATION_WRONG_CREDENTIALS:
        title = "Authorization error: wrong credentials."
    elif exception.code == exceptions.PN_USER_INSUFFICIENT_RIGHTS:
        title = "API access error: insufficient access rights."
    elif exception.code == exceptions.PN_API_ACCESS_TOKEN_EXPIRED:
        title = "API access error: time token expired."
    elif exception.code == exceptions.PN_KEYSET_BLOCK_EXISTS:
        title = "Block create did fail: block with same name already exists)."
    elif exception.code == exceptions.PN_KEYSET_BLOCKS_FETCH_DID_FAIL:
        title = "Unable fetch list of blocks for keyset."
    elif exception.code == exceptions.PN_BLOCK_CREATE_DID_FAIL:
        title = "Block creation did fail."
    elif exception.code == exceptions.PN_BLOCK_UPDATE_DID_FAIL:
        title = "Block update did fail."
    elif exception.code == exceptions.PN_BLOCK_REMOVE_DID_FAIL:
        title = "Block removal did fail."
    elif exception.code == exceptions.PN_BLOCK_START_STOP_DID_FAIL:
        title = "Block start/stop did fail."
    elif exception.code == exceptions.PN_EVENT_HANDLER_MISSING_FIELDS:
        title = "Event handler creation did fail: missing fields."
    elif exception.code == exceptions.PN_BLOCK_EVENT_HANDLER_EXISTS:
        title = "Event handler creation did fail: missing fields."
    elif exception.code == exceptions.PN_EVENT_HANDLER_CREATE_DID_FAIL:
        title = "Event handler creation did fail."
    elif exception.code == exceptions.PN_EVENT_HANDLER_UPDATE_DID_FAIL:
        title = "Event handler update did fail."
    elif exception.code == exceptions.PN_EVENT_HANDLER_REMOVE_DID_FAIL:
        title = "Event handler removal did fail."

    return title


def _content_of_file_at_path(path):
    """Read file content.

    Try read content of file at specified path.
    :type path:  str
    :param path: Full path to location of file which should be read'ed.
    :rtype:  content
    :return: File content or 'None'
    """
    content = None
    if path and os.path.exists(path):
        with open(path) as opened_file:
            b_content = opened_file.read()
            try:
                content = to_text(b_content, errors="surrogate_or_strict")
            except UnicodeError:
                pass

    return content


def main():
    fields = dict(
        email=dict(default="", type="str"),
        password=dict(default="", type="str", no_log=True),
        account=dict(default="", type="str"),
        application=dict(required=True, type="str"),
        keyset=dict(required=True, type="str", no_log=False),
        state=dict(default="present", type="str", choices=["started", "stopped", "present", "absent"]),
        name=dict(required=True, type="str"),
        description=dict(type="str"),
        event_handlers=dict(default=list(), type="list", elements="dict"),
        changes=dict(default=dict(), type="dict"),
        cache=dict(default=dict(), type="dict"),
        validate_certs=dict(default=True, type="bool"),
    )
    module = AnsibleModule(argument_spec=fields, supports_check_mode=True)

    if not HAS_PUBNUB_BLOCKS_CLIENT:
        module.fail_json(msg="pubnub_blocks_client required for this module.")

    params = module.params

    # Authorize user.
    user = pubnub_user(module)
    # Initialize PubNub account instance.
    account = pubnub_account(module, user=user)
    # Try fetch application with which module should work.
    application = pubnub_application(module, account=account)
    # Try fetch keyset with which module should work.
    keyset = pubnub_keyset(module, account=account, application=application)
    # Try fetch block with which module should work.
    block = pubnub_block(module, account=account, keyset=keyset)
    is_new_block = block is not None and block.uid == -1

    # Check whether block should be removed or not.
    if block is not None and params["state"] == "absent":
        keyset.remove_block(block)
        block = None

    if block is not None:
        # Update block information if required.
        if params.get("changes") and params["changes"].get("name"):
            block.name = params["changes"]["name"]

        # Process event changes to event handlers.
        for event_handler_data in params.get("event_handlers") or list():
            state = event_handler_data.get("state") or "present"
            event_handler = pubnub_event_handler(data=event_handler_data, block=block)
            if state == "absent" and event_handler:
                block.delete_event_handler(event_handler)

    # Update block operation state if required.
    if block and not is_new_block:
        if params["state"] == "started":
            block.start()
        elif params["state"] == "stopped":
            block.stop()

    # Save current account state.
    if not module.check_mode:
        try:
            account.save()
        except (
            exceptions.APIAccessError,
            exceptions.KeysetError,
            exceptions.BlockError,
            exceptions.EventHandlerError,
            exceptions.GeneralPubNubError,
        ) as exc:
            module_cache = dict(account)
            module_cache.update(dict(pnm_user=dict(user)))
            exc_msg = _failure_title_from_exception(exc)
            exc_descr = exc.message if hasattr(exc, "message") else exc.args[0]
            module.fail_json(msg=exc_msg, description=exc_descr, changed=account.changed, module_cache=module_cache)

    # Report module execution results.
    module_cache = dict(account)
    module_cache.update(dict(pnm_user=dict(user)))
    changed_will_change = account.changed or account.will_change
    module.exit_json(changed=changed_will_change, module_cache=module_cache)


if __name__ == "__main__":
    main()
