"""Fake Filesystem Generator
============================

This script generates a file structure based on supplied configuration and
template files, with the ability to replace placeholders with generated
patterns. This enables a simple configuration file to generate many folders
and files with dates, random numbers, and incrementing numbers in their name.
Additionally, the configuration file supplies a path to template files that
are copied to the new location and can be any type, content, etc.

Through this, we can quickly mimic a realistic environment, planting real files
across a folder structure.

.. note:: Please read the sample configuration file for in-depth documentation
  on how to specify configurations this script can consume.


Class documentation
-----------------------

"""
import argparse
import os
import shutil
import sys
import logging
import datetime
import json
from collections import OrderedDict
import random
from typing import Iterable, Union, Generator, List

from ruamel.yaml import YAML

logger = logging.getLogger()


class ApplyPatterns:
    """A class to apply patterns, using metadata to influence how pattern
    replacement text is generated.

    Attributes:
        patterns: A dictionary mapping the pattern identifier to the class
          method that can apply the pattern using the later supplied metadata.
    """
    def __init__(self):
        self.patterns = {
            "{YEAR}": self.apply_year,
            "{MONTH}": self.apply_month,
            "{RAND_NUM}": self.apply_random_number,
            "{INCREMENT_NUMBER}": self.apply_incrementing_number
        }

    def apply(self, pattern: str, meta: dict) -> Iterable:
        """Apply a pattern

        This searches for the pattern identifier within self.patterns and,
        if the identifier is found, calls the relevant class method to render
        the pattern's replacement value(s).

        Args:
            pattern: The string pattern identifier, one of the keys in the
              self.patterns dictionary
            meta: Metadata dictionary that contains values to control how a
              pattern should apply. Specified in `_meta` within the
              configuration file, as a child to the object to configure.

        Returns:
            Iterable: An object that we can iterate over in a for loop
        """

        pattern_func = self.patterns.get(pattern)

        if not pattern_func:
            return []

        return pattern_func(meta)

    @staticmethod
    def return_number_range(min_value: int, max_value: int) -> Generator[int, None, None]:
        """Returns a range of integers.

        Args:
            min_value: The minimum integer for the range
            max_value: The maximum integer for the range. We add 1 to this value
              so the range is inclusive of this value.

        Returns:
            Generator: An object that we can iterate over in a for loop
        """
        return range(min_value, max_value+1)

    @staticmethod
    def apply_random_number(meta: dict) -> Generator[str, None, None]:
        """Apply a pattern that yields a pseudorandom number.

        The meta dictionary may contain the following keys:
        * padding: The number of leading zeros to apply to the generated number.
        * num_instances: The number of pseudorandom numbers to generate.
        * min_value: The minimum of the numeric range to pseudorandomly select.
        * max_value: The maximum of the numeric range to pseudorandomly select.

        Args:
            meta: The metadata use to influence how the number is generated.

        Yield:
            str: The padding formatted string containing the generated number.
        """
        fmt_string = "{{:0{}d}}".format(meta.get("padding", 0))
        for _ in range(meta.get("num_instances", 1)):
            yield fmt_string.format(
                random.randint(
                    meta.get("min_value", 1),
                    meta.get("max_value", 999)+1
                )
            )

    @classmethod
    def apply_incrementing_number(cls, meta: dict) -> Generator[int, None, None]:
        """Apply a pattern that yields an incrementing number.

        The meta dictionary may contain the following keys to influence the
        number of incrementing numbers generated by this function:
        * min_number: The minimum of the numeric range to pseudorandomly select.
        * max_number: The maximum of the numeric range to pseudorandomly select.

        Args:
            meta: The metadata use to influence how many numbers are generated.

        Returns:
            Generator: The range of incrementing numbers.
        """
        num_files_to_create = random.randint(
            meta.get("min_number", 1),
            meta.get("max_number", 5)
        )
        return cls.return_number_range(1, num_files_to_create)

    @classmethod
    def apply_year(cls, meta) -> Generator[int, None, None]:
        """Apply the year pattern using the available metadata.

        The meta dictionary may contain the following keys:
        * min_year: The minimum year to include.
        * max_year: The maximum year to include.

        Args:
            meta: The metadata use to influence the number of years to return.

        Returns:
            Generator: The range of incrementing numbers.
        """
        min_year = meta.get("min_year", 2010)
        max_year = meta.get("max_year", int(datetime.datetime.now().strftime("%Y")))
        return cls.return_number_range(min_year, max_year)

    @classmethod
    def apply_month(cls, meta) -> List[str]:
        """Apply the month pattern using the available metadata.

        The meta dictionary may contain the following keys:
        * min_month: The minimum month to include.
        * max_month: The maximum month to include.

        Args:
            meta: The metadata use to influence the number of months to return.

        Returns:
            Generator: The range of incrementing numbers.
        """
        min_month = meta.get("min_month", 1)
        max_month = meta.get("max_month", 12)
        padding = meta.get("padding")
        if padding:
            fmt_str = "{{:0{}d}}".format(padding)
            range_list = [fmt_str.format(i)
                for i in cls.return_number_range(min_month, max_month)]
            return range_list
        else:
            return [str(x) for x in cls.return_number_range(min_month, max_month)]

