# Copyright (c) 2022 Felix Fontein <felix@fontein.de>
# Copyright 2016 Red Hat | Ansible
# 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

# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!

from __future__ import annotations

import re
import typing as t
from time import sleep

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

from ansible_collections.community.docker.plugins.module_utils._api.utils.utils import (
    parse_repository_tag,
)
from ansible_collections.community.docker.plugins.module_utils._module_container.base import (
    Client,
)
from ansible_collections.community.docker.plugins.module_utils._util import (
    DifferenceTracker,
    DockerBaseClass,
    compare_generic,
    is_image_name_id,
    normalize_ip_address,
    sanitize_result,
)

if t.TYPE_CHECKING:
    from collections.abc import Sequence

    from ansible.module_utils.basic import AnsibleModule

    from .base import Engine, EngineDriver, Option, OptionGroup


class Container(DockerBaseClass):
    def __init__(
        self, container: dict[str, t.Any] | None, engine_driver: EngineDriver
    ) -> None:
        super().__init__()
        self.raw = container
        self.id: str | None = None
        self.image: str | None = None
        self.image_name: str | None = None
        self.container = container
        self.engine_driver = engine_driver
        if container:
            self.id = engine_driver.get_container_id(container)
            self.image = engine_driver.get_image_from_container(container)
            self.image_name = engine_driver.get_image_name_from_container(container)
        self.log(self.container, pretty_print=True)

    @property
    def exists(self) -> bool:
        return bool(self.container)

    @property
    def removing(self) -> bool:
        return (
            self.engine_driver.is_container_removing(self.container)
            if self.container
            else False
        )

    @property
    def running(self) -> bool:
        return (
            self.engine_driver.is_container_running(self.container)
            if self.container
            else False
        )

    @property
    def paused(self) -> bool:
        return (
            self.engine_driver.is_container_paused(self.container)
            if self.container
            else False
        )


