import functools
import os
import shutil
import traceback
from collections.abc import Generator
from typing import Any

import requests
from git import Repo
from git.exc import GitCommandError

from polkadot.globber import glob
from polkadot.logging import log_operation, logger


def filer(fn: Any) -> Any:
    """
    decorator that makes file operations lazy and builds a dependency tree.
    functions decorated with this generator must always yield, even in the case of failure.

    :param fn: function performing some side effect
    """

    @functools.wraps(fn)
    def inner(
        path: str,
        *args: Any,
        deps: list[Generator[Any, Any, Any]] | None = None,
        **kwargs: Any,
    ) -> Generator[Any, Any, Any]:
        """
        generator wrapper that receives the polkadot config as the ``DOTFILES`` are iterated.

        if relative paths are provided in ``path``, it is assumed that the home directory is where the files are being added.

        .. note:: if a generator fails to yield, the whole pipeline will stop.

        :param path: required part of the destination file:
        :param args: optional positional arguments
        :param deps: nested dependencies, e.g. mkdir before touching a file
        :param kwargs: optional keyword arguments which allow for support of different operations
        """
        config: dict[str, Any] = yield

        if deps:
            for dep in deps:
                yield from dep

        if not path.startswith("/"):
            path = os.path.join(config["DOTFILES_OUTPUT_DIRECTORY"], path)

        if config["DOTFILES_DRY_RUN"]:
            yield log_operation(fn, path, args, kwargs)
        else:
            yield from fn(path, *args, config=config, **kwargs)

    return inner


@filer
def copy(
    dest: str,
    source: str,
    config: dict[str, Any] | None = None,
    template: bool = True,
) -> Generator[Any]:
    """
    open a file, template it, write the rendered output to the destination and set file metadata

    .. note:: accepts wildcards for both arguments

    :param dest: string or wildcard pattern for destination file(s)
    :param source: string or wildcard pattern for source file(s)
    :param template: if False, skip jinja templating
    :param config: polkadot config dictionary
    """
    assert config is not None
    sources = list(glob(source, config["DOTFILES_WORKING_DIRECTORY"]))

    if source.endswith("*") and not dest.endswith("*"):
        logger.error("Globbed sources must be copied to a globbed directory.")
        return
    else:
        dest = os.path.dirname(dest)

    prefix = len(source.split(os.path.sep)) - 1

    for source in sources:
        realdest = os.path.join(dest, *(source.split(os.path.sep)[prefix:]))
        logger.debug(f"copy {source} to {realdest}")

        if template:
            try:
                tmpl = config["DOTFILES_JINJA_ENV"].get_template(source)
                output = tmpl.render(config).encode("utf-8")
            except UnicodeDecodeError:
                logger.warning(f"couldn't template non-text file: {source}")
                with open(source, "rb") as binary_source:
                    output = binary_source.read()

            with open(realdest, "wb") as d:
                d.write(output)
            yield shutil.copystat(source, realdest)
        else:
            yield shutil.copy2(source, realdest)


@filer
def touch(dest: str, config: dict[str, Any] | None = None) -> Generator[Any]:
    """
    touch a file, creating if it doesn't exist and setting the access time.

    :param dest: string or wildcard pattern for destination file
    :param config: polkadot config dictionary
    """
    logger.debug(f"touch {dest}")
    yield open(dest, "a").close()
    yield os.utime(dest, None)


@filer
def mkdir(dest: str, config: dict[str, Any] | None = None) -> Generator[Any]:
    """
    make a directory like ``mkdir -p``

    :param dest: string or wildcard pattern for destination directory
    :param config: polkadot config dictionary
    """
    logger.debug(f"mkdir {dest}")
    yield os.makedirs(dest, exist_ok=True)


@filer
def mode(dest: str, octal: int, config: dict[str, Any] | None = None) -> Generator[Any]:
    """
    set a file mode on ``dest``

    :param dest: string or wildcard pattern for destination file
    :param octal: octal representation (0o744) for chmod
    :param config: polkadot config dictionary
    """
    logger.debug(f"chmod {dest} {oct(octal)}")
    yield os.chmod(dest, octal)


@filer
def gitclone(
    dest: str,
    source: str,
    branch: str = "master",
    config: dict[str, Any] | None = None,
    git_kwargs: dict[str, Any] | None = None,
) -> Generator[Any]:
    """
    clone a git repository

    .. note:: this will skip the repo if it already exists, it will not run ``git pull``

    :param dest: string or wildcard pattern for destination directory
    :param source: git url for source repository
    :param branch: branch to check out when repository is cloned
    :param config: polkadot config dictionary
    """
    logger.debug(f"git clone {source} into {dest} on '{branch}'")
    try:
        yield Repo().clone_from(source, dest, branch=branch, **(git_kwargs or {}))
    except GitCommandError:
        logger.debug(traceback.format_exc())
        logger.info(f"skipped git clone for existing repository in {dest}")
        yield None


@filer
def download(
    dest: str, source: str, config: dict[str, Any] | None = None
) -> Generator[Any]:
    """
    streams a file using requests

    :param dest: string or destination file
    :param source: remote url for source file
    :param config: polkadot config dictionary
    """
    logger.debug(f"downloading file from {source} into {dest}")
    try:
        request = requests.get(source, stream=True)
        with open(dest, "wb") as output:
            for chunk in request.iter_content(chunk_size=1024):
                if chunk:
                    yield output.write(chunk)
    except Exception:
        logger.error("failed to download file")
        logger.error(traceback.format_exc())
        yield None