class FakeFiles:
    """Class to handle the generation of fake files

    Args:
        dry_run: If true, does everything except create the files and folders

    Attributes:
        config: The loaded configuration
        config_file_path: The file path to the configuration file
        target: The base folder to write data to
        patterns: An instance of ApplyPatterns
        dry_run: If true, prevents creation of files and folders
    """

    def __init__(self, dry_run: bool = False):
        self.config = None
        self.config_file_path = None
        self.target = None
        self.patterns = ApplyPatterns()
        self.dry_run = dry_run

    def load_config(self, config_file_path: str) -> None:
        """Reads the JSON config into self.config

        Arguments:
            config_file_path: Path to the configuration file.

        Returns:
            None. Sets value to self.config
        """
        self.config_file_path = config_file_path
        yaml = YAML(typ="safe")
        with open(config_file_path, 'rb') as open_config_file:
            self.config = yaml.load(open_config_file)

    def makedirs(self, path: str, exist_ok: bool = True) -> None:
        """Wrapper for os.makedirs to handle dry run checks. Also prepends the
        self.target value to the path, if self.target is not None.

        Arguments:
            path: The directory path to create
            exist_ok: Whether it is okay for the folder to already exist.

        Returns:
            None
        """
        if self.dry_run:
            logger.info("DRYRUN: Created folder {}".format(path))
        else:
            os.makedirs(path, exist_ok=exist_ok)
            logger.info("Created folder {}".format(path))

    def valid_config(self) -> bool:
        """Validate a loaded config to ensure required variables are present.

        Returns:
            bool: Whether or not a configuration file is valid
        """
        if self.config.get("plan") is None:
            logger.error("Invalid configuration file. Missing plan key.")
            return False

        # TODO validate subkeys within the plan

        return True

    def run(self, target: str):
        """Runs plan defined within the configuration file, loaded
        into self.config.

        Arguments:
            target: The root directory to write new files to.

        Returns:
            None
        """
        if not self.valid_config():
            return None

        self.target = target
        self.makedirs(target)

        for key, value in self.config.get("plan").items():
            self._build_folder(
                base_path=self.target,
                folder_name=key,
                folder_attributes=value
            )

    def _build_folder(self, base_path: str, folder_name: str, folder_attributes: dict):
        """Construct a folder based on the plan and meta data supplied
        in self.config

        Arguments:
            base_path: The root that folder should be created in
            folder_name: The folder name to use
            folder_attributes: Information about the folder's contents

        Returns:
            None
        """
        # Create the folder if it does not exist
        new_folders = self._create_folders(
            base_path=base_path,
            folder_name=folder_name,
            folder_attributes=folder_attributes
        )
        for folder in new_folders:
            # Check for files to create in this folder
            if "_filenames" in folder_attributes:
                self._create_files(
                    base_folder=folder,
                    folder_attributes=folder_attributes
                )

            # Check for other folders to create
            for key, value in folder_attributes.items():
                if key.startswith("_"):
                    # Skip any metadata or filename definitions
                    continue
                # Recursively call the function to ensure subfolder creation.
                self._build_folder(
                    base_path=folder,
                    folder_name=key,
                    folder_attributes=value
                )

    def _create_folders(self, base_path: str, folder_name: str, folder_attributes: dict) -> str:
        """Creates the folder in the specified path using the defined pattners.

        Yields:
            newly created folder name
        """
        found_patterns = []
        for pattern in self.patterns.patterns:
            if pattern in folder_name:
                found_patterns.append(pattern)

        if not found_patterns:
            # Handle folders without patterns in the name
            new_folder_path = os.path.join(base_path, folder_name)
            self.makedirs(new_folder_path)
            yield new_folder_path
        else:
            # Apply patterns
            for pattern in found_patterns:
                new_values = self.patterns.apply(pattern, folder_attributes.get("_meta", {}))
                for new_value in new_values:
                    new_folder = folder_name.replace(pattern, str(new_value))
                    new_folder_path = os.path.join(base_path, new_folder)
                    self.makedirs(new_folder_path)
                    yield new_folder_path

    def _create_files(self, base_folder, folder_attributes):
        """Creates files in the specified path using the defined
        patterns and template.

        Args:
            folder: The base folder to place newly created files
            file_attributes: The folder attributes, possibly containing _filenames to create

        Returns:
            None
        """
        for file_entry in folder_attributes.get("_filenames", []):
            self._create_file(file_entry=file_entry, base_folder=base_folder, folder_attributes=folder_attributes)

    def _create_file(self, file_entry, base_folder, folder_attributes):
        found_patterns = []
        for pattern in self.patterns.patterns:
            if pattern in file_entry["name"]:
                found_patterns.append(pattern)

        if not found_patterns:
            # Handle folders without patterns in the name
            self._copy_template(
                file_entry=file_entry,
                new_file_name=file_entry["name"],
                base_folder=base_folder
            )
        else:
            # Apply patterns
            for pattern in found_patterns:
                new_values = self.patterns.apply(pattern, file_entry.get("_meta", {}))
                for new_value in new_values:
                    file_name = file_entry["name"].replace(pattern, str(new_value))
                    self._copy_template(file_entry, file_name, base_folder)

    def _copy_template(self, file_entry, new_file_name, base_folder):
        # Get the template location
        config_base_path = os.path.split(self.config_file_path)[0]
        template_file = os.path.join(config_base_path, file_entry["template"])
        new_file = os.path.join(base_folder, new_file_name)

        if self.dry_run:
            logger.info("DRYRUN: Copied file from {} to {}".format(template_file, new_file))

        else:
            logger.info("Copied file from {} to {}".format(template_file, new_file))
            shutil.copyfile(template_file, new_file)


if __name__ == "__main__":
    arg_parser = argparse.ArgumentParser()
    arg_parser.add_argument(
        "target",
        help="Path to write the generated files and folders."
    )
    arg_parser.add_argument(
        "config",
        help="Configuration file to run"
    )
    arg_parser.add_argument(
        "-n",
        "--dry-run",
        help="Dry run, do everything but make files and folders.",
        action="store_true"
    )
    cli_args = arg_parser.parse_args()
    faker = FakeFiles(dry_run=cli_args.dry_run)
    faker.load_config(config_file_path=cli_args.config)
    faker.run(target=cli_args.target)
