#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import json
from json import JSONEncoder
import os
import sys
from collections.abc import Mapping
from glob import glob
import re
from typing import List, Dict, Optional, Any, Tuple, Iterator, overload, Union, Set

if sys.version_info >= (3, 8):
    from typing import Literal
else:
    from typing_extensions import Literal

try:
    import jsonschema  # type: ignore
    HAS_JSONSCHEMA = True
except ImportError:
    HAS_JSONSCHEMA = False


class EncodeGalaxies(JSONEncoder):
    def default(self, obj: Any) -> Dict[str, str]:
        if isinstance(obj, Galaxy):
            return obj.to_dict()
        return JSONEncoder.default(self, obj)


class EncodeClusters(JSONEncoder):
    def default(self, obj: Any) -> Dict[str, str]:
        if isinstance(obj, list):
            obj.sort()
            return JSONEncoder.default(self, obj)
        elif isinstance(obj, set):
            return JSONEncoder.default(self, sorted(list(obj)))
        elif isinstance(obj, (Cluster, ClusterValue, ClusterValueMeta)):
            return obj.to_dict()
        return JSONEncoder.default(self, obj)


class PyMISPGalaxiesError(Exception):
    def __init__(self, message: str):
        super(PyMISPGalaxiesError, self).__init__(message)
        self.message = message


class UnableToRevertMachinetag(PyMISPGalaxiesError):
    pass


class Galaxy():
    """
    Represents a galaxy in the PyMISPGalaxies library.

    Attributes:
        galaxy (Dict[str, str]): The dictionary containing the galaxy data.
        type (str): The type of the galaxy.
        name (str): The name of the galaxy.
        icon (str): The icon of the galaxy.
        description (str): The description of the galaxy.
        version (int): The version of the galaxy.
        uuid (str): The UUID of the galaxy.
        namespace (str, optional): The namespace of the galaxy.
        kill_chain_order (Dict, optional): The kill chain order of the galaxy.
        _init_hash (int): The hash of the json representation of the galaxy at __init__().
    """

    def __init__(self, galaxy: Union[str, Dict[str, str]]):
        """
        Initializes a Galaxy object from an existing galaxy.

        Args:
            galaxy (str): The name of the existing galaxy to load from the data folder.
            galaxy (Dict[str, str]): The dictionary containing the galaxy data.
        """
        if isinstance(galaxy, str):
            root_dir_galaxies = os.path.join(os.path.abspath(os.path.dirname(sys.modules['pymispgalaxies'].__file__)), 'data', 'misp-galaxy', 'galaxies')  # type: ignore [type-var, arg-type]
            galaxy_file = os.path.join(root_dir_galaxies, f"{galaxy}.json")
            with open(galaxy_file, 'r') as f:
                self.galaxy = json.load(f)
        else:
            self.galaxy = galaxy
        self.type = self.galaxy['type']
        self.name = self.galaxy['name']
        self.icon = self.galaxy['icon']
        self.description = self.galaxy['description']
        self.version: int = self.galaxy['version']
        self.uuid = self.galaxy['uuid']
        self.namespace = self.galaxy.pop('namespace', None)
        self.kill_chain_order = self.galaxy.pop('kill_chain_order', None)
        self._init_hash: int = hash(self.to_json())

    def save(self, file_name: str, update_version: bool = True) -> None:
        """
        Saves the galaxy to a file <file_name>.json

        Args:
            file_name (str): The name of the file to save the galaxy to.
            update_version (bool, optional): Flag indicating whether to update the version if the galaxy changed. Defaults to True.
        """
        if update_version and self.has_changed():
            self.version += 1
        root_dir_galaxies = os.path.join(os.path.abspath(os.path.dirname(sys.modules['pymispgalaxies'].__file__)), 'data', 'misp-galaxy', 'galaxies')  # type: ignore [type-var, arg-type]
        galaxy_file = os.path.join(root_dir_galaxies, f"{file_name}.json")
        with open(galaxy_file, 'w') as f:
            json.dump(self, f, cls=EncodeGalaxies, indent=2, sort_keys=True, ensure_ascii=False)
            f.write('\n')  # needed for the beauty and to be compliant with jq_all_the_things

    def has_changed(self) -> bool:
        """
        Checks if the galaxy has changed since initialization.

        Returns:
            bool: True if the galaxy has changed, False otherwise.
        """
        return hash(self.to_json()) != self._init_hash

    def to_json(self) -> str:
        """
        Converts the galaxy object to a JSON string.

        Returns:
            str: The JSON representation of the galaxy object.
        """
        return json.dumps(self, cls=EncodeGalaxies, sort_keys=True)

    def to_dict(self) -> Dict[str, Any]:
        """
        Converts the galaxy object to a dictionary.

        Returns:
            Dict[str, str]: The dictionary representation of the galaxy object.
        """
        to_return = {'type': self.type, 'name': self.name, 'description': self.description,
                     'version': self.version, 'uuid': self.uuid, 'icon': self.icon}
        if self.namespace:
            to_return['namespace'] = self.namespace
        if self.kill_chain_order:
            to_return['kill_chain_order'] = self.kill_chain_order
        return to_return