class ContainerManager(DockerBaseClass, t.Generic[Client]):
    def __init__(
        self,
        module: AnsibleModule,
        engine_driver: EngineDriver,
        client: Client,
        active_options: list[OptionGroup],
    ) -> None:
        super().__init__()
        self.module = module
        self.engine_driver = engine_driver
        self.client = client
        self.options = active_options
        self.all_options = self._collect_all_options(active_options)
        self.check_mode = self.module.check_mode
        self.param_cleanup: bool = self.module.params["cleanup"]
        self.param_container_default_behavior: t.Literal[
            "compatibility", "no_defaults"
        ] = self.module.params["container_default_behavior"]
        self.param_default_host_ip: str | None = self.module.params["default_host_ip"]
        self.param_debug: bool = self.module.params["debug"]
        self.param_force_kill: bool = self.module.params["force_kill"]
        self.param_image: str | None = self.module.params["image"]
        self.param_image_comparison: t.Literal["desired-image", "current-image"] = (
            self.module.params["image_comparison"]
        )
        self.param_image_label_mismatch: t.Literal["ignore", "fail"] = (
            self.module.params["image_label_mismatch"]
        )
        self.param_image_name_mismatch: t.Literal["ignore", "recreate"] = (
            self.module.params["image_name_mismatch"]
        )
        self.param_keep_volumes: bool = self.module.params["keep_volumes"]
        self.param_kill_signal: str | None = self.module.params["kill_signal"]
        self.param_name: str = self.module.params["name"]
        self.param_networks_cli_compatible: bool = self.module.params[
            "networks_cli_compatible"
        ]
        self.param_output_logs: bool = self.module.params["output_logs"]
        self.param_paused: bool | None = self.module.params["paused"]
        param_pull: t.Literal["never", "missing", "always", True, False] = (
            self.module.params["pull"]
        )
        if param_pull is True:
            param_pull = "always"
        if param_pull is False:
            param_pull = "missing"
        self.param_pull: t.Literal["never", "missing", "always"] = param_pull
        self.param_pull_check_mode_behavior: t.Literal[
            "image_not_present", "always"
        ] = self.module.params["pull_check_mode_behavior"]
        self.param_recreate: bool = self.module.params["recreate"]
        self.param_removal_wait_timeout: int | float | None = self.module.params[
            "removal_wait_timeout"
        ]
        self.param_healthy_wait_timeout: int | float | None = self.module.params[
            "healthy_wait_timeout"
        ]
        if (
            self.param_healthy_wait_timeout is not None
            and self.param_healthy_wait_timeout <= 0
        ):
            self.param_healthy_wait_timeout = None
        self.param_restart: bool = self.module.params["restart"]
        self.param_state: t.Literal[
            "absent", "present", "healthy", "started", "stopped"
        ] = self.module.params["state"]
        self._parse_comparisons()
        self._update_params()
        self.results = {"changed": False, "actions": []}
        self.diff: dict[str, t.Any] = {}
        self.diff_tracker = DifferenceTracker()
        self.facts: dict[str, t.Any] | None = {}
        if self.param_default_host_ip:
            valid_ip = False
            if re.match(
                r"^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$", self.param_default_host_ip
            ):
                valid_ip = True
            if re.match(r"^\[[0-9a-fA-F:]+\]$", self.param_default_host_ip):
                valid_ip = True
            if re.match(r"^[0-9a-fA-F:]+$", self.param_default_host_ip):
                self.param_default_host_ip = f"[{self.param_default_host_ip}]"
                valid_ip = True
            if not valid_ip:
                self.fail(
                    "The value of default_host_ip must be an empty string, an IPv4 address, "
                    f'or an IPv6 address. Got "{self.param_default_host_ip}" instead.'
                )
        self.parameters: list[tuple[OptionGroup, dict[str, t.Any]]] | None = None

    def _add_action(self, action: dict[str, t.Any]) -> None:
        actions: list[dict[str, t.Any]] = self.results["actions"]  # type: ignore
        actions.append(action)

    def _collect_all_options(
        self, active_options: list[OptionGroup]
    ) -> dict[str, Option]:
        all_options = {}
        for options in active_options:
            for option in options.options:
                all_options[option.name] = option
        return all_options

    def _collect_all_module_params(self) -> set[str]:
        all_module_options = set()
        for option, data in self.module.argument_spec.items():
            all_module_options.add(option)
            if "aliases" in data:
                for alias in data["aliases"]:
                    all_module_options.add(alias)
        return all_module_options

    def _parse_comparisons(self) -> None:
        # Keep track of all module params and all option aliases
        all_module_options = self._collect_all_module_params()
        comp_aliases = {}
        for option_name, option in self.all_options.items():
            if option.not_an_ansible_option:
                continue
            comp_aliases[option_name] = option_name
            for alias in option.ansible_aliases:
                comp_aliases[alias] = option_name
        # Process comparisons specified by user
        comparisons: dict[str, t.Any] | None = self.module.params.get("comparisons")
        if comparisons:
            # If '*' appears in comparisons, process it first
            if "*" in comparisons:
                value = comparisons["*"]
                if value not in ("strict", "ignore"):
                    self.fail(
                        "The wildcard can only be used with comparison modes 'strict' and 'ignore'!"
                    )
                for option in self.all_options.values():
                    # `networks` is special: only update if
                    # some value is actually specified
                    if (
                        option.name == "networks"
                        and self.module.params["networks"] is None
                    ):
                        continue
                    option.comparison = value
            # Now process all other comparisons.
            comp_aliases_used: dict[str, str] = {}
            for key, value in comparisons.items():
                if key == "*":
                    continue
                # Find main key
                key_main = comp_aliases.get(key)
                if key_main is None:
                    if key_main in all_module_options:
                        self.fail(
                            f"The module option '{key}' cannot be specified in the comparisons dict, "
                            "since it does not correspond to container's state!"
                        )
                    if (
                        key not in self.all_options
                        or self.all_options[key].not_an_ansible_option
                    ):
                        self.fail(f"Unknown module option '{key}' in comparisons dict!")
                    key_main = key
                if key_main in comp_aliases_used:
                    self.fail(
                        f"Both '{key}' and '{comp_aliases_used[key_main]}' (aliases of {key_main}) are specified in comparisons dict!"
                    )
                comp_aliases_used[key_main] = key
                # Check value and update accordingly
                if value in ("strict", "ignore"):
                    self.all_options[key_main].comparison = value
                elif value == "allow_more_present":
                    if self.all_options[key_main].comparison_type == "value":
                        self.fail(
                            f"Option '{key}' is a value and not a set/list/dict, so its comparison cannot be {value}"
                        )
                    self.all_options[key_main].comparison = value
                else:
                    self.fail(f"Unknown comparison mode '{value}'!")
        # Copy values
        for option in self.all_options.values():
            if option.copy_comparison_from is not None:
                option.comparison = self.all_options[
                    option.copy_comparison_from
                ].comparison

    def _update_params(self) -> None:
        if (
            self.param_networks_cli_compatible is True
            and self.module.params["networks"]
            and self.module.params["network_mode"] is None
        ):
            # Same behavior as Docker CLI: if networks are specified, use the name of the first network as the value for network_mode
            # (assuming no explicit value is specified for network_mode)
            self.module.params["network_mode"] = self.module.params["networks"][0][
                "name"
            ]
        if self.param_container_default_behavior == "compatibility":
            old_default_values = {
                "auto_remove": False,
                "detach": True,
                "init": False,
                "interactive": False,
                "memory": "0",
                "paused": False,
                "privileged": False,
                "read_only": False,
                "tty": False,
            }
            for param, value in old_default_values.items():
                if self.module.params[param] is None:
                    self.module.params[param] = value

    def fail(self, *args: str, **kwargs: t.Any) -> t.NoReturn:
        # mypy doesn't know that Client has fail() method
        raise self.client.fail(*args, **kwargs)  # type: ignore

    def run(self) -> None:
        if self.param_state in ("stopped", "started", "present", "healthy"):
            # mypy doesn't get that self.param_state has only one of the above values
            self.present(self.param_state)  # type: ignore
        elif self.param_state == "absent":
            self.absent()

        if not self.check_mode and not self.param_debug:
            self.results.pop("actions")

        if self.module._diff or self.param_debug:
            self.diff["before"], self.diff["after"] = (
                self.diff_tracker.get_before_after()
            )
            self.results["diff"] = self.diff

        if self.facts:
            self.results["container"] = self.facts

    def wait_for_state(
        self,
        container_id: str,
        *,
        complete_states: Sequence[str | None] | None = None,
        wait_states: Sequence[str | None] | None = None,
        accept_removal: bool = False,
        max_wait: int | float | None = None,
        health_state: bool = False,
    ) -> dict[str, t.Any] | None:
        delay = 1.0
        total_wait = 0.0
        while True:
            # Inspect container
            result = self.engine_driver.inspect_container_by_id(
                self.client, container_id
            )
            if result is None:
                if accept_removal:
                    return result
                msg = f'Encontered vanished container while waiting for container "{container_id}"'
                self.fail(msg)
            # Check container state
            state_info = result.get("State") or {}
            if health_state:
                state_info = state_info.get("Health") or {}
            state = state_info.get("Status")
            if complete_states is not None and state in complete_states:
                return result
            if wait_states is not None and state not in wait_states:
                msg = f'Encontered unexpected state "{state}" while waiting for container "{container_id}"'
                self.fail(msg, container=result)
            # Wait
            if max_wait is not None:
                if total_wait > max_wait or delay < 1e-4:
                    msg = f'Timeout of {max_wait} seconds exceeded while waiting for container "{container_id}"'
                    self.fail(msg, container=result)
                if total_wait + delay > max_wait:
                    delay = max_wait - total_wait
            sleep(delay)
            total_wait += delay
            # Exponential backoff, but never wait longer than 10 seconds
            # (1.1**24 < 10, 1.1**25 > 10, so it will take 25 iterations
            #  until the maximal 10 seconds delay is reached. By then, the
            #  code will have slept for ~1.5 minutes.)
            delay = min(delay * 1.1, 10)

    def _collect_params(
        self, active_options: list[OptionGroup]
    ) -> list[tuple[OptionGroup, dict[str, t.Any]]]:
        parameters = []
        for options in active_options:
            values = {}
            engine = options.get_engine(self.engine_driver.name)
            for option in options.all_options:
                if (
                    not option.not_an_ansible_option
                    and self.module.params[option.name] is not None
                ):
                    values[option.name] = self.module.params[option.name]
            values = options.preprocess(self.module, values)
            engine.preprocess_value(
                self.module,
                self.client,
                self.engine_driver.get_api_version(self.client),
                options.options,
                values,
            )
            parameters.append((options, values))
        return parameters

    def _needs_container_image(self) -> bool:
        assert self.parameters is not None
        for options, values in self.parameters:
            engine = options.get_engine(self.engine_driver.name)
            if engine.needs_container_image(values):
                return True
        return False

    def _needs_host_info(self) -> bool:
        assert self.parameters is not None
        for options, values in self.parameters:
            engine = options.get_engine(self.engine_driver.name)
            if engine.needs_host_info(values):
                return True
        return False

    def present(
        self, state: t.Literal["stopped", "started", "present", "healthy"]
    ) -> None:
        self.parameters = self._collect_params(self.options)
        container = self._get_container(self.param_name)
        was_running = container.running
        was_paused = container.paused
        container_created = False

        # If the image parameter was passed then we need to deal with the image
        # version comparison. Otherwise we handle this depending on whether
        # the container already runs or not; in the former case, in case the
        # container needs to be restarted, we use the existing container's
        # image ID.
        image, container_image, comparison_image = self._get_image(
            container, needs_container_image=self._needs_container_image()
        )
        self.log(image, pretty_print=True)
        host_info = (
            self.engine_driver.get_host_info(self.client)
            if self._needs_host_info()
            else None
        )
        if not container.exists or container.removing:
            # New container
            if container.removing:
                self.log("Found container in removal phase")
            else:
                self.log("No container found")
            if not self.param_image:
                self.fail("Cannot create container when image is not specified!")
            self.diff_tracker.add("exists", parameter=True, active=False)
            if container.removing and not self.check_mode:
                # Wait for container to be removed before trying to create it
                assert container.id is not None
                self.wait_for_state(
                    container.id,
                    wait_states=["removing"],
                    accept_removal=True,
                    max_wait=self.param_removal_wait_timeout,
                )
            new_container = self.container_create(self.param_image)
            if new_container:
                container = new_container
            container_created = True
        else:
            # Existing container
            assert container.id is not None
            different, differences = self.has_different_configuration(
                container, container_image, comparison_image, host_info
            )
            image_different = False
            if self.all_options["image"].comparison == "strict":
                image_different = self._image_is_different(image, container)
                if (
                    self.param_image_name_mismatch == "recreate"
                    and self.param_image is not None
                    and self.param_image != container.image_name
                ):
                    different = True
                    self.diff_tracker.add(
                        "image_name",
                        parameter=self.param_image,
                        active=container.image_name,
                    )
            if image_different or different or self.param_recreate:
                self.diff_tracker.merge(differences)
                self.diff["differences"] = (
                    differences.get_legacy_docker_container_diffs()
                )
                if image_different:
                    self.diff["image_different"] = True
                self.log("differences")
                self.log(
                    differences.get_legacy_docker_container_diffs(), pretty_print=True
                )
                image_to_use = self.param_image
                if not image_to_use and container and container.image:
                    image_to_use = container.image
                if not image_to_use:
                    self.fail(
                        "Cannot recreate container when image is not specified or cannot be extracted from current container!"
                    )
                if container.running:
                    self.container_stop(container.id)
                self.container_remove(container.id)
                if not self.check_mode:
                    self.wait_for_state(
                        container.id,
                        wait_states=["removing"],
                        accept_removal=True,
                        max_wait=self.param_removal_wait_timeout,
                    )
                new_container = self.container_create(image_to_use)
                if new_container:
                    container = new_container
                container_created = True
                comparison_image = image

        if container and container.exists:
            container = self.update_limits(
                container, container_image, comparison_image, host_info
            )
            container = self.update_networks(container, container_created)

            if state in ("started", "healthy") and not container.running:
                self.diff_tracker.add("running", parameter=True, active=was_running)
                assert container.id is not None
                container = self.container_start(container.id)
            elif state in ("started", "healthy") and self.param_restart:
                self.diff_tracker.add("running", parameter=True, active=was_running)
                self.diff_tracker.add("restarted", parameter=True, active=False)
                assert container.id is not None
                container = self.container_restart(container.id)
            elif state == "stopped" and container.running:
                self.diff_tracker.add("running", parameter=False, active=was_running)
                assert container.id is not None
                self.container_stop(container.id)
                container = self._get_container(container.id)

            if (
                state in ("started", "healthy")
                and self.param_paused is not None
                and container.paused != self.param_paused
            ):
                self.diff_tracker.add(
                    "paused", parameter=self.param_paused, active=was_paused
                )
                if not self.check_mode:
                    assert container.id is not None
                    try:
                        if self.param_paused:
                            self.engine_driver.pause_container(
                                self.client, container.id
                            )
                        else:
                            self.engine_driver.unpause_container(
                                self.client, container.id
                            )
                    except Exception as exc:  # pylint: disable=broad-exception-caught
                        self.fail(
                            f"Error {'pausing' if self.param_paused else 'unpausing'} container {container.id}: {exc}"
                        )
                    container = self._get_container(container.id)
                self.results["changed"] = True
                self._add_action({"set_paused": self.param_paused})

        self.facts = container.raw

        if state == "healthy" and not self.check_mode:
            # `None` means that no health check enabled; simply treat this as 'healthy'
            assert container.id is not None
            inspect_result = self.wait_for_state(
                container.id,
                wait_states=["starting", "unhealthy"],
                complete_states=["healthy", None],
                max_wait=self.param_healthy_wait_timeout,
                health_state=True,
            )
            if inspect_result:
                # Return the latest inspection results retrieved
                self.facts = inspect_result

    def absent(self) -> None:
        container = self._get_container(self.param_name)
        if container.exists:
            assert container.id is not None
            if container.running:
                self.diff_tracker.add("running", parameter=False, active=True)
                self.container_stop(container.id)
            self.diff_tracker.add("exists", parameter=False, active=True)
            self.container_remove(container.id)

    def _output_logs(self, msg: str | bytes) -> None:
        self.module.log(msg=msg)

    def _get_container(self, container: str) -> Container:
        """
        Expects container ID or Name. Returns a container object
        """
        container_data = self.engine_driver.inspect_container_by_name(
            self.client, container
        )
        return Container(container_data, self.engine_driver)

    def _get_container_image(
        self, container: Container, fallback: dict[str, t.Any] | None = None
    ) -> dict[str, t.Any] | None:
        if not container.exists or container.removing:
            return fallback
        image = container.image
        assert image is not None
        if is_image_name_id(image):
            image_data = self.engine_driver.inspect_image_by_id(self.client, image)
        else:
            repository, tag = parse_repository_tag(image)
            if not tag:
                tag = "latest"
            image_data = self.engine_driver.inspect_image_by_name(
                self.client, repository, tag
            )
        return image_data or fallback

    def _get_image(
        self, container: Container, needs_container_image: bool = False
    ) -> tuple[
        dict[str, t.Any] | None, dict[str, t.Any] | None, dict[str, t.Any] | None
    ]:
        image_parameter = self.param_image
        get_container_image = needs_container_image or not image_parameter
        container_image = (
            self._get_container_image(container) if get_container_image else None
        )
        if container_image:
            self.log("current image")
            self.log(container_image, pretty_print=True)
        if not image_parameter:
            self.log("No image specified")
            return None, container_image, container_image
        if is_image_name_id(image_parameter):
            image = self.engine_driver.inspect_image_by_id(self.client, image_parameter)
            if image is None:
                self.fail(f"Cannot find image with ID {image_parameter}")
        else:
            repository, tag = parse_repository_tag(image_parameter)
            if not tag:
                tag = "latest"
            image = self.engine_driver.inspect_image_by_name(
                self.client, repository, tag
            )
            if not image and self.param_pull == "never":
                self.fail(
                    f"Cannot find image with name {repository}:{tag}, and pull=never"
                )
            if not image or self.param_pull == "always":
                if not self.check_mode:
                    self.log("Pull the image.")
                    image, already_to_latest = self.engine_driver.pull_image(
                        self.client,
                        repository,
                        tag,
                        image_platform=self.module.params["platform"],
                    )
                    if already_to_latest:
                        self.results["changed"] = False
                        self._add_action(
                            {"pulled_image": f"{repository}:{tag}", "changed": False}
                        )
                    else:
                        self.results["changed"] = True
                        self._add_action(
                            {"pulled_image": f"{repository}:{tag}", "changed": True}
                        )
                elif not image or self.param_pull_check_mode_behavior == "always":
                    # If the image is not there, or pull_check_mode_behavior == 'always', claim we'll
                    # pull. (Implicitly: if the image is there, claim it already was latest unless
                    # pull_check_mode_behavior == 'always'.)
                    self.results["changed"] = True
                    action: dict[str, t.Any] = {"pulled_image": f"{repository}:{tag}"}
                    if not image:
                        action["changed"] = True
                    self._add_action(action)

        self.log("image")
        self.log(image, pretty_print=True)

        comparison_image = image
        if self.param_image_comparison == "current-image":
            if not get_container_image:
                container_image = self._get_container_image(container)
            comparison_image = container_image

        return image, container_image, comparison_image

    def _image_is_different(
        self, image: dict[str, t.Any] | None, container: Container
    ) -> bool:
        if (
            image
            and image.get("Id")
            and container
            and container.image
            and image.get("Id") != container.image
        ):
            self.diff_tracker.add(
                "image", parameter=image.get("Id"), active=container.image
            )
            return True
        return False

    def _compose_create_parameters(self, image: str) -> dict[str, t.Any]:
        params: dict[str, t.Any] = {}
        assert self.parameters is not None
        for options, values in self.parameters:
            engine = options.get_engine(self.engine_driver.name)
            if engine.can_set_value(self.engine_driver.get_api_version(self.client)):
                engine.set_value(
                    self.module,
                    params,
                    self.engine_driver.get_api_version(self.client),
                    options.options,
                    values,
                )
        params["Image"] = image
        return params

    def _record_differences(
        self,
        differences: DifferenceTracker,
        options: OptionGroup,
        param_values: dict[str, t.Any],
        engine: Engine,
        container: Container,
        container_image: dict[str, t.Any] | None,
        image: dict[str, t.Any] | None,
        host_info: dict[str, t.Any] | None,
    ) -> None:
        assert container.raw is not None
        container_values = engine.get_value(
            self.module,
            container.raw,
            self.engine_driver.get_api_version(self.client),
            options.options,
            container_image,
            host_info,
        )
        expected_values = engine.get_expected_values(
            self.module,
            self.client,
            self.engine_driver.get_api_version(self.client),
            options.options,
            image,
            param_values.copy(),
            host_info,
        )
        for option in options.options:
            if option.name in expected_values:
                param_value = expected_values[option.name]
                container_value = container_values.get(option.name)
                match = engine.compare_value(option, param_value, container_value)

                if not match:
                    # No match.
                    if engine.ignore_mismatching_result(
                        self.module,
                        self.client,
                        self.engine_driver.get_api_version(self.client),
                        option,
                        image,
                        container_value,
                        param_value,
                        host_info,
                    ):
                        # Ignore the result
                        continue

                    # Record the differences
                    p = param_value
                    c = container_value
                    if option.comparison_type == "set":
                        # Since the order does not matter, sort so that the diff output is better.
                        if p is not None:
                            p = sorted(p)
                        if c is not None:
                            c = sorted(c)
                    elif option.comparison_type == "set(dict)":
                        # Since the order does not matter, sort so that the diff output is better.
                        if option.name == "expected_mounts":
                            # For selected values, use one entry as key
                            def sort_key_fn(x: dict[str, t.Any]) -> t.Any:
                                return x["target"]

                        else:
                            # We sort the list of dictionaries by using the sorted items of a dict as its key.
                            def sort_key_fn(x: dict[str, t.Any]) -> t.Any:
                                return sorted(
                                    (a, to_text(b, errors="surrogate_or_strict"))
                                    for a, b in x.items()
                                )

                        if p is not None:
                            p = sorted(p, key=sort_key_fn)
                        if c is not None:
                            c = sorted(c, key=sort_key_fn)
                    differences.add(option.name, parameter=p, active=c)

    def has_different_configuration(
        self,
        container: Container,
        container_image: dict[str, t.Any] | None,
        image: dict[str, t.Any] | None,
        host_info: dict[str, t.Any] | None,
    ) -> tuple[bool, DifferenceTracker]:
        differences = DifferenceTracker()
        update_differences = DifferenceTracker()
        assert self.parameters is not None
        for options, param_values in self.parameters:
            engine = options.get_engine(self.engine_driver.name)
            if engine.can_update_value(self.engine_driver.get_api_version(self.client)):
                self._record_differences(
                    update_differences,
                    options,
                    param_values,
                    engine,
                    container,
                    container_image,
                    image,
                    host_info,
                )
            else:
                self._record_differences(
                    differences,
                    options,
                    param_values,
                    engine,
                    container,
                    container_image,
                    image,
                    host_info,
                )
        has_differences = not differences.empty
        # Only consider differences of properties that can be updated when there are also other differences
        if has_differences:
            differences.merge(update_differences)
        return has_differences, differences

    def has_different_resource_limits(
        self,
        container: Container,
        container_image: dict[str, t.Any] | None,
        image: dict[str, t.Any] | None,
        host_info: dict[str, t.Any] | None,
    ) -> tuple[bool, DifferenceTracker]:
        differences = DifferenceTracker()
        assert self.parameters is not None
        for options, param_values in self.parameters:
            engine = options.get_engine(self.engine_driver.name)
            if not engine.can_update_value(
                self.engine_driver.get_api_version(self.client)
            ):
                continue
            self._record_differences(
                differences,
                options,
                param_values,
                engine,
                container,
                container_image,
                image,
                host_info,
            )
        has_differences = not differences.empty
        return has_differences, differences

    def _compose_update_parameters(self) -> dict[str, t.Any]:
        result: dict[str, t.Any] = {}
        assert self.parameters is not None
        for options, values in self.parameters:
            engine = options.get_engine(self.engine_driver.name)
            if not engine.can_update_value(
                self.engine_driver.get_api_version(self.client)
            ):
                continue
            engine.update_value(
                self.module,
                result,
                self.engine_driver.get_api_version(self.client),
                options.options,
                values,
            )
        return result

    def update_limits(
        self,
        container: Container,
        container_image: dict[str, t.Any] | None,
        image: dict[str, t.Any] | None,
        host_info: dict[str, t.Any] | None,
    ) -> Container:
        limits_differ, different_limits = self.has_different_resource_limits(
            container, container_image, image, host_info
        )
        if limits_differ:
            self.log("limit differences:")
            self.log(
                different_limits.get_legacy_docker_container_diffs(), pretty_print=True
            )
            self.diff_tracker.merge(different_limits)
        if limits_differ and not self.check_mode:
            assert container.id is not None
            self.container_update(container.id, self._compose_update_parameters())
            return self._get_container(container.id)
        return container

    def has_network_differences(
        self, container: Container
    ) -> tuple[bool, list[dict[str, t.Any]]]:
        """
        Check if the container is connected to requested networks with expected options: links, aliases, ipv4, ipv6
        """
        different = False
        differences: list[dict[str, t.Any]] = []

        if not self.module.params["networks"]:
            return different, differences

        assert container.container is not None
        if not container.container.get("NetworkSettings"):
            self.fail(
                "has_missing_networks: Error parsing container properties. NetworkSettings missing."
            )

        connected_networks = container.container["NetworkSettings"]["Networks"]
        for network in self.module.params["networks"]:
            network_info = connected_networks.get(network["name"])
            if network_info is None:
                different = True
                differences.append({"parameter": network, "container": None})
            else:
                diff = False
                network_info_ipam = network_info.get("IPAMConfig") or {}
                if network.get("ipv4_address") and normalize_ip_address(
                    network["ipv4_address"]
                ) != normalize_ip_address(network_info_ipam.get("IPv4Address")):
                    diff = True
                if network.get("ipv6_address") and normalize_ip_address(
                    network["ipv6_address"]
                ) != normalize_ip_address(network_info_ipam.get("IPv6Address")):
                    diff = True
                if network.get("aliases") and not compare_generic(
                    network["aliases"],
                    network_info.get("Aliases"),
                    "allow_more_present",
                    "set",
                ):
                    diff = True
                if network.get("links"):
                    expected_links = []
                    for link, alias in network["links"]:
                        expected_links.append(f"{link}:{alias}")
                    if not compare_generic(
                        expected_links,
                        network_info.get("Links"),
                        "allow_more_present",
                        "set",
                    ):
                        diff = True
                if network.get("mac_address") and network[
                    "mac_address"
                ] != network_info.get("MacAddress"):
                    diff = True
                if diff:
                    different = True
                    differences.append(
                        {
                            "parameter": network,
                            "container": {
                                "name": network["name"],
                                "ipv4_address": network_info_ipam.get("IPv4Address"),
                                "ipv6_address": network_info_ipam.get("IPv6Address"),
                                "aliases": network_info.get("Aliases"),
                                "links": network_info.get("Links"),
                                "mac_address": network_info.get("MacAddress"),
                            },
                        }
                    )
        return different, differences

    def has_extra_networks(
        self, container: Container
    ) -> tuple[bool, list[dict[str, t.Any]]]:
        """
        Check if the container is connected to non-requested networks
        """
        extra_networks: list[dict[str, t.Any]] = []
        extra = False

        assert container.container is not None
        if not container.container.get("NetworkSettings"):
            self.fail(
                "has_extra_networks: Error parsing container properties. NetworkSettings missing."
            )

        connected_networks = container.container["NetworkSettings"].get("Networks")
        if connected_networks:
            for network, network_config in connected_networks.items():
                keep = False
                if self.module.params["networks"]:
                    for expected_network in self.module.params["networks"]:
                        if expected_network["name"] == network:
                            keep = True
                if not keep:
                    extra = True
                    extra_networks.append(
                        {"name": network, "id": network_config["NetworkID"]}
                    )
        return extra, extra_networks

    def update_networks(
        self, container: Container, container_created: bool
    ) -> Container:
        updated_container = container
        if self.all_options["networks"].comparison != "ignore" or container_created:
            has_network_differences, network_differences = self.has_network_differences(
                container
            )
            if has_network_differences:
                if self.diff.get("differences"):
                    self.diff["differences"].append(
                        {"network_differences": network_differences}
                    )
                else:
                    self.diff["differences"] = [
                        {"network_differences": network_differences}
                    ]
                for netdiff in network_differences:
                    self.diff_tracker.add(
                        f"network.{netdiff['parameter']['name']}",
                        parameter=netdiff["parameter"],
                        active=netdiff["container"],
                    )
                self.results["changed"] = True
                updated_container = self._add_networks(container, network_differences)

        purge_networks = (
            self.all_options["networks"].comparison == "strict"
            and self.module.params["networks"] is not None
        )
        if purge_networks:
            has_extra_networks, extra_networks = self.has_extra_networks(container)
            if has_extra_networks:
                if self.diff.get("differences"):
                    self.diff["differences"].append({"purge_networks": extra_networks})
                else:
                    self.diff["differences"] = [{"purge_networks": extra_networks}]
                for extra_network in extra_networks:
                    self.diff_tracker.add(
                        f"network.{extra_network['name']}", active=extra_network
                    )
                self.results["changed"] = True
                updated_container = self._purge_networks(container, extra_networks)
        return updated_container

    def _add_networks(
        self, container: Container, differences: list[dict[str, t.Any]]
    ) -> Container:
        assert container.id is not None
        for diff in differences:
            # remove the container from the network, if connected
            if diff.get("container"):
                self._add_action({"removed_from_network": diff["parameter"]["name"]})
                if not self.check_mode:
                    try:
                        self.engine_driver.disconnect_container_from_network(
                            self.client, container.id, diff["parameter"]["id"]
                        )
                    except Exception as exc:  # pylint: disable=broad-exception-caught
                        self.fail(
                            f"Error disconnecting container from network {diff['parameter']['name']} - {exc}"
                        )
            # connect to the network
            self._add_action(
                {
                    "added_to_network": diff["parameter"]["name"],
                    "network_parameters": diff["parameter"],
                }
            )
            if not self.check_mode:
                params = {
                    key: value
                    for key, value in diff["parameter"].items()
                    if key not in ("id", "name")
                }
                try:
                    self.log(
                        f"Connecting container to network {diff['parameter']['id']}"
                    )
                    self.log(params, pretty_print=True)
                    self.engine_driver.connect_container_to_network(
                        self.client, container.id, diff["parameter"]["id"], params
                    )
                except Exception as exc:  # pylint: disable=broad-exception-caught
                    self.fail(
                        f"Error connecting container to network {diff['parameter']['name']} - {exc}"
                    )
        return self._get_container(container.id)

    def _purge_networks(
        self, container: Container, networks: list[dict[str, t.Any]]
    ) -> Container:
        assert container.id is not None
        for network in networks:
            self._add_action({"removed_from_network": network["name"]})
            if not self.check_mode:
                try:
                    self.engine_driver.disconnect_container_from_network(
                        self.client, container.id, network["name"]
                    )
                except Exception as exc:  # pylint: disable=broad-exception-caught
                    self.fail(
                        f"Error disconnecting container from network {network['name']} - {exc}"
                    )
        return self._get_container(container.id)

    def container_create(self, image: str) -> Container | None:
        create_parameters = self._compose_create_parameters(image)
        self.log("create container")
        self.log(f"image: {image} parameters:")
        self.log(create_parameters, pretty_print=True)
        networks = {}
        if self.param_networks_cli_compatible and self.module.params["networks"]:
            network_list = self.module.params["networks"]
            if not self.engine_driver.create_container_supports_more_than_one_network(
                self.client
            ):
                network_list = network_list[:1]
            for network in network_list:
                networks[network["name"]] = {
                    key: value
                    for key, value in network.items()
                    if key not in ("name", "id")
                }
        self._add_action(
            {
                "created": "Created container",
                "create_parameters": create_parameters,
                "networks": networks,
            }
        )
        self.results["changed"] = True
        if not self.check_mode:
            try:
                container_id = self.engine_driver.create_container(
                    self.client, self.param_name, create_parameters, networks=networks
                )
            except Exception as exc:  # pylint: disable=broad-exception-caught
                self.fail(f"Error creating container: {exc}")
            return self._get_container(container_id)
        return None

    def container_start(self, container_id: str) -> Container:
        self.log(f"start container {container_id}")
        self._add_action({"started": container_id})
        self.results["changed"] = True
        if not self.check_mode:
            try:
                self.engine_driver.start_container(self.client, container_id)
            except Exception as exc:  # pylint: disable=broad-exception-caught
                self.fail(f"Error starting container {container_id}: {exc}")

            if self.module.params["detach"] is False:
                status = self.engine_driver.wait_for_container(
                    self.client, container_id
                )
                # mypy doesn't know that Client has fail_results property
                self.client.fail_results["status"] = status  # type: ignore
                self.results["status"] = status

                output: str | bytes
                if self.module.params["auto_remove"]:
                    output = "Cannot retrieve result as auto_remove is enabled"
                    if self.param_output_logs:
                        self.module.warn(
                            "Cannot output_logs if auto_remove is enabled!"
                        )
                else:
                    output, real_output = self.engine_driver.get_container_output(
                        self.client, container_id
                    )
                    if real_output and self.param_output_logs:
                        self._output_logs(msg=output)

                if self.param_cleanup:
                    self.container_remove(container_id, force=True)
                insp = self._get_container(container_id)
                if insp.raw:
                    insp.raw["Output"] = output
                else:
                    insp.raw = {"Output": output}
                if status != 0:
                    # Set `failed` to True and return output as msg
                    self.results["failed"] = True
                    self.results["msg"] = output
                return insp
        return self._get_container(container_id)

    def container_remove(
        self, container_id: str, link: bool = False, force: bool = False
    ) -> None:
        volume_state = not self.param_keep_volumes
        self.log(
            f"remove container container:{container_id} v:{volume_state} link:{link} force{force}"
        )
        self._add_action(
            {
                "removed": container_id,
                "volume_state": volume_state,
                "link": link,
                "force": force,
            }
        )
        self.results["changed"] = True
        if not self.check_mode:
            try:
                self.engine_driver.remove_container(
                    self.client,
                    container_id,
                    remove_volumes=volume_state,
                    link=link,
                    force=force,
                )
            except Exception as exc:  # pylint: disable=broad-exception-caught
                self.fail(f"Error removing container {container_id}: {exc}")

    def container_update(
        self, container_id: str, update_parameters: dict[str, t.Any]
    ) -> Container:
        if update_parameters:
            self.log(f"update container {container_id}")
            self.log(update_parameters, pretty_print=True)
            self._add_action(
                {"updated": container_id, "update_parameters": update_parameters}
            )
            self.results["changed"] = True
            if not self.check_mode:
                try:
                    self.engine_driver.update_container(
                        self.client, container_id, update_parameters
                    )
                except Exception as exc:  # pylint: disable=broad-exception-caught
                    self.fail(f"Error updating container {container_id}: {exc}")
        return self._get_container(container_id)

    def container_kill(self, container_id: str) -> None:
        self._add_action({"killed": container_id, "signal": self.param_kill_signal})
        self.results["changed"] = True
        if not self.check_mode:
            try:
                self.engine_driver.kill_container(
                    self.client, container_id, kill_signal=self.param_kill_signal
                )
            except Exception as exc:  # pylint: disable=broad-exception-caught
                self.fail(f"Error killing container {container_id}: {exc}")

    def container_restart(self, container_id: str) -> Container:
        self._add_action(
            {"restarted": container_id, "timeout": self.module.params["stop_timeout"]}
        )
        self.results["changed"] = True
        if not self.check_mode:
            try:
                self.engine_driver.restart_container(
                    self.client, container_id, self.module.params["stop_timeout"] or 10
                )
            except Exception as exc:  # pylint: disable=broad-exception-caught
                self.fail(f"Error restarting container {container_id}: {exc}")
        return self._get_container(container_id)

    def container_stop(self, container_id: str) -> None:
        if self.param_force_kill:
            self.container_kill(container_id)
            return
        self._add_action(
            {"stopped": container_id, "timeout": self.module.params["stop_timeout"]}
        )
        self.results["changed"] = True
        if not self.check_mode:
            try:
                self.engine_driver.stop_container(
                    self.client, container_id, self.module.params["stop_timeout"]
                )
            except Exception as exc:  # pylint: disable=broad-exception-caught
                self.fail(f"Error stopping container {container_id}: {exc}")


