"""Functions for generating a project from a project template."""

from __future__ import annotations

import fnmatch
import json
import logging
import os
import shutil
import warnings
from collections import OrderedDict
from pathlib import Path
from typing import Any

from binaryornot.check import is_binary
from jinja2 import Environment, FileSystemLoader
from jinja2.exceptions import TemplateSyntaxError, UndefinedError
from rich.prompt import InvalidResponse

from dxh_py.exceptions import (
    ContextDecodingException,
    EmptyDirNameException,
    OutputDirExistsException,
    UndefinedVariableInTemplate,
)
from dxh_py.find import find_template
from dxh_py.hooks import run_hook_from_repo_dir
from dxh_py.prompt import YesNoPrompt
from dxh_py.utils import (
    create_env_with_context,
    make_sure_path_exists,
    rmtree,
    work_in,
)

logger = logging.getLogger(__name__)


def is_copy_only_path(path: str, context: dict[str, Any]) -> bool:
    """Check whether the given `path` should only be copied and not rendered.

    Returns True if `path` matches a pattern in the given `context` dict,
    otherwise False.

    :param path: A file-system path referring to a file or dir that
        should be rendered or just copied.
    :param context: dxh_py context.
    """
    try:
        for dont_render in context['dxh_py']['_copy_without_render']:
            if fnmatch.fnmatch(path, dont_render):
                return True
    except KeyError:
        return False

    return False


def apply_overwrites_to_context(
    context: dict[str, Any],
    overwrite_context: dict[str, Any],
    *,
    in_dictionary_variable: bool = False,
) -> None:
    """Modify the given context in place based on the overwrite_context."""
    for variable, overwrite in overwrite_context.items():
        if variable not in context:
            if not in_dictionary_variable:
                # We are dealing with a new variable on first level, ignore
                continue
            # We are dealing with a new dictionary variable in a deeper level
            context[variable] = overwrite

        context_value = context[variable]
        if isinstance(context_value, list):
            if in_dictionary_variable:
                context[variable] = overwrite
                continue
            if isinstance(overwrite, list):
                # We are dealing with a multichoice variable
                # Let's confirm all choices are valid for the given context
                if set(overwrite).issubset(set(context_value)):
                    context[variable] = overwrite
                else:
                    raise ValueError(
                        f"{overwrite} provided for multi-choice variable "
                        f"{variable}, but valid choices are {context_value}"
                    )
            else:
                # We are dealing with a choice variable
                if overwrite in context_value:
                    # This overwrite is actually valid for the given context
                    # Let's set it as default (by definition first item in list)
                    # see ``dxh_py.prompt.prompt_choice_for_config``
                    context_value.remove(overwrite)
                    context_value.insert(0, overwrite)
                else:
                    raise ValueError(
                        f"{overwrite} provided for choice variable "
                        f"{variable}, but the choices are {context_value}."
                    )
        elif isinstance(context_value, dict) and isinstance(overwrite, dict):
            # Partially overwrite some keys in original dict
            apply_overwrites_to_context(
                context_value, overwrite, in_dictionary_variable=True
            )
            context[variable] = context_value
        elif isinstance(context_value, bool) and isinstance(overwrite, str):
            # We are dealing with a boolean variable
            # Convert overwrite to its boolean counterpart
            try:
                context[variable] = YesNoPrompt().process_response(overwrite)
            except InvalidResponse as err:
                raise ValueError(
                    f"{overwrite} provided for variable "
                    f"{variable} could not be converted to a boolean."
                ) from err
        else:
            # Simply overwrite the value for this variable
            context[variable] = overwrite