class Galaxies(Mapping):  # type: ignore
    """
    A class representing a collection of MISP galaxies.

    Parameters:
    - galaxies: A list of dictionaries representing the galaxies. Each dictionary should contain the type and other properties of a galaxy.
                If left empty, the galaxies are loaded from the data folder.

    Attributes:
    - galaxies: A dictionary containing the galaxies, where the keys are the types of the galaxies and the values are instances of the Galaxy class.
    - root_dir_galaxies: The root directory of the MISP galaxies.

    Methods:
    - validate_with_schema: Validates the galaxies against the schema.
    - __getitem__: Returns the galaxy with the specified type.
    - __iter__: Returns an iterator over the galaxy types.
    - __len__: Returns the number of galaxies in the collection.
    """

    def __init__(self, galaxies: List[Dict[str, str]] = []):
        """
        Initializes a new instance of the Galaxies class.

        Parameters:
        - galaxies: A list of dictionaries representing the galaxies. Each dictionary should contain the type and other properties of a galaxy.
                    If left empty, the galaxies are loaded from the data folder.
        """
        if not galaxies:
            galaxies = []
            self.root_dir_galaxies = os.path.join(os.path.abspath(os.path.dirname(sys.modules['pymispgalaxies'].__file__)),  # type: ignore
                                                  'data', 'misp-galaxy', 'galaxies')
            for galaxy_file in glob(os.path.join(self.root_dir_galaxies, '*.json')):
                with open(galaxy_file, 'r') as f:
                    galaxies.append(json.load(f))

        self.galaxies = {}
        for galaxy in galaxies:
            self.galaxies[galaxy['type']] = Galaxy(galaxy)

    def validate_with_schema(self) -> None:
        """
        Validates the galaxies against the schema.

        Raises:
        - ImportError: If the jsonschema module is not installed.
        """
        if not HAS_JSONSCHEMA:
            raise ImportError('jsonschema is required: pip install jsonschema')
        schema = os.path.join(os.path.abspath(os.path.dirname(sys.modules['pymispgalaxies'].__file__)),  # type: ignore
                              'data', 'misp-galaxy', 'schema_galaxies.json')
        with open(schema, 'r') as f:
            loaded_schema = json.load(f)
        for g in self.galaxies.values():
            jsonschema.validate(g.galaxy, loaded_schema)

    def __getitem__(self, g_type: str) -> Galaxy:
        """
        Returns the galaxy with the specified type.

        Parameters:
        - g_type: The type of the galaxy.

        Returns:
        - The Galaxy instance with the specified type.

        Raises:
        - KeyError: If the galaxy with the specified type does not exist.
        """
        return self.galaxies[g_type]

    def __iter__(self) -> Iterator[str]:
        """
        Returns an iterator over the galaxy types.

        Returns:
        - An iterator over the galaxy types.
        """
        return iter(self.galaxies)

    def __len__(self) -> int:
        """
        Returns the number of galaxies in the collection.

        Returns:
        - The number of galaxies in the collection.
        """
        return len(self.galaxies)


