import functools
import hashlib
import json
import os
import shutil
import sys
from pathlib import Path
from typing import Dict, List

import yaml
from rich import print
from rich.table import Table
from ruamel.yaml import YAML

import _nebari
from _nebari.deprecate import DEPRECATED_FILE_PATHS
from _nebari.provider.cicd.github import gen_nebari_linter, gen_nebari_ops
from _nebari.provider.cicd.gitlab import gen_gitlab_ci
from _nebari.stages import tf_objects
from _nebari.utils import is_relative_to


def render_template(output_directory, config_filename, dry_run=False):
    # get directory for nebari templates
    template_directory = Path(_nebari.__file__).parent / "template"

    # would be nice to remove assumption that input directory
    # is in local filesystem and a directory
    if not template_directory.is_dir():
        raise ValueError(f"template directory={template_directory} is not a directory")

    if output_directory == Path.home():
        print("ERROR: Deploying Nebari in home directory is not advised!")
        sys.exit(1)

    # mkdir all the way down to repo dir so we can copy .gitignore
    # into it in remove_existing_renders
    output_directory.mkdir(exist_ok=True, parents=True)

    if not config_filename.is_file():
        raise ValueError(
            f"cookiecutter configuration={config_filename} is not filename"
        )

    with open(config_filename) as f:
        yaml = YAML(typ="safe", pure=True)
        config = yaml.load(f)

    # For any config values that start with
    # NEBARI_SECRET_, set the values using the
    # corresponding env var.
    set_env_vars_in_config(config)

    config["repo_directory"] = output_directory.name
    config["nebari_config_yaml_path"] = str(config_filename.absolute())

    contents = render_contents(config)

    directories = [
        f"stages/02-infrastructure/{config['provider']}",
        "stages/03-kubernetes-initialize",
        "stages/04-kubernetes-ingress",
        "stages/05-kubernetes-keycloak",
        "stages/06-kubernetes-keycloak-configuration",
        "stages/07-kubernetes-services",
        "stages/08-nebari-tf-extensions",
    ]
    if (
        config["provider"] not in {"existing", "local"}
        and config["terraform_state"]["type"] == "remote"
    ):
        directories.append(f"stages/01-terraform-state/{config['provider']}")

    source_dirs = [template_directory / Path(directory) for directory in directories]
    output_dirs = [output_directory / Path(directory) for directory in directories]
    new, untracked, updated, deleted = inspect_files(
        source_dirs,
        output_dirs,
        source_base_dir=template_directory,
        output_base_dir=output_directory,
        ignore_filenames=[
            "terraform.tfstate",
            ".terraform.lock.hcl",
            "terraform.tfstate.backup",
        ],
        ignore_directories=[
            ".terraform",
            "__pycache__",
        ],
        deleted_paths=DEPRECATED_FILE_PATHS,
        contents=contents,
    )

    if new:
        table = Table("The following files will be created:", style="deep_sky_blue1")
        for filename in sorted(new):
            table.add_row(str(filename), style="green")
        print(table)
    if updated:
        table = Table("The following files will be updated:", style="deep_sky_blue1")
        for filename in sorted(updated):
            table.add_row(str(filename), style="green")
        print(table)
    if deleted:
        table = Table("The following files will be deleted:", style="deep_sky_blue1")
        for filename in sorted(deleted):
            table.add_row(str(filename), style="green")
        print(table)
    if untracked:
        table = Table(
            "The following files are untracked (only exist in output directory):",
            style="deep_sky_blue1",
        )
        for filename in sorted(updated):
            table.add_row(str(filename), style="green")
        print(table)

    if dry_run:
        print("dry-run enabled no files will be created, updated, or deleted")
    else:
        for filename in new | updated:
            input_filename = template_directory / filename
            output_filename = output_directory / filename
            output_filename.parent.mkdir(parents=True, exist_ok=True)

            if input_filename.exists():
                shutil.copy(input_filename, output_filename)
            else:
                with open(output_filename, "w") as f:
                    f.write(contents[filename])

        for path in deleted:
            abs_path = (output_directory / path).resolve()

            if not is_relative_to(abs_path, output_directory):
                raise Exception(
                    f"[ERROR] SHOULD NOT HAPPEN filename was about to be deleted but path={abs_path} is outside of output_directory"
                )

            if abs_path.is_file():
                abs_path.unlink()
            elif abs_path.is_dir():
                shutil.rmtree(abs_path)


def render_contents(config: Dict):
    """Dynamically generated contents from _nebari configuration."""
    contents = {
        **tf_objects.stage_01_terraform_state(config),
        **tf_objects.stage_02_infrastructure(config),
        **tf_objects.stage_03_kubernetes_initialize(config),
        **tf_objects.stage_04_kubernetes_ingress(config),
        **tf_objects.stage_05_kubernetes_keycloak(config),
        **tf_objects.stage_06_kubernetes_keycloak_configuration(config),
        **tf_objects.stage_07_kubernetes_services(config),
        **tf_objects.stage_08_nebari_tf_extensions(config),
    }

    if config.get("ci_cd"):
        for fn, workflow in gen_cicd(config).items():
            workflow_json = workflow.json(
                indent=2,
                by_alias=True,
                exclude_unset=True,
                exclude_defaults=True,
            )
            workflow_yaml = yaml.dump(
                json.loads(workflow_json), sort_keys=False, indent=2
            )
            contents.update({fn: workflow_yaml})

    contents.update(gen_gitignore())

    return contents


def gen_gitignore():
    """
    Generate `.gitignore` file.
    Add files as needed.
    """
    from inspect import cleandoc

    files_to_ignore = """
        # ignore terraform state
        .terraform
        terraform.tfstate
        terraform.tfstate.backup
        .terraform.tfstate.lock.info

        # python
        __pycache__
    """
    return {Path(".gitignore"): cleandoc(files_to_ignore)}


