import copy
import logging
from collections import OrderedDict
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, NamedTuple, Union

import yaml

import cekit
from cekit.cekit_types import _T, RawDescriptor
from cekit.config import Config
from cekit.descriptor import (
    Arg,
    Descriptor,
    Env,
    Label,
    Modules,
    Osbs,
    Packages,
    Port,
    Run,
    Volume,
)
from cekit.descriptor.resource import Resource, create_resource
from cekit.errors import CekitError
from cekit.tools import get_latest_image_version

if TYPE_CHECKING:
    from cekit.descriptor.modules import Install
    from cekit.descriptor.overrides import Overrides
    from cekit.generator.base import ModuleRegistry

logger = logging.getLogger("cekit")
config = Config()

_image_schema = yaml.safe_load(
    """
map:
  name: {type: str, required: True}
  version: {type: text, required: True}
  schema_version: {type: int}
  release: {type: text}
  from: {type: str}
  follow_tag: {type: str}
  description: {type: text}
  args:  {type: any}
  labels: {type: any}
  envs:  {type: any}
  ports: {type: any}
  run: {type: any}
  artifacts: {type: any}
  modules: {type: any}
  packages: {type: any}
  osbs: {type: any}
  volumes: {type: any}
  help:
    map:
      add: {type: bool}
      template: {type: text}"""
)


class ImageOverrides(NamedTuple):
    artifacts: Dict[str, "Resource"]
    modules: Dict[str, "Install"]


def get_image_schema():
    return copy.deepcopy(_image_schema)