def run_module(engine_driver: EngineDriver) -> None:
    module, active_options, client = engine_driver.setup(
        argument_spec={
            "cleanup": {"type": "bool", "default": False},
            "comparisons": {"type": "dict"},
            "container_default_behavior": {
                "type": "str",
                "default": "no_defaults",
                "choices": ["compatibility", "no_defaults"],
            },
            "command_handling": {
                "type": "str",
                "choices": ["compatibility", "correct"],
                "default": "correct",
            },
            "default_host_ip": {"type": "str"},
            "force_kill": {"type": "bool", "default": False, "aliases": ["forcekill"]},
            "image": {"type": "str"},
            "image_comparison": {
                "type": "str",
                "choices": ["desired-image", "current-image"],
                "default": "desired-image",
            },
            "image_label_mismatch": {
                "type": "str",
                "choices": ["ignore", "fail"],
                "default": "ignore",
            },
            "image_name_mismatch": {
                "type": "str",
                "choices": ["ignore", "recreate"],
                "default": "recreate",
            },
            "keep_volumes": {"type": "bool", "default": True},
            "kill_signal": {"type": "str"},
            "name": {"type": "str", "required": True},
            "networks_cli_compatible": {"type": "bool", "default": True},
            "output_logs": {"type": "bool", "default": False},
            "paused": {"type": "bool"},
            "pull": {
                "type": "raw",
                "choices": ["never", "missing", "always", True, False],
                "default": "missing",
            },
            "pull_check_mode_behavior": {
                "type": "str",
                "choices": ["image_not_present", "always"],
                "default": "image_not_present",
            },
            "recreate": {"type": "bool", "default": False},
            "removal_wait_timeout": {"type": "float"},
            "restart": {"type": "bool", "default": False},
            "state": {
                "type": "str",
                "default": "started",
                "choices": ["absent", "present", "healthy", "started", "stopped"],
            },
            "healthy_wait_timeout": {"type": "float", "default": 300},
        },
        required_if=[
            ("state", "present", ["image"]),
        ],
    )

    def execute() -> t.NoReturn:
        cm = ContainerManager(module, engine_driver, client, active_options)
        cm.run()
        module.exit_json(**sanitize_result(cm.results))

    engine_driver.run(execute, client)