def gen_cicd(config):
    """
    Use cicd schema to generate workflow files based on the
    `ci_cd` key in the `config`.

    For more detail on schema:
    GiHub-Actions - nebari/providers/cicd/github.py
    GitLab-CI - nebari/providers/cicd/gitlab.py
    """
    cicd_files = {}
    cicd_provider = config["ci_cd"]["type"]

    if cicd_provider == "github-actions":
        gha_dir = Path(".github") / "workflows"
        cicd_files[gha_dir / "nebari-ops.yaml"] = gen_nebari_ops(config)
        cicd_files[gha_dir / "nebari-linter.yaml"] = gen_nebari_linter(config)

    elif cicd_provider == "gitlab-ci":
        cicd_files[Path(".gitlab-ci.yml")] = gen_gitlab_ci(config)

    else:
        raise ValueError(
            f"The ci_cd provider, {cicd_provider}, is not supported. Supported providers include: `github-actions`, `gitlab-ci`."
        )

    return cicd_files


def inspect_files(
    source_dirs: Path,
    output_dirs: Path,
    source_base_dir: Path,
    output_base_dir: Path,
    ignore_filenames: List[str] = None,
    ignore_directories: List[str] = None,
    deleted_paths: List[Path] = None,
    contents: Dict[str, str] = None,
):
    """Return created, updated and untracked files by computing a checksum over the provided directory.

    Args:
        source_dirs (Path): The source dir used as base for comparison
        output_dirs (Path): The destination dir which will be matched with
        source_base_dir (Path): Relative base path to source directory
        output_base_dir (Path): Relative base path to output directory
        ignore_filenames (list[str]): Filenames to ignore while comparing for changes
        ignore_directories (list[str]): Directories to ignore while comparing for changes
        deleted_paths (list[Path]): Paths that if exist in output directory should be deleted
        contents (dict): filename to content mapping for dynamically generated files
    """
    ignore_filenames = ignore_filenames or []
    ignore_directories = ignore_directories or []
    contents = contents or {}

    source_files = {}
    output_files = {}

    def list_files(
        directory: Path, ignore_filenames: List[str], ignore_directories: List[str]
    ):
        for path in directory.rglob("*"):
            if not path.is_file():
                continue

            if path.name in ignore_filenames:
                continue

            if any(
                d in ignore_directories for d in path.relative_to(directory).parts[:-1]
            ):
                continue

            yield path

    for filename, content in contents.items():
        source_files[filename] = hashlib.sha256(content.encode("utf8")).hexdigest()
        output_filename = output_base_dir / filename
        if output_filename.is_file():
            output_files[filename] = hash_file(filename)

    deleted_files = set()
    for path in deleted_paths:
        absolute_path = output_base_dir / path
        if absolute_path.exists():
            deleted_files.add(path)

    for source_dir, output_dir in zip(source_dirs, output_dirs):
        for filename in list_files(source_dir, ignore_filenames, ignore_directories):
            relative_path = filename.relative_to(source_base_dir)
            source_files[relative_path] = hash_file(filename)

        for filename in list_files(output_dir, ignore_filenames, ignore_directories):
            relative_path = filename.relative_to(output_base_dir)
            output_files[relative_path] = hash_file(filename)

    new_files = source_files.keys() - output_files.keys()
    untracted_files = output_files.keys() - source_files.keys()

    updated_files = set()
    for prevalent_file in source_files.keys() & output_files.keys():
        if source_files[prevalent_file] != output_files[prevalent_file]:
            updated_files.add(prevalent_file)

    return new_files, untracted_files, updated_files, deleted_paths


def hash_file(file_path: str):
    """Get the hex digest of the given file.

    Args:
        file_path (str): path to file
    """
    with open(file_path, "rb") as f:
        return hashlib.sha256(f.read()).hexdigest()


def set_env_vars_in_config(config):
    """

    For values in the config starting with 'NEBARI_SECRET_XXX' the environment
    variables are searched for the pattern XXX and the config value is
    modified. This enables setting secret values that should not be directly
    stored in the config file.

    NOTE: variables are most likely written to a file somewhere upon render. In
    order to further reduce risk of exposure of any of these variables you might
    consider preventing storage of the terraform render output.
    """
    private_entries = get_secret_config_entries(config)
    for idx in private_entries:
        set_nebari_secret(config, idx)


def get_secret_config_entries(config, config_idx=None, private_entries=None):
    output = private_entries or []
    if config_idx is None:
        sub_dict = config
        config_idx = []
    else:
        sub_dict = get_sub_config(config, config_idx)

    for key, value in sub_dict.items():
        if type(value) is dict:
            sub_dict_outputs = get_secret_config_entries(
                config, [*config_idx, key], private_entries
            )
            output = [*output, *sub_dict_outputs]
        else:
            if "NEBARI_SECRET_" in str(value):
                output = [*output, [*config_idx, key]]
    return output


def get_sub_config(conf, conf_idx):
    sub_config = functools.reduce(dict.__getitem__, conf_idx, conf)
    return sub_config


def set_sub_config(conf, conf_idx, value):
    get_sub_config(conf, conf_idx[:-1])[conf_idx[-1]] = value


def set_nebari_secret(config, idx):
    placeholder = get_sub_config(config, idx)
    secret_var = get_nebari_secret(placeholder)
    set_sub_config(config, idx, secret_var)


def get_nebari_secret(secret_var):
    env_var = secret_var.lstrip("NEBARI_SECRET_")
    val = os.environ.get(env_var)
    if not val:
        raise EnvironmentError(
            f"Since '{secret_var}' was found in the"
            " Nebari config, the environment variable"
            f" '{env_var}' must be set."
        )
    return val
