from __future__ import annotations

import atexit
from contextlib import ExitStack
import importlib
from importlib import resources
import importlib.machinery
import importlib.util
import os
import pathlib
import re
import tempfile
from types import ModuleType
from typing import Any
from typing import Optional
from typing import Union

from mako import exceptions
from mako.template import Template

from .exc import CommandError


def template_to_file(
    template_file: Union[str, os.PathLike[str]],
    dest: Union[str, os.PathLike[str]],
    output_encoding: str,
    *,
    append_with_newlines: bool = False,
    **kw: Any,
) -> None:
    template = Template(filename=_preserving_path_as_str(template_file))
    try:
        output = template.render_unicode(**kw).encode(output_encoding)
    except:
        with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as ntf:
            ntf.write(
                exceptions.text_error_template()
                .render_unicode()
                .encode(output_encoding)
            )
            fname = ntf.name
        raise CommandError(
            "Template rendering failed; see %s for a "
            "template-oriented traceback." % fname
        )
    else:
        with open(dest, "ab" if append_with_newlines else "wb") as f:
            if append_with_newlines:
                f.write("\n\n".encode(output_encoding))
            f.write(output)


def coerce_resource_to_filename(fname_or_resource: str) -> pathlib.Path:
    """Interpret a filename as either a filesystem location or as a package
    resource.

    Names that are non absolute paths and contain a colon
    are interpreted as resources and coerced to a file location.

    """
    # TODO: there seem to be zero tests for the package resource codepath
    if not os.path.isabs(fname_or_resource) and ":" in fname_or_resource:
        tokens = fname_or_resource.split(":")

        # from https://importlib-resources.readthedocs.io/en/latest/migration.html#pkg-resources-resource-filename  # noqa E501

        file_manager = ExitStack()
        atexit.register(file_manager.close)

        ref = resources.files(tokens[0])
        for tok in tokens[1:]:
            ref = ref / tok
        fname_or_resource = file_manager.enter_context(  # type: ignore[assignment]  # noqa: E501
            resources.as_file(ref)
        )
    return pathlib.Path(fname_or_resource)


def pyc_file_from_path(
    path: Union[str, os.PathLike[str]],
) -> Optional[pathlib.Path]:
    """Given a python source path, locate the .pyc."""

    pathpath = pathlib.Path(path)
    candidate = pathlib.Path(
        importlib.util.cache_from_source(pathpath.as_posix())
    )
    if candidate.exists():
        return candidate

    # even for pep3147, fall back to the old way of finding .pyc files,
    # to support sourceless operation
    ext = pathpath.suffix
    for ext in importlib.machinery.BYTECODE_SUFFIXES:
        if pathpath.with_suffix(ext).exists():
            return pathpath.with_suffix(ext)
    else:
        return None


def load_python_file(
    dir_: Union[str, os.PathLike[str]], filename: Union[str, os.PathLike[str]]
) -> ModuleType:
    """Load a file from the given path as a Python module."""

    dir_ = pathlib.Path(dir_)
    filename_as_path = pathlib.Path(filename)
    filename = filename_as_path.name

    module_id = re.sub(r"\W", "_", filename)
    path = dir_ / filename
    ext = path.suffix
    if ext == ".py":
        if path.exists():
            module = load_module_py(module_id, path)
        else:
            pyc_path = pyc_file_from_path(path)
            if pyc_path is None:
                raise ImportError("Can't find Python file %s" % path)
            else:
                module = load_module_py(module_id, pyc_path)
    elif ext in (".pyc", ".pyo"):
        module = load_module_py(module_id, path)
    else:
        assert False
    return module


def load_module_py(
    module_id: str, path: Union[str, os.PathLike[str]]
) -> ModuleType:
    spec = importlib.util.spec_from_file_location(module_id, path)
    assert spec
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)  # type: ignore
    return module


def _preserving_path_as_str(path: Union[str, os.PathLike[str]]) -> str:
    """receive str/pathlike and return a string.

    Does not convert an incoming string path to a Path first, to help with
    unit tests that are doing string path round trips without OS-specific
    processing if not necessary.

    """
    if isinstance(path, str):
        return path
    elif isinstance(path, pathlib.PurePath):
        return str(path)
    else:
        return str(pathlib.Path(path))