class ClusterValueMeta():

    def __init__(self, m: Dict[str, Any]):
        self.type = m.pop('type', None)
        self.complexity = m.pop('complexity', None)
        self.effectiveness = m.pop('effectiveness', None)
        self.country = m.pop('country', None)
        self.possible_issues = m.pop('possible_issues', None)
        self.colour = m.pop('colour', None)
        self.motive = m.pop('motive', None)
        self.impact = m.pop('impact', None)
        self.refs: Set[str] = set(m.pop('refs', []))
        self.official_refs: Set[str] = set(m.pop('official-refs', []))
        self.synonyms: Set[str] = set(m.pop('synonyms', []))
        self.derivated_from = m.pop('derivated_from', None)
        self.status = m.pop('status', None)
        self.date = m.pop('date', None)
        self.encryption = m.pop('encryption', None)
        self.extensions = m.pop('extensions', None)
        self.ransomnotes = m.pop('ransomnotes', None)
        # NOTE: meta can have aditional properties. We only load the ones
        # defined on the schema
        self.additional_properties = m

    def to_json(self) -> str:
        return json.dumps(self, cls=EncodeClusters, sort_keys=True)

    def to_dict(self) -> Dict[str, Any]:
        to_return = {}
        if self.type:
            to_return['type'] = self.type
        if self.complexity:
            to_return['complexity'] = self.complexity
        if self.effectiveness:
            to_return['effectiveness'] = self.effectiveness
        if self.country:
            to_return['country'] = self.country
        if self.possible_issues:
            to_return['possible_issues'] = self.possible_issues
        if self.colour:
            to_return['colour'] = self.colour
        if self.motive:
            to_return['motive'] = self.motive
        if self.impact:
            to_return['impact'] = self.impact
        if self.refs:
            to_return['refs'] = sorted(list(self.refs))
        if self.official_refs:
            to_return['official-refs'] = sorted(list(self.official_refs))
        if self.synonyms:
            to_return['synonyms'] = sorted(list(self.synonyms))
        if self.derivated_from:
            to_return['derivated_from'] = self.derivated_from
        if self.status:
            to_return['status'] = self.status
        if self.date:
            to_return['date'] = self.date
        if self.encryption:
            to_return['encryption'] = self.encryption
        if self.extensions:
            to_return['extensions'] = self.extensions
        if self.ransomnotes:
            to_return['ransomnotes'] = self.ransomnotes
        if self.additional_properties:
            for key, value in self.additional_properties.items():
                if isinstance(value, list):
                    to_return[key] = sorted(value)
                else:
                    to_return[key] = value
        return to_return