class Image(Descriptor):
    def __init__(self, descriptor: RawDescriptor, artifact_dir: str):
        self._artifact_dir: str = artifact_dir
        self.path: str = artifact_dir
        self.schema = _image_schema.copy()
        super(Image, self).__init__(descriptor)
        self.skip_merging = ["description", "version", "name", "release"]
        self._prepare()

    def _prepare(self):
        # TODO: Separating raw image descriptor from a higher level Image class would change this
        # confusing code into a connector/factory.
        self._descriptor["labels"] = [
            Label(x) for x in self._descriptor.get("labels", [])
        ]
        self._descriptor["envs"] = [Env(x) for x in self._descriptor.get("envs", [])]
        self._descriptor["ports"] = [Port(x) for x in self._descriptor.get("ports", [])]
        if "run" in self._descriptor:
            self._descriptor["run"] = Run(self._descriptor["run"])
        self._descriptor["artifacts"] = [
            create_resource(a, directory=self._artifact_dir)
            for a in self._descriptor.get("artifacts", [])
        ]
        self._descriptor["modules"] = Modules(
            self._descriptor.get("modules", {}), self.path
        )
        self._descriptor["packages"] = Packages(
            self._descriptor.get("packages", {}), self.path
        )
        self._descriptor["osbs"] = Osbs(self._descriptor.get("osbs", {}), self.path)
        self._descriptor["volumes"] = [
            Volume(x) for x in self._descriptor.get("volumes", [])
        ]

        # make sure image declarations override any module definitions
        # TODO: Make into a NamedTuple to make types easier to reason about.
        self._image_overrides = ImageOverrides(
            artifacts=Image._to_dict(self.artifacts),
            modules=Image._to_dict(self.modules.install),
        )
        self._all_artifacts: Dict[str, Resource] = Image._to_dict(self.artifacts)

    def process_defaults(self):
        """Prepares default values before rendering"""
        if not self.run:
            self.run = Run({})

        # do we want to force a user?
        if "user" not in self.run:
            self.run._descriptor["user"] = cekit.DEFAULT_USER

        # Default package manager is yum
        if not self.packages.manager:
            self.packages._descriptor["manager"] = "yum"

        # Default directory for supplementary files that should be copied to dist-git directory
        if not self.osbs.extra_dir:
            self.osbs._descriptor["extra_dir"] = "osbs_extra"

        # Placing this here rather than in init as apply_image_overrides runs after that. This means
        # follow_tag is applied *after* overrides.
        if self.follow:
            if not config.get("common", "redhat"):
                raise CekitError(
                    "follow_tag annotation only supported with redhat flag"
                )
            self.base = get_latest_image_version(self.follow)

    @property
    def name(self) -> str:
        return self.get("name")

    @name.setter
    def name(self, value: str):
        self._descriptor["name"] = value

    @property
    def version(self) -> Any:
        # TODO: Convert to string up front to simplify
        return self.get("version")

    @version.setter
    def version(self, value: Any):
        self._descriptor["version"] = value

    # TODO: release is undocumented.
    @property
    def release(self) -> str:
        return self.get("release")

    @release.setter
    def release(self, value: str):
        self._descriptor["release"] = value

    @property
    def base(self) -> str:
        return self.get("from")

    @base.setter
    def base(self, value: str):
        self._descriptor["from"] = value

    @property
    def follow(self) -> str:
        return self.get("follow_tag")

    @follow.setter
    def follow(self, value: str):
        self._descriptor["follow_tag"] = value

    @property
    def description(self) -> str:
        return self.get("description")

    @description.setter
    def description(self, value: str) -> None:
        self._descriptor["description"] = value

    @property
    def labels(self) -> List[Label]:
        return self.get("labels", [])

    @property
    def envs(self) -> List[Env]:
        return self.get("envs", [])

    @property
    def args(self) -> List[Arg]:
        return self.get("args", [])

    @property
    def ports(self) -> List[Port]:
        return self.get("ports", [])

    @property
    def run(self) -> Run:
        return self.get("run")

    @run.setter
    def run(self, value: Run):
        self._descriptor["run"] = value

    @property
    def all_artifacts(self) -> Iterable[Resource]:
        return self._all_artifacts.values()

    @property
    def artifacts(self) -> List[Resource]:
        return self.get("artifacts", [])

    @property
    def modules(self) -> Modules:
        return self.get("modules", Modules({}, self._artifact_dir))

    @property
    def packages(self) -> Packages:
        return self.get("packages", Packages({}, self.path))

    @property
    def osbs(self) -> Osbs:
        return self.get("osbs")

    @osbs.setter
    def osbs(self, value: Osbs):
        self._descriptor["osbs"] = value

    @property
    def volumes(self) -> List[Volume]:
        return self.get("volumes", [])

    @property
    def help(self) -> dict:
        return self.get("help", {})

    def apply_image_overrides(self, overrides: List["Overrides"]):
        """
        Applies overrides to the image descriptor.
        """
        if not overrides:
            return
        for override in overrides:
            if override.name:
                self.name = override.name
            if override.version:
                self.version = override.version
            if override.base:
                self.base = override.base
            if override.description:
                self.description = override.description

            labels = Image._to_dict(self.labels)
            for label in override.labels:
                name = label.name
                if name in labels:
                    labels[name] = label.merge(labels[name])
                else:
                    labels[name] = label
            self._descriptor["labels"] = list(labels.values())

            envs = Image._to_dict(self.envs)
            for env in override.envs:
                name = env.name
                if name in envs:
                    envs[name] = env.merge(envs[name])
                else:
                    envs[name] = env
            self._descriptor["envs"] = list(envs.values())

            ports = Image._to_dict(self.ports)
            for port in override.ports:
                name = port.value
                if name in ports:
                    ports[name] = port.merge(ports[name])
                else:
                    ports[name] = port
            self._descriptor["ports"] = list(ports.values())

            module_repositories = Image._to_dict(self.modules.repositories)
            for repository in override.modules.repositories:
                name = repository.name
                if name in module_repositories:
                    module_repositories[name] = repository.merge(
                        module_repositories[name]
                    )
                else:
                    module_repositories[name] = repository
            self.modules._descriptor["repositories"] = list(
                module_repositories.values()
            )

            self.packages._descriptor = override.packages.merge(self.packages)

            # In case content sets are provided as null values
            # Remove the key entirely.
            # TODO: This should be handled probably at general level, for every key
            for flag in ["content_sets", "content_sets_file"]:
                if flag in override.packages and override.packages[flag] is None:
                    self.packages._descriptor.pop("content_sets", None)
                    self.packages._descriptor.pop("content_sets_file", None)

            # Merge override osbs items into self.
            self.osbs = self.osbs.merge(override.osbs)

            # Using 'or []' to avoid having to set default value in packages.py for _descriptor["remove"]
            for package in override.packages.remove or []:
                if package not in self.packages.remove:
                    self.packages.remove.append(package)

            for package in override.packages.install or []:
                if package not in self.packages.install:
                    self.packages.install.append(package)

            for package in override.packages.reinstall or []:
                if package not in self.packages.reinstall:
                    self.packages.reinstall.append(package)

            artifact_overrides = self._image_overrides.artifacts
            image_artifacts = Image._to_dict(self.artifacts)
            for i, artifact in enumerate(override.artifacts):
                name = artifact.name
                # override.artifact contains override values WITH defaults.
                # override.original_descriptor contains override value WITHOUT defaults.
                # artifact_overrides contains original dictionary
                #
                # Iterating over dest / target / ...
                #   If we have _not_ supplied a target (check original_descriptor),
                #      then check artifact_overrides,
                #         otherwise use default from override.artifact
                override_without_defaults = override.original_descriptor.get(
                    "artifacts"
                )[i]
                for key in ["dest", "target", "description"]:
                    if override_without_defaults.get(key):
                        logger.debug(
                            "Key ({}) found in override as {}".format(
                                key, override_without_defaults.get(key)
                            )
                        )
                        artifact[key] = override_without_defaults.get(key)
                    elif artifact_overrides.get(name) and artifact_overrides.get(
                        name
                    ).get(key):
                        new_value = artifact_overrides.get(name).get(key)
                        logger.debug(
                            "Key ({}) found in original artifact as {}".format(
                                key, new_value
                            )
                        )
                        artifact[key] = new_value

                # collect override so we can apply it to modules
                artifact_overrides[name] = artifact
                # add it to the list of everything
                self._all_artifacts[name] = artifact
                # Apply override to image descriptor
                image_artifacts[name] = artifact
                # Sort the output as it makes it easier to view and test.
                logger.debug(
                    "Final (with override) artifact is {}".format(
                        sorted(artifact.items())
                    )
                )
            self._descriptor["artifacts"] = list(image_artifacts.values())

            module_overrides = self._image_overrides.modules
            image_modules = Image._to_dict(self.modules.install)
            for module in override.modules.install:
                name = module.name
                # collect override so we can apply it to modules.
                # this allows us to override module versions without affecting ordering.
                module_overrides[name] = module
                # Apply override to image descriptor
                # If the module does not exists in the original descriptor, add it there
                image_modules[name] = module
            self.modules._descriptor["install"] = list(image_modules.values())

            if override.run is not None:
                if self.run:
                    self.run = override.run.merge(self.run)
                else:
                    self.run = override.run

    def apply_module_overrides(self, module_registry: "ModuleRegistry"):
        """
        Applies overrides to included modules.  This includes:
            Artifact definitions
            Module dependency version overrides
        Also incorporates module contributed global configuration into the image:
            Run specification
            Package repository definitions
            Required artifacts
        """
        install_list: Dict[str, "Install"] = OrderedDict()

        # index by name for easier access
        self._package_repositories = Image._to_dict(self.packages.repositories)

        # collect final 'run' value from modules
        self._module_run = Run({})

        # process the modules and integrate relevant bits into ourself
        self.process_install_list(
            self, self.modules.install, install_list, module_registry
        )

        # update ourself based on module declarations
        # final order of modules to be installed
        self.modules._descriptor["install"] = list(install_list.values())
        # all package repositories required for installing packages
        self.packages._descriptor["repositories"] = list(
            self._package_repositories.values()
        )

        # final 'run' value
        if self.run:
            self.run = self.run.merge(self._module_run)
        else:
            self.run = self._module_run

    def process_install_list(
        self,
        source: Union["Image"],
        to_install_list: List["Install"],
        install_list: Dict[str, "Install"],
        module_registry: "ModuleRegistry",
    ) -> None:
        # TODO: Return value is passed as parameter in `install_list`
        module_overrides = self._image_overrides.modules
        artifact_overrides = self._image_overrides.artifacts
        for to_install in to_install_list:
            logger.debug(
                "Preparing module '{}' required by '{}'.".format(
                    to_install.name, source.name
                )
            )
            override = module_overrides.get(to_install.name, None)
            if override:
                if override.version != to_install.version:
                    logger.debug(
                        "Module '{}:{}' being overridden with '{}:{}'.".format(
                            to_install.name,
                            to_install.version,
                            override.name,
                            override.version,
                        )
                    )
                # apply module override
                to_install = override

            existing = install_list.get(to_install.name, None)
            # see if we've already processed this
            if existing:
                # check for a version conflict
                if existing.version != to_install.version:
                    logger.warning(
                        "Module version inconsistency for {}: {} requested, but {} will be used.".format(
                            to_install.name, to_install.version, existing.version
                        )
                    )
                continue

            module = module_registry.get_module(to_install.name, to_install.version)
            if not module:
                raise CekitError(
                    "Could not locate module %s version %s. Please verify that it is included in one of the "
                    "specified module repositories."
                    % (to_install.name, to_install.version)
                )

            # collect artifacts and apply overrides
            module_artifacts = Image._to_dict(module.artifacts)
            for artifact in module.artifacts:
                name = artifact.name
                if name in artifact_overrides:
                    override = artifact_overrides[name]
                    self._all_artifacts[name] = override
                    module_artifacts[name] = override
                else:
                    self._all_artifacts[name] = artifact
            module._descriptor["artifacts"] = list(module_artifacts.values())

            # collect package repositories
            for repo in module.packages.repositories:
                name = repo.name
                if name not in self._package_repositories:
                    self._package_repositories[name] = repo

            # collect package manager
            if not self.packages.manager and module.packages.manager:
                logger.debug(
                    f"Applying module package manager of {module.packages.manager} to image"
                )
                self.packages._descriptor["manager"] = module.packages.manager

            # incorporate run specification contributed by module
            if module.run:
                # we're looping in order of install, so we want the current module to override whatever we have
                self._module_run = module.run.merge(self._module_run)

            # prevent circular dependencies. we'll move it to the end after processing
            install_list[to_install.name] = to_install

            # process this modules dependencies
            self.process_install_list(
                module, module.modules.install, install_list, module_registry
            )

            # move this module to the end of the list.
            install_list.pop(to_install.name)
            install_list[to_install.name] = to_install

    # helper to simplify merging lists of objects
    @classmethod
    def _to_dict(cls, named_items: Iterable[_T], key="name") -> Dict[str, _T]:
        # TODO: `key` argument is never used?
        # TODO: This assumes that `name` is always a string, but in fact it isn't for Port
        dictionary = OrderedDict()
        for item in named_items:
            dictionary[item[key]] = item
        return dictionary
