#!/usr/bin/python
#
# Copyright (c) 2019 Piotr Wojciechowski <piotr@it-playground.pl>
# 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: docker_swarm_info

short_description: Retrieves facts about Docker Swarm cluster

description:
  - Retrieves facts about a Docker Swarm.
  - Returns lists of swarm objects names for the services - nodes, services, tasks.
  - The output differs depending on API version available on docker host.
  - Must be run on Swarm Manager node; otherwise module fails with error message. It does return boolean flags in on both
    error and success which indicate whether the docker daemon can be communicated with, whether it is in Swarm mode, and
    whether it is a Swarm Manager node.
author:
  - Piotr Wojciechowski (@WojciechowskiPiotr)

extends_documentation_fragment:
  - community.docker._docker
  - community.docker._docker.docker_py_2_documentation
  - community.docker._attributes
  - community.docker._attributes.actiongroup_docker
  - community.docker._attributes.info_module
  - community.docker._attributes.idempotent_not_modify_state

options:
  nodes:
    description:
      - Whether to list swarm nodes.
    type: bool
    default: false
  nodes_filters:
    description:
      - A dictionary of filter values used for selecting nodes to list.
      - 'For example, C(name: mynode).'
      - See L(the docker documentation,https://docs.docker.com/engine/reference/commandline/node_ls/#filtering) for more information
        on possible filters.
    type: dict
  services:
    description:
      - Whether to list swarm services.
    type: bool
    default: false
  services_filters:
    description:
      - A dictionary of filter values used for selecting services to list.
      - 'For example, C(name: myservice).'
      - See L(the docker documentation,https://docs.docker.com/engine/reference/commandline/service_ls/#filtering) for more
        information on possible filters.
    type: dict
  tasks:
    description:
      - Whether to list containers.
    type: bool
    default: false
  tasks_filters:
    description:
      - A dictionary of filter values used for selecting tasks to list.
      - 'For example, C(node: mynode-1).'
      - See L(the docker documentation,https://docs.docker.com/engine/reference/commandline/service_ps/#filtering) for more
        information on possible filters.
    type: dict
  unlock_key:
    description:
      - Whether to retrieve the swarm unlock key.
    type: bool
    default: false
  verbose_output:
    description:
      - When set to V(true) and O(nodes), O(services), or O(tasks) is set to V(true), then the module output will contain
        verbose information about objects matching the full output of API method.
      - For details see the documentation of your version of Docker API at U(https://docs.docker.com/engine/api/).
      - The verbose output in this module contains only subset of information returned by this info module for each type of
        the objects.
    type: bool
    default: false

requirements:
  - "L(Docker SDK for Python,https://docker-py.readthedocs.io/en/stable/) >= 2.0.0"
  - "Docker API >= 1.25"
"""

EXAMPLES = r"""
---
- name: Get info on Docker Swarm
  community.docker.docker_swarm_info:
  ignore_errors: true
  register: result

- name: Inform about basic flags
  ansible.builtin.debug:
    msg: |
      Was able to talk to docker daemon: {{ result.can_talk_to_docker }}
      Docker in Swarm mode: {{ result.docker_swarm_active }}
      This is a Manager node: {{ result.docker_swarm_manager }}

- name: Get info on Docker Swarm and list of registered nodes
  community.docker.docker_swarm_info:
    nodes: true
  register: result

- name: Get info on Docker Swarm and extended list of registered nodes
  community.docker.docker_swarm_info:
    nodes: true
    verbose_output: true
  register: result

- name: Get info on Docker Swarm and filtered list of registered nodes
  community.docker.docker_swarm_info:
    nodes: true
    nodes_filters:
      name: mynode
  register: result

- name: Show swarm facts
  ansible.builtin.debug:
    var: result.swarm_facts

- name: Get the swarm unlock key
  community.docker.docker_swarm_info:
    unlock_key: true
  register: result

- name: Print swarm unlock key
  ansible.builtin.debug:
    var: result.swarm_unlock_key
"""

RETURN = r"""
can_talk_to_docker:
  description:
    - Will be V(true) if the module can talk to the docker daemon.
  returned: both on success and on error
  type: bool
docker_swarm_active:
  description:
    - Will be V(true) if the module can talk to the docker daemon, and the docker daemon is in Swarm mode.
  returned: both on success and on error
  type: bool
docker_swarm_manager:
  description:
    - Will be V(true) if the module can talk to the docker daemon, the docker daemon is in Swarm mode, and the current node
      is a manager node.
    - Only if this one is V(true), the module will not fail.
  returned: both on success and on error
  type: bool
swarm_facts:
  description:
    - Facts representing the basic state of the docker Swarm cluster.
    - Contains tokens to connect to the Swarm.
  returned: always
  type: dict
swarm_unlock_key:
  description:
    - Contains the key needed to unlock the swarm.
  returned: When O(unlock_key=true).
  type: str
nodes:
  description:
    - List of dict objects containing the basic information about each volume. Keys matches the C(docker node ls) output unless
      O(verbose_output=true). See description for O(verbose_output).
  returned: When O(nodes=true)
  type: list
  elements: dict
services:
  description:
    - List of dict objects containing the basic information about each volume. Keys matches the C(docker service ls) output
      unless O(verbose_output=true). See description for O(verbose_output).
  returned: When O(services=true)
  type: list
  elements: dict
tasks:
  description:
    - List of dict objects containing the basic information about each volume. Keys matches the C(docker service ps) output
      unless O(verbose_output=true). See description for O(verbose_output).
  returned: When O(tasks=true)
  type: list
  elements: dict
"""

import traceback
import typing as t

try:
    from docker.errors import APIError, DockerException
except ImportError:
    # missing Docker SDK for Python handled in ansible.module_utils.docker_common
    pass

from ansible_collections.community.docker.plugins.module_utils._common import (
    RequestException,
)
from ansible_collections.community.docker.plugins.module_utils._swarm import (
    AnsibleDockerSwarmClient,
)
from ansible_collections.community.docker.plugins.module_utils._util import (
    DockerBaseClass,
    clean_dict_booleans_for_docker_api,
)


class DockerSwarmManager(DockerBaseClass):
    def __init__(
        self, client: AnsibleDockerSwarmClient, results: dict[str, t.Any]
    ) -> None:
        super().__init__()

        self.client = client
        self.results = results
        self.verbose_output = self.client.module.params["verbose_output"]

        listed_objects: list[t.Literal["nodes", "tasks", "services"]] = [
            "tasks",
            "services",
            "nodes",
        ]

        self.client.fail_task_if_not_swarm_manager()

        self.results["swarm_facts"] = self.get_docker_swarm_facts()

        for docker_object in listed_objects:
            if self.client.module.params[docker_object]:
                returned_name = docker_object
                filter_name = docker_object + "_filters"
                filters = clean_dict_booleans_for_docker_api(
                    client.module.params.get(filter_name)
                )
                self.results[returned_name] = self.get_docker_items_list(
                    docker_object, filters
                )
        if self.client.module.params["unlock_key"]:
            self.results["swarm_unlock_key"] = self.get_docker_swarm_unlock_key()

    def get_docker_swarm_facts(self) -> dict[str, t.Any]:
        try:
            return self.client.inspect_swarm()
        except APIError as exc:
            self.client.fail(f"Error inspecting docker swarm: {exc}")

    def get_docker_items_list(
        self,
        docker_object: t.Literal["nodes", "tasks", "services"],
        filters: dict[str, str],
    ) -> list[dict[str, t.Any]]:
        items_list: list[dict[str, t.Any]] = []

        try:
            if docker_object == "nodes":
                items = self.client.nodes(filters=filters)
            elif docker_object == "tasks":
                items = self.client.tasks(filters=filters)
            elif docker_object == "services":
                items = self.client.services(filters=filters)
            else:
                raise ValueError(f"Invalid docker_object {docker_object}")
        except APIError as exc:
            self.client.fail(
                f"Error inspecting docker swarm for object '{docker_object}': {exc}"
            )

        if self.verbose_output:
            return items

        for item in items:
            item_record = {}

            if docker_object == "nodes":
                item_record = self.get_essential_facts_nodes(item)
            elif docker_object == "tasks":
                item_record = self.get_essential_facts_tasks(item)
            elif docker_object == "services":
                item_record = self.get_essential_facts_services(item)
                if item_record.get("Mode") == "Global":
                    item_record["Replicas"] = len(items)
            items_list.append(item_record)

        return items_list

    @staticmethod
    def get_essential_facts_nodes(item: dict[str, t.Any]) -> dict[str, t.Any]:
        object_essentials = {}

        object_essentials["ID"] = item.get("ID")
        object_essentials["Hostname"] = item["Description"]["Hostname"]
        object_essentials["Status"] = item["Status"]["State"]
        object_essentials["Availability"] = item["Spec"]["Availability"]
        if "ManagerStatus" in item:
            object_essentials["ManagerStatus"] = item["ManagerStatus"]["Reachability"]
            if (
                "Leader" in item["ManagerStatus"]
                and item["ManagerStatus"]["Leader"] is True
            ):
                object_essentials["ManagerStatus"] = "Leader"
        else:
            object_essentials["ManagerStatus"] = None
        object_essentials["EngineVersion"] = item["Description"]["Engine"][
            "EngineVersion"
        ]

        return object_essentials

    def get_essential_facts_tasks(self, item: dict[str, t.Any]) -> dict[str, t.Any]:
        object_essentials = {}

        object_essentials["ID"] = item["ID"]
        # Returning container ID to not trigger another connection to host
        # Container ID is sufficient to get extended info in other tasks
        object_essentials["ContainerID"] = item["Status"]["ContainerStatus"][
            "ContainerID"
        ]
        object_essentials["Image"] = item["Spec"]["ContainerSpec"]["Image"]
        object_essentials["Node"] = self.client.get_node_name_by_id(item["NodeID"])
        object_essentials["DesiredState"] = item["DesiredState"]
        object_essentials["CurrentState"] = item["Status"]["State"]
        if "Err" in item["Status"]:
            object_essentials["Error"] = item["Status"]["Err"]
        else:
            object_essentials["Error"] = None

        return object_essentials

    @staticmethod
    def get_essential_facts_services(item: dict[str, t.Any]) -> dict[str, t.Any]:
        object_essentials = {}

        object_essentials["ID"] = item["ID"]
        object_essentials["Name"] = item["Spec"]["Name"]
        if "Replicated" in item["Spec"]["Mode"]:
            object_essentials["Mode"] = "Replicated"
            object_essentials["Replicas"] = item["Spec"]["Mode"]["Replicated"][
                "Replicas"
            ]
        elif "Global" in item["Spec"]["Mode"]:
            object_essentials["Mode"] = "Global"
            # Number of replicas have to be updated in calling method or may be left as None
            object_essentials["Replicas"] = None
        object_essentials["Image"] = item["Spec"]["TaskTemplate"]["ContainerSpec"][
            "Image"
        ]
        if item["Spec"].get("EndpointSpec") and "Ports" in item["Spec"]["EndpointSpec"]:
            object_essentials["Ports"] = item["Spec"]["EndpointSpec"]["Ports"]
        else:
            object_essentials["Ports"] = []

        return object_essentials

    def get_docker_swarm_unlock_key(self) -> str | None:
        unlock_key = self.client.get_unlock_key() or {}
        return unlock_key.get("UnlockKey") or None


def main() -> None:
    argument_spec = {
        "nodes": {"type": "bool", "default": False},
        "nodes_filters": {"type": "dict"},
        "tasks": {"type": "bool", "default": False},
        "tasks_filters": {"type": "dict"},
        "services": {"type": "bool", "default": False},
        "services_filters": {"type": "dict"},
        "unlock_key": {"type": "bool", "default": False},
        "verbose_output": {"type": "bool", "default": False},
    }
    option_minimal_versions = {
        "unlock_key": {"docker_py_version": "2.7.0"},
    }

    client = AnsibleDockerSwarmClient(
        argument_spec=argument_spec,
        supports_check_mode=True,
        min_docker_version="2.0.0",
        option_minimal_versions=option_minimal_versions,
        fail_results={
            "can_talk_to_docker": False,
            "docker_swarm_active": False,
            "docker_swarm_manager": False,
        },
    )
    client.fail_results["can_talk_to_docker"] = True
    client.fail_results["docker_swarm_active"] = client.check_if_swarm_node()
    client.fail_results["docker_swarm_manager"] = client.check_if_swarm_manager()

    try:
        results = {
            "changed": False,
        }

        DockerSwarmManager(client, results)
        results.update(client.fail_results)
        client.module.exit_json(**results)
    except DockerException as e:
        client.fail(
            f"An unexpected Docker error occurred: {e}",
            exception=traceback.format_exc(),
        )
    except RequestException as e:
        client.fail(
            f"An unexpected requests error occurred when Docker SDK for Python tried to talk to the docker daemon: {e}",
            exception=traceback.format_exc(),
        )


if __name__ == "__main__":
    main()
