import collections
import glob
import jinja2
import jinja2.meta
import json
import os
import pprint
import sys
import yaml

from typing import Dict, List

class PackerTemplate:
    """This class implements one packer template.
    Basically, a packer template is made of four sections:
    - description: a string that explains the purpose of the template
    - variables: a dictionary of variables used in the template through jinja2 mechanism
    - builders: a list of dictionaries where each dictionary defines the type of image that will be built
    - provisioners: a list of dictionaries where each dictionary defines actions used to configure the image
    - processors: a list of dictionaries where each dictionary defines actions to be run after the image is built
    """

    def __init__(self, name: str, yaml_node: dict, packages: List[str], packages_base_dir: str, templates_base_dir: str) -> None:
        """Constructor
        """

        self._name = name  
        
        self._packages_base_dir = packages_base_dir
        self._templates_base_dir = templates_base_dir

        environment_file = os.path.join(self._templates_base_dir,self._name,"environment.yml")
        self._read_environment_file(environment_file)
        self._environment.update(os.environ)

        # Fetch the 'packer' node
        packer_node = yaml_node.get("packer", {})

        # Fetch the 'description' node from the packer node
        self._description = packer_node.get("description", "No description provided")

        # Fetch the '_variables' node from the packer node
        self._variables = packer_node.get("variables", {})

        # Fetch the 'builders' node from the packer node
        self._builders = packer_node.get("builders", [])
        for builder in self._builders:
            self._update_builder(builder)

        # Fetch the 'provisioners' node from the packer node
        self._provisioners = packer_node.get("provisioners", [])

        # Case of the file based provisioner, update the source path
        for provisioner in self._provisioners:
            self._update_provisioner(provisioner,os.path.join(self._templates_base_dir,self._name))

        # Fetch the 'postprocessors' node from the packer node
        self._postprocessors = packer_node.get("postprocessors", [])

        self._load_packages(packages)

    def _read_environment_file(self, environment_file):
        """Read YAML environemnt file and populate os.environ

        Parameters
        ----------
        environment_file: str
            Path to the environment file.
        """

        if not os.path.exists(environment_file):
            return

        self._environment = {}
        # The vm_name is set as part of the jinja environment
        self._environment["vm_name"] = self._name

        with open(environment_file, "r") as fin:
            env = yaml.safe_load(fin)
            for k,v in env.get("environment",{}).items():
                self._environment[k] = str(v)

    def _update_builder(self, builder: Dict[str,str]):
        """Update some fields of a builder.

        Parameters
        ----------
        builder: dict
            The builder to update.
        """

        builder["output_directory"] = "builds"
        if not os.path.isabs(builder["output_directory"]):
            builder["output_directory"] = os.path.join(self._templates_base_dir,self._name,builder["output_directory"])

        builder["http_directory"] = "http"
        if not os.path.isabs(builder["http_directory"]):
            builder["http_directory"] = os.path.join(self._templates_base_dir,self._name,builder["http_directory"])

        # For convenience set the vm name with a fixed standardized value
        builder["vm_name"] = "{}-{}".format(self._name,builder["type"])

    def _update_provisioner(self, provisioner : Dict[str,str], base_dir : str):
        """Update some fields of a provisioner.

        Parameters
        ----------
        provisioner: dict
            The provisioner to update.

        base_dir: str
            The base directory for this provisioner.
        """

        if not provisioner["type"] in ["file","shell"]:
            return

        for k,v in provisioner.items():
            if k in ["source","script"] and not os.path.isabs(provisioner[k]):
                provisioner[k] = os.path.join(base_dir,provisioner[k])

    @property
    def builders(self) -> list:
        """Returns the list of packer builders of this :class:`PackerTemplate`.
        """

        return self._builders

    @property
    def description(self) -> str:
        """Returns the description of this :class:`PackerTemplate`.
        """

        return self._description

    @property
    def name(self) -> str:
        """Returns the name of this :class:`PackerTemplate`.
        """

        return self._name

    @property
    def postprocessors(self) -> list:
        """Returns the postprocessors of this :class:`PackerTemplate`.
        """

        return self._postprocessors

    @property
    def provisioners(self) -> list:
        """Returns the provisioners of this :class:`PackerTemplate`.
        """

        return self._provisioners

    @property
    def variables(self) -> dict:
        """Returns the variables of this :class:`PackerTemplate`.
        """

        return self._variables

    def set_parent(self, parent_template: "PackerTemplate"):
        """Set the parent template to this :class:`PackerTemplate`.

        This defines a relationship for future packer run in the sense that the child template will start directly from the image of 
        its parent template.

        Parameters
        ----------
        parent_template: :class:`PackerTemplate`
            The :class:`PackerTemplate` of the parent template to connect the child template with.
            
        """

        # List of the builder names for the child template
        child_builder_names = [builder["name"] for builder in self._builders]

        # Loop over the builder of the parent template
        for builder in parent_template.builders:

            builder_name = builder["name"]

            # If this is a builder specific to the parent config, copy it in the child config and set it with an image dependency
            if builder_name not in child_builder_names:
                parent_builder = {}
                parent_builder["name"] = builder_name
                parent_builder["type"] = builder["type"]
                parent_builder["iso_url"] = os.path.join(builder["output_directory"],"{}-{}".format(parent_template.name, builder_name))
                parent_builder["iso_checksum_type"] = "none"
                parent_builder["iso_checksum_url"] = "none"
                parent_builder["headless"] = "{{ headless }}"
                self._builders.insert(0,parent_builder)

            # If the builder is also defined in the child config, use the child config one and specify the image dependency
            else:

                builder = next((b for b in self._builders if b["name"] == builder_name),None)
                if builder is None:
                    continue

                builder["iso_url"] = os.path.join(self._templates_base_dir,parent_template.name,"builds","{}-{}".format(parent_template.name, builder_name))
                builder["iso_checksum_type"] = "none"
                builder["iso_checksum_url"] = "none"
                builder["headless"] = "{{ headless }}"

    def _load_packages(self, packages: List[str]):
        """Load the non-standard package YAML file and append them as provisioners of this :class:`PackerTemplate`.

        Parameters
        ----------
        list
            The non-standard packages to append.    
        """

        # If *" is in the list, fetch all the packages
        if "*" in packages:
            packages_dir = glob.glob(os.path.join(self._packages_base_dir,"*"))
        # Otherwise just fetch the selected ones
        else:
            packages_dir = []
            for package in packages:
                package_dir = os.path.join(self._packages_base_dir,package)
                if os.path.exists(package_dir) and os.path.isdir(package_dir):
                    packages_dir.append(package_dir)

        # Loop over the packages directories
        for package_dir in packages_dir:
            
            # Build the path to the package manifest file (YAML)
            manifest_file = os.path.join(package_dir,"manifest.yml")
            
            # Open and load the provisioners list from the manifest file
            try:
              fin = open(manifest_file, "r")
            # If the manifest does not exist, the provisioners list is set to an empty list
            except FileNotFoundError:
                manifest_data: List = []
            else:
                root_node = yaml.safe_load(fin)
                manifest_data = root_node["provisioners"]

            # Loop over the provisioners list and update when necessary relative paths with absolute one for packer to run correctly
            for provisioner in manifest_data:
                self._update_provisioner(provisioner,os.path.join(package_dir,self._name))

            # Extend the current provisioners list with the ones of the selected packages
            self._provisioners.extend(manifest_data)
            
    def dump(self, output_file: str, **kwargs):
        """Dump this PackerTemplate to a file.

        Parameters
        ----------
        output_file: str
            The path to the output json file for this :class:`PackerTemplate`.
        """

        # Get the basename and the ext of the output_file
        basename, ext = os.path.splitext(output_file)
        # If the ext is different from .json set it to .json
        if ext != ".json":
            ext = ".json"

        # Reformat the output_file
        output_file = "{}{}".format(basename, ext)

        # This will be the node to be dumped
        node = {}
        node["description"] = self._description
        node["variables"] = self._variables
        node["builders"] = self._builders
        node["provisioners"] = self._provisioners
        node["post-processors"] = self._postprocessors

        stringified_node = repr(node)

        # Create a jinja evironment for parsing the manifest
        env = jinja2.Environment()
        # Parse the string with jinja templates in unrendered state in order to get te set of all jinja templates
        ast = env.parse(stringified_node)
        jinja_templates = jinja2.meta.find_undeclared_variables(ast)
        # Retrieve those templates which are not declared in the environment. These are the undefined templates.
        undefined = jinja_templates.difference(self._environment.keys())

        # If there are some undefined templates, stop here.
        if undefined:
            print(f'The following variables are undefined: {undefined!r}')
            sys.exit(1)
        
        template = env.from_string(stringified_node)
        rendered = template.render(**self._environment)

        # Dump to the output file
        with open(output_file, "w") as fout:
            json.dump(yaml.safe_load(rendered), fout, **kwargs)

    def __str__(self) -> str:
        """Returns the string representation for this :class:`PackerTemplate`.
        """

        d : collections.OrderedDict = collections.OrderedDict()
        d["description"] = self._description
        d["variables"] = self._variables
        d["builders"] = self._builders
        d["provisioners"] = self._provisioners
        d["post-processors"] = self._postprocessors

        return pprint.pformat(d)