def generate_context(
    context_file: str = 'dxh_py.json',
    default_context: dict[str, Any] | None = None,
    extra_context: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """Generate the context for a dxh_py project template.

    Loads the JSON file as a Python object, with key being the JSON filename.

    :param context_file: JSON file containing key/value pairs for populating
        the dxh_py's variables.
    :param default_context: Dictionary containing config to take into account.
    :param extra_context: Dictionary containing configuration overrides
    """
    context = OrderedDict([])

    try:
        with open(context_file, encoding='utf-8') as file_handle:
            obj = json.load(file_handle, object_pairs_hook=OrderedDict)
    except ValueError as e:
        # JSON decoding error.  Let's throw a new exception that is more
        # friendly for the developer or user.
        full_fpath = os.path.abspath(context_file)
        json_exc_message = str(e)
        our_exc_message = (
            f"JSON decoding error while loading '{full_fpath}'. "
            f"Decoding error details: '{json_exc_message}'"
        )
        raise ContextDecodingException(our_exc_message) from e

    # Add the Python object to the context dictionary
    file_name = os.path.split(context_file)[1]
    file_stem = file_name.split('.')[0]
    context[file_stem] = obj

    # Overwrite context variable defaults with the default context from the
    # user's global config, if available
    if default_context:
        try:
            apply_overwrites_to_context(obj, default_context)
        except ValueError as error:
            warnings.warn(f"Invalid default received: {error}")
    if extra_context:
        apply_overwrites_to_context(obj, extra_context)

    logger.debug('Context generated is %s', context)
    return context


def generate_file(
    project_dir: str,
    infile: str,
    context: dict[str, Any],
    env: Environment,
    skip_if_file_exists: bool = False,
) -> None:
    """Render filename of infile as name of outfile, handle infile correctly.

    Dealing with infile appropriately:

        a. If infile is a binary file, copy it over without rendering.
        b. If infile is a text file, render its contents and write the
           rendered infile to outfile.

    Precondition:

        When calling `generate_file()`, the root template dir must be the
        current working directory. Using `utils.work_in()` is the recommended
        way to perform this directory change.

    :param project_dir: Absolute path to the resulting generated project.
    :param infile: Input file to generate the file from. Relative to the root
        template dir.
    :param context: Dict for populating the dxh_py's variables.
    :param env: Jinja2 template execution environment.
    """
    logger.debug('Processing file %s', infile)

    # Render the path to the output file (not including the root project dir)
    outfile_tmpl = env.from_string(infile)

    outfile = os.path.join(project_dir, outfile_tmpl.render(**context))
    file_name_is_empty = os.path.isdir(outfile)
    if file_name_is_empty:
        logger.debug('The resulting file name is empty: %s', outfile)
        return

    if skip_if_file_exists and os.path.exists(outfile):
        logger.debug('The resulting file already exists: %s', outfile)
        return

    logger.debug('Created file at %s', outfile)

    # Just copy over binary files. Don't render.
    logger.debug("Check %s to see if it's a binary", infile)
    if is_binary(infile):
        logger.debug('Copying binary %s to %s without rendering', infile, outfile)
        shutil.copyfile(infile, outfile)
        shutil.copymode(infile, outfile)
        return

    # Force fwd slashes on Windows for get_template
    # This is a by-design Jinja issue
    infile_fwd_slashes = infile.replace(os.path.sep, '/')

    # Render the file
    try:
        tmpl = env.get_template(infile_fwd_slashes)
    except TemplateSyntaxError as exception:
        # Disable translated so that printed exception contains verbose
        # information about syntax error location
        exception.translated = False
        raise
    rendered_file = tmpl.render(**context)

    if context['dxh_py'].get('_new_lines', False):
        # Use `_new_lines` from context, if configured.
        newline = context['dxh_py']['_new_lines']
        logger.debug('Using configured newline character %s', repr(newline))
    else:
        # Detect original file newline to output the rendered file.
        # Note that newlines can be a tuple if file contains mixed line endings.
        # In this case, we pick the first line ending we detected.
        with open(infile, encoding='utf-8') as rd:
            rd.readline()  # Read only the first line to load a 'newlines' value.
        newline = rd.newlines[0] if isinstance(rd.newlines, tuple) else rd.newlines
        logger.debug('Using detected newline character %s', repr(newline))

    logger.debug('Writing contents to file %s', outfile)

    with open(outfile, 'w', encoding='utf-8', newline=newline) as fh:
        fh.write(rendered_file)

    # Apply file permissions to output file
    shutil.copymode(infile, outfile)


def render_and_create_dir(
    dirname: str,
    context: dict[str, Any],
    output_dir: Path | str,
    environment: Environment,
    overwrite_if_exists: bool = False,
) -> tuple[Path, bool]:
    """Render name of a directory, create the directory, return its path."""
    if not dirname or dirname == "":
        msg = 'Error: directory name is empty'
        raise EmptyDirNameException(msg)

    name_tmpl = environment.from_string(dirname)
    rendered_dirname = name_tmpl.render(**context)

    dir_to_create = Path(output_dir, rendered_dirname)

    logger.debug(
        'Rendered dir %s must exist in output_dir %s', dir_to_create, output_dir
    )

    output_dir_exists = dir_to_create.exists()

    if output_dir_exists:
        if overwrite_if_exists:
            logger.debug(
                'Output directory %s already exists, overwriting it', dir_to_create
            )
        else:
            msg = f'Error: "{dir_to_create}" directory already exists'
            raise OutputDirExistsException(msg)
    else:
        make_sure_path_exists(dir_to_create)

    return dir_to_create, not output_dir_exists


def _run_hook_from_repo_dir(
    repo_dir: str,
    hook_name: str,
    project_dir: Path | str,
    context: dict[str, Any],
    delete_project_on_failure: bool,
) -> None:
    """Run hook from repo directory, clean project directory if hook fails.

    :param repo_dir: Project template input directory.
    :param hook_name: The hook to execute.
    :param project_dir: The directory to execute the script from.
    :param context: dxh_py project context.
    :param delete_project_on_failure: Delete the project directory on hook
        failure?
    """
    warnings.warn(
        "The '_run_hook_from_repo_dir' function is deprecated, "
        "use 'dxh_py.hooks.run_hook_from_repo_dir' instead",
        DeprecationWarning,
        2,
    )
    run_hook_from_repo_dir(
        repo_dir, hook_name, project_dir, context, delete_project_on_failure
    )


def generate_files(
    repo_dir: Path | str,
    context: dict[str, Any] | None = None,
    output_dir: Path | str = '.',
    overwrite_if_exists: bool = False,
    skip_if_file_exists: bool = False,
    accept_hooks: bool = True,
    keep_project_on_failure: bool = False,
) -> str:
    """Render the templates and saves them to files.

    :param repo_dir: Project template input directory.
    :param context: Dict for populating the template's variables.
    :param output_dir: Where to output the generated project dir into.
    :param overwrite_if_exists: Overwrite the contents of the output directory
        if it exists.
    :param skip_if_file_exists: Skip the files in the corresponding directories
        if they already exist
    :param accept_hooks: Accept pre and post hooks if set to `True`.
    :param keep_project_on_failure: If `True` keep generated project directory even when
        generation fails
    """
    context = context or OrderedDict([])

    env = create_env_with_context(context)

    template_dir = find_template(repo_dir, env)
    logger.debug('Generating project from %s...', template_dir)

    unrendered_dir = os.path.split(template_dir)[1]
    try:
        project_dir: Path | str
        project_dir, output_directory_created = render_and_create_dir(
            unrendered_dir, context, output_dir, env, overwrite_if_exists
        )
    except UndefinedError as err:
        msg = f"Unable to create project directory '{unrendered_dir}'"
        raise UndefinedVariableInTemplate(msg, err, context) from err

    # We want the Jinja path and the OS paths to match. Consequently, we'll:
    #   + CD to the template folder
    #   + Set Jinja's path to '.'
    #
    #  In order to build our files to the correct folder(s), we'll use an
    # absolute path for the target folder (project_dir)

    project_dir = os.path.abspath(project_dir)
    logger.debug('Project directory is %s', project_dir)

    # if we created the output directory, then it's ok to remove it
    # if rendering fails
    delete_project_on_failure = output_directory_created and not keep_project_on_failure

    if accept_hooks:
        run_hook_from_repo_dir(
            repo_dir, 'pre_gen_project', project_dir, context, delete_project_on_failure
        )

    with work_in(template_dir):
        env.loader = FileSystemLoader(['.', '../templates'])

        for root, dirs, files in os.walk('.'):
            # We must separate the two types of dirs into different lists.
            # The reason is that we don't want ``os.walk`` to go through the
            # unrendered directories, since they will just be copied.
            copy_dirs = []
            render_dirs = []

            for d in dirs:
                d_ = os.path.normpath(os.path.join(root, d))
                # We check the full path, because that's how it can be
                # specified in the ``_copy_without_render`` setting, but
                # we store just the dir name
                if is_copy_only_path(d_, context):
                    logger.debug('Found copy only path %s', d)
                    copy_dirs.append(d)
                else:
                    render_dirs.append(d)

            for copy_dir in copy_dirs:
                indir = os.path.normpath(os.path.join(root, copy_dir))
                outdir = os.path.normpath(os.path.join(project_dir, indir))
                outdir = env.from_string(outdir).render(**context)
                logger.debug('Copying dir %s to %s without rendering', indir, outdir)

                # The outdir is not the root dir, it is the dir which marked as copy
                # only in the config file. If the program hits this line, which means
                # the overwrite_if_exists = True, and root dir exists
                if os.path.isdir(outdir):
                    shutil.rmtree(outdir)
                shutil.copytree(indir, outdir)

            # We mutate ``dirs``, because we only want to go through these dirs
            # recursively
            dirs[:] = render_dirs
            for d in dirs:
                unrendered_dir = os.path.join(project_dir, root, d)
                try:
                    render_and_create_dir(
                        unrendered_dir, context, output_dir, env, overwrite_if_exists
                    )
                except UndefinedError as err:
                    if delete_project_on_failure:
                        rmtree(project_dir)
                    _dir = os.path.relpath(unrendered_dir, output_dir)
                    msg = f"Unable to create directory '{_dir}'"
                    raise UndefinedVariableInTemplate(msg, err, context) from err

            for f in files:
                infile = os.path.normpath(os.path.join(root, f))
                if is_copy_only_path(infile, context):
                    outfile_tmpl = env.from_string(infile)
                    outfile_rendered = outfile_tmpl.render(**context)
                    outfile = os.path.join(project_dir, outfile_rendered)
                    logger.debug(
                        'Copying file %s to %s without rendering', infile, outfile
                    )
                    shutil.copyfile(infile, outfile)
                    shutil.copymode(infile, outfile)
                    continue
                try:
                    generate_file(
                        project_dir, infile, context, env, skip_if_file_exists
                    )
                except UndefinedError as err:
                    if delete_project_on_failure:
                        rmtree(project_dir)
                    msg = f"Unable to create file '{infile}'"
                    raise UndefinedVariableInTemplate(msg, err, context) from err

    if accept_hooks:
        run_hook_from_repo_dir(
            repo_dir,
            'post_gen_project',
            project_dir,
            context,
            delete_project_on_failure,
        )

    return project_dir