class ClusterValue():
    """
    Represents a cluster value.

    Attributes:
        uuid (str): The UUID of the cluster value.
        value (Any): The value of the cluster.
        description (str): The description of the cluster value.
        meta (ClusterValueMeta): The metadata associated with the cluster value.
        searchable (List[str]): A list of searchable terms for the cluster value.

    Methods:
        __init__(self, v: Dict[str, Any]): Initializes a ClusterValue object.
        __init_meta(self, m: Optional[Dict[str, str]]) -> Optional[ClusterValueMeta]: Initializes the metadata for the cluster value.
        to_json(self) -> str: Converts the ClusterValue object to a JSON string.
        to_dict(self) -> Dict[str, Any]: Converts the ClusterValue object to a dictionary.
    """

    def __init__(self, v: Dict[str, Any]):
        """
        Initializes a ClusterValue object.

        Args:
            v (Dict[str, Any]): A dictionary containing the cluster value information.

        Raises:
            PyMISPGalaxiesError: If the cluster value is invalid (no value).
        """
        if not v['value']:
            raise PyMISPGalaxiesError("Invalid cluster (no value): {}".format(v))
        self.uuid = v.get('uuid', None)
        self.value = v['value']
        self.description = v.get('description')
        self.meta = self.__init_meta(v.get('meta'))
        self.related: List[Dict[str, str]] = []
        try:
            # LATER convert related to a class?
            self.related = v['related']
        except KeyError:
            pass
        self.searchable = [self.value]
        if self.uuid:
            self.searchable.append(self.uuid)
        if self.meta and self.meta.synonyms:
            self.searchable += self.meta.synonyms
        self.searchable = list(set(self.searchable))

    def __init_meta(self, m: Optional[Dict[str, str]]) -> Optional[ClusterValueMeta]:
        """
        Initializes the metadata for the cluster value.

        Args:
            m (Optional[Dict[str, str]]): A dictionary containing the metadata for the cluster value.

        Returns:
            Optional[ClusterValueMeta]: The initialized ClusterValueMeta object or None if no metadata is provided.
        """
        if not m:
            return None
        return ClusterValueMeta(m)

    def merge(self, new: 'ClusterValue') -> None:
        """
        Merges the new cluster value with the existing one. Practically it replaces the existing one but merges relations
        """
        # backup relations
        related_backup = self.related.copy()
        # overwrite itself
        self.__init__(new.to_dict())  # type: ignore [misc]
        # merge relations with backup  # LATER conver related to a class of Hashable type, as that would be much more efficient in keeping uniques
        for rel in related_backup:
            # if uuid exists, skip, as we already copied it
            exists = False
            for existing_item in self.related:
                if rel['dest-uuid'] == existing_item['dest-uuid']:
                    exists = True
                    break
            # else append rel to list
            if not exists:
                self.related.append(rel)

    def to_json(self) -> str:
        """
        Converts the ClusterValue object to a JSON string.

        Returns:
            str: The JSON representation of the ClusterValue object.
        """
        return json.dumps(self, cls=EncodeClusters, sort_keys=True)

    def to_dict(self) -> Dict[str, Any]:
        """
        Converts the ClusterValue object to a dictionary.

        Returns:
            Dict[str, Any]: The dictionary representation of the ClusterValue object.
        """
        to_return = {'value': self.value}
        if self.uuid:
            to_return['uuid'] = self.uuid
        if self.description:
            to_return['description'] = self.description
        if self.meta:
            to_return['meta'] = self.meta.to_dict()
        if self.related:
            to_return['related'] = sorted(self.related, key=lambda x: x['dest-uuid'])
        return to_return


class Cluster(Mapping):  # type: ignore
    """
    Represents a cluster in the PyMISPGalaxies library.

    Attributes:
        cluster (Union[Dict[str, Any], str]): The dictionary containing the cluster data or the name of the existing cluster to load from the data folder.
        name (str): The name of the cluster.
        type (str): The type of the cluster.
        source (str): The source of the cluster.
        authors (List[str]): The authors of the cluster.
        description (str): The description of the cluster.
        uuid (str): The UUID of the cluster.
        version (int): The version of the cluster.
        category (str): The category of the cluster.
        cluster_values (Dict[str, ClusterValue]): A dictionary containing the cluster values, where the keys are the values of the cluster and the values are instances of the ClusterValue class.
        duplicates (List[Tuple[str, str]]): A list of tuples representing duplicate values in the cluster, where each tuple contains the name of the cluster and the duplicate value.
        _init_hash (int): The hash of the json representation of the cluster at __init__().

    Methods:
        __init__(self, cluster: Union[Dict[str, Any], str], skip_duplicates: bool = False): Initializes a Cluster object from a dict or existing cluster file
        search(self, query: str, return_tags: bool = False) -> Union[List[ClusterValue], List[str]]: Searches for values in the cluster that match the given query.
        machinetags(self) -> List[str]: Returns a list of machine tags for the cluster.
        get_by_external_id(self, external_id: str) -> ClusterValue: Returns the cluster value with the specified external ID.
        save(self, name:str) -> None: Saves the cluster to a file <name>.json
        __str__(self) -> str: Returns a string representation of the cluster.
        __getitem__(self, name: str) -> ClusterValue: Returns the cluster value with the specified name.
        __len__(self) -> int: Returns the number of cluster values in the cluster.
        __iter__(self) -> Iterator[str]: Returns an iterator over the cluster values.
        to_json(self) -> str: Converts the Cluster object to a JSON string.
        to_dict(self) -> Dict[str, Any]: Converts the Cluster object to a dictionary.
    """

    def __init__(self, cluster: Union[Dict[str, Any], str], skip_duplicates: bool = False):
        """
        Initializes a Cluster object from an existing cluster.

        Args:
            cluster (str): The name of the existing cluster to load from the data folder.
            cluster (Dict[str, Any]): A dictionary containing the cluster data.
            skip_duplicates (bool, optional): Flag indicating whether to skip duplicate values. Defaults to False.
        """
        if isinstance(cluster, str):
            root_dir_clusters = os.path.join(os.path.abspath(os.path.dirname(sys.modules['pymispgalaxies'].__file__)), 'data', 'misp-galaxy', 'clusters')  # type: ignore [type-var, arg-type]
            cluster_file = os.path.join(root_dir_clusters, f"{cluster}.json")
            with open(cluster_file, 'r') as f:
                self.cluster = json.load(f)
        else:
            self.cluster = cluster
        self.name: str = self.cluster['name']
        self.type: str = self.cluster['type']
        self.source: str = self.cluster['source']
        self.authors: Set[str] = set(self.cluster['authors'])
        self.description: str = self.cluster['description']
        self.uuid: str = self.cluster['uuid']
        self.version: int = self.cluster['version']
        self.category: str = self.cluster['category']
        self.cluster_values: Dict[str, Any] = {}
        self.duplicates: List[Tuple[str, str]] = []
        try:
            for value in self.cluster['values']:
                new_cluster_value = ClusterValue(value)
                self.append(new_cluster_value, skip_duplicates)
        except KeyError:
            pass
        self._init_hash: int = hash(self.to_json())

    @overload
    def search(self, query: str, return_tags: Literal[False] = False) -> List[ClusterValue]:
        ...

    @overload
    def search(self, query: str, return_tags: Literal[True]) -> List[str]:
        ...

    @overload
    def search(self, query: str, return_tags: bool) -> Union[List[ClusterValue], List[str]]:
        ...

    def search(self, query: str, return_tags: bool = False) -> Union[List[ClusterValue], List[str]]:
        """
        Searches for values in the cluster that match the given query.

        Args:
            query (str): The query to search for.
            return_tags (bool, optional): Flag indicating whether to return machine tags instead of cluster values. Defaults to False.

        Returns:
            Union[List[ClusterValue], List[str]]: A list of matching cluster values or machine tags.
        """
        matching = []
        for v in self.values():
            if [s for s in v.searchable if query.lower() in s.lower()]:
                if return_tags:
                    matching.append('misp-galaxy:{}="{}"'.format(self.type, v.value))
                else:
                    matching.append(v)
        return matching

    def machinetags(self) -> List[str]:
        """
        Returns a list of machine tags for the cluster.

        Returns:
            List[str]: A list of machine tags.
        """
        to_return = []
        for v in self.values():
            to_return.append('misp-galaxy:{}="{}"'.format(self.type, v.value))
        return to_return

    def get_by_external_id(self, external_id: str) -> ClusterValue:
        """
        Returns the cluster value with the specified external ID.

        Args:
            external_id (str): The external ID to search for.

        Returns:
            ClusterValue: The cluster value with the specified external ID.

        Raises:
            KeyError: If no value with the specified external ID is found.
        """
        for value in self.cluster_values.values():
            if value.meta and value.meta.additional_properties and value.meta.additional_properties.get('external_id') == external_id:
                return value
        raise KeyError('No value with external_id: {}'.format(external_id))

    def get_kill_chain_tactics(self) -> Dict[str, List[str]]:
        """
        Returns the sorted kill chain tactics associated with the cluster.

        Returns:
            List[str]: A list of kill chain tactics.
        """
        items = set()
        for v in self.cluster_values.values():
            if v.meta and v.meta.additional_properties and v.meta.additional_properties.get('kill_chain'):
                for item in v.meta.additional_properties.get('kill_chain'):
                    items.add(item)
        result: Dict[str, List[str]] = {}
        for item in items:
            key, value = item.split(':')
            if key not in result:
                result[key] = []
            result[key].append(value)

        for key in result.keys():
            result[key] = sorted(result[key])
        return result

    def append(self, cv: Union[Dict[str, Any], ClusterValue], skip_duplicates: bool = False) -> None:
        """
        Adds a cluster value to the cluster, and merge it if it already exists.

        Args:
            cv (Union[Dict[str, Any], ClusterValue]): The cluster value to add.
            skip_duplicates (bool, optional): Flag indicating whether to skip duplicate values. Defaults to False.
        """
        if isinstance(cv, dict):
            cv = ClusterValue(cv)
        existing = self.get(cv.value)
        if existing:
            if cv.uuid == existing.uuid:
                # merge the existing
                self.cluster_values[cv.value.lower()].merge(cv)
                return
            elif skip_duplicates:
                self.duplicates.append((self.name, cv.value))
            else:
                raise PyMISPGalaxiesError("Duplicate value ({}) in cluster: {}".format(cv.value, self.name))
        self.cluster_values[cv.value.lower()] = cv

    def save(self, name: str, update_version: bool = True) -> None:
        """
        Saves the cluster to a file <name>.json

        Args:
            name (str): The name of the file to save the cluster to.
            update_version (bool, optional): Flag indicating whether to update the version if the cluster changed. Defaults to True.
        """
        if update_version and self.has_changed():
            self.version += 1
        root_dir_clusters = os.path.join(os.path.abspath(os.path.dirname(sys.modules['pymispgalaxies'].__file__)), 'data', 'misp-galaxy', 'clusters')  # type: ignore [type-var, arg-type]
        cluster_file = os.path.join(root_dir_clusters, f"{name}.json")
        with open(cluster_file, 'w') as f:
            json.dump(self, f, cls=EncodeClusters, indent=2, sort_keys=True, ensure_ascii=False)
            f.write('\n')  # needed for the beauty and to be compliant with jq_all_the_things

    def has_changed(self) -> bool:
        """
        Checks if the cluster has changed since initialization.

        Returns:
            bool: True if the cluster has changed, False otherwise.
        """
        return hash(self.to_json()) != self._init_hash

    def __str__(self) -> str:
        """
        Returns a string representation of the cluster.

        Returns:
            str: A string representation of the cluster.
        """
        return '\n'.join(self.machinetags())

    def __getitem__(self, name: str) -> ClusterValue:
        """
        Returns the cluster value with the specified name.

        Args:
            name (str): The name of the cluster value.

        Returns:
            ClusterValue: The cluster value with the specified name.
        """
        return self.cluster_values[name.lower()]

    def __len__(self) -> int:
        """
        Returns the number of cluster values in the cluster.

        Returns:
            int: The number of cluster values.
        """
        return len(self.cluster_values)

    def __iter__(self) -> Iterator[str]:
        """
        Returns an iterator over the cluster values.

        Returns:
            Iterator[str]: An iterator over the cluster values.
        """
        return iter(self.cluster_values)

    def to_json(self) -> str:
        """
        Converts the Cluster object to a JSON string.

        Returns:
            str: The JSON representation of the Cluster object.
        """
        return json.dumps(self, cls=EncodeClusters, sort_keys=True)

    def to_dict(self) -> Dict[str, Any]:
        """
        Converts the Cluster object to a dictionary.

        Returns:
            Dict[str, Any]: The dictionary representation of the Cluster object.
        """
        to_return = {'name': self.name, 'type': self.type, 'source': self.source,
                     'authors': sorted(list(self.authors)), 'description': self.description,
                     'uuid': self.uuid, 'version': self.version, 'category': self.category,
                     'values': []}
        to_return['values'] = [v for v in self.values()]
        return to_return


class Clusters(Mapping):  # type: ignore

    def __init__(self, clusters: List[Dict[str, str]] = [], skip_duplicates: bool = False):
        """
        Allows to interact with a group of clusters.

        Args:
            clusters (List[Dict[str, str]], optional): A list of dictionaries representing clusters. If left empty, load the clusters from the data folder.
            skip_duplicates (bool, optional): Flag indicating whether to skip duplicate clusters. Defaults to False.
        """
        if not clusters:
            clusters = []
            self.root_dir_clusters = os.path.join(os.path.abspath(os.path.dirname(sys.modules['pymispgalaxies'].__file__)),  # type: ignore
                                                  'data', 'misp-galaxy', 'clusters')
            for cluster_file in glob(os.path.join(self.root_dir_clusters, '*.json')):
                with open(cluster_file, 'r') as f:
                    clusters.append(json.load(f))
        self.clusters = {}
        for cluster in clusters:
            self.clusters[cluster['type']] = Cluster(cluster, skip_duplicates=skip_duplicates)

    def validate_with_schema(self) -> None:
        """
        Validates the clusters against the schema.

        Raises:
            ImportError: If jsonschema is not installed.
        """
        if not HAS_JSONSCHEMA:
            raise ImportError('jsonschema is required: pip install jsonschema')
        schema = os.path.join(os.path.abspath(os.path.dirname(sys.modules['pymispgalaxies'].__file__)),  # type: ignore
                              'data', 'misp-galaxy', 'schema_clusters.json')
        with open(schema, 'r') as f:
            loaded_schema = json.load(f)
        for c in self.values():
            jsonschema.validate(c.cluster, loaded_schema)

    def all_machinetags(self) -> List[str]:
        """
        Returns a list of all machinetags in the clusters.

        Returns:
            List[str]: A list of machinetags.
        """
        return [cluster.machinetags() for cluster in self.values()]

    def revert_machinetag(self, machinetag: str) -> Tuple[Cluster, ClusterValue]:
        """
        Reverts a machinetag to its original cluster and value.

        Args:
            machinetag (str): The machinetag to revert.

        Returns:
            Tuple[Cluster, ClusterValue]: A tuple containing the original cluster and value.

        Raises:
            UnableToRevertMachinetag: If the machinetag could not be found.
        """
        try:
            _, cluster_type, cluster_value = re.findall('^([^:]*):([^=]*)="([^"]*)"$', machinetag)[0]
            cluster: Cluster = self[cluster_type]
            value: ClusterValue = cluster[cluster_value]
            return cluster, value
        except Exception:
            raise UnableToRevertMachinetag('The machinetag {} could not be found.'.format(machinetag))

    def search(self, query: str, return_tags: bool = False) -> List[Tuple[Cluster, str]]:
        """
        Searches for clusters and values matching the given query.

        Args:
            query (str): The query to search for.
            return_tags (bool, optional): Flag indicating whether to return the matching tags. Defaults to False.

        Returns:
            List[Tuple[Cluster, str]]: A list of tuples containing the matching cluster and value.

        """
        to_return = []
        for cluster in self.values():
            values = cluster.search(query, return_tags)
            if not values:
                continue
            to_return.append((cluster, values))
        return to_return

    def __getitem__(self, name: str) -> Cluster:
        """
        Returns the cluster with the specified name.

        Args:
            name (str): The name of the cluster.

        Returns:
            Cluster: The cluster object.

        Raises:
            KeyError: If the cluster with the specified name does not exist.
        """
        return self.clusters[name]

    def __iter__(self) -> Iterator[str]:
        """
        Returns an iterator over the cluster names.

        Returns:
            Iterator[str]: An iterator over the cluster names.
        """
        return iter(self.clusters)

    def __len__(self) -> int:
        """
        Returns the number of clusters.

        Returns:
            int: The number of clusters.
        """
        return len(self.clusters)

    def __str__(self) -> str:
        """
        Returns a string representation of the Clusters object.

        Returns:
            str: A string representation of the Clusters object.
        """
        to_print = ''
        for cluster in self.values():
            to_print += '{}\n\n'.format(cluster)
        return to_print
