import inspect
import typing as t
from functools import cached_property
from os import PathLike
from pathlib import Path

import jinja2
import platformdirs

from ..repo.base import Repo
from ..utils import read_text
from ..utils import wrap_text


class Script[RepoType: Repo]:
    """Defines how to select repositories and modify them. Typically, you'll
    want to subclass a more specific class that is already set up to work with a
    remote host. You'd subclass this class if you wanted to define such a class
    for a new host.

    :param submit: Whether to submit the changes. This is disabled by default,
        to give you a chance to develop the changes first.
    """

    target: str = "main"
    """The name of the target branch to branch off of and merge into."""

    branch: str
    """The name of the work branch to create."""

    title: str
    """A short title describing the change. Used as the first line of the
    automatic commit, as well as the title of the PR. By convention, this should
    be at most 50 characters.
    """

    body: str
    """Additional description about the change. Used in the commit message
    after the title, separated by an empty line. Also used as the body of the
    PR. This will be re-wrapped to 72 characters to match convention.
    """

    def __init__(self, *, submit: bool = False) -> None:
        if self.target == self.branch:
            raise ValueError(
                "Work branch name must be different than target branch name."
            )

        source_file = inspect.getsourcefile(self.__class__)

        if source_file is None:
            raise RuntimeError("Could not determine script root.")

        self.root_dir: Path = Path(source_file).parent
        """The directory containing the running script. Used to reference
        resource files and templates.
        """

        self.jinja_env: jinja2.Environment = jinja2.Environment(
            loader=jinja2.FileSystemLoader(self.root_dir),
            trim_blocks=True,
            lstrip_blocks=True,
            keep_trailing_newline=True,
        )
        """A Jinja environment configured to use :attr:`root_dir` as a template
        folder. See :meth:`render_template`.
        """

        self.clones_dir: Path = platformdirs.user_cache_path("modify-repos") / "clones"
        """Directory where repos are cloned to. Uses the appropriate user cache
        dir for the platform.
        """

        self.clones_dir.mkdir(parents=True, exist_ok=True)

        if not (ignore := self.clones_dir / ".gitignore").exists():
            ignore.write_text("*\n")

        self.body = wrap_text(self.body, width=72)
        self.enable_submit = submit
        """Whether to submit the changes. This is disabled by default, to give
        you a chance to develop the changes first. It is set from the `submit`
        param.
        """

    def render_template(self, name: str, /, **kwargs: t.Any) -> str:
        """Render the named template file with context. Uses :attr:`jinja_env`,
        which finds templates next to the script file.

        :param name: Template name to load.
        :param kwargs: Context to pass to the render call.
        """
        return self.jinja_env.get_template(name).render(**kwargs)

    def list_all_repos(self) -> list[RepoType]:
        """Get the list of all repos that may be cloned. Override this to
        define how to generate this list. Called by :meth:`list_repos`."""
        raise NotImplementedError

    def list_repos(self) -> list[RepoType]:
        """Get the filtered list of repos that will be cloned. Override
        :meth:`list_all_repos` and :meth:`select_for_clone` to control what is
        returned here. Called by :meth:`run`."""
        return [r for r in self.list_all_repos() if self.select_for_clone(r)]

    @cached_property
    def full_target(self) -> str:
        """The upstream target branch, which is :attr:`target` prefixed by
        `origin/`.
        """
        return f"origin/{self.target}"

    @cached_property
    def commit_message(self) -> str:
        """The message to use for the automatic commit in :meth:`commit`.
        Defaults to :attr:`title` and :attr:`body` separated by a blank line.
        """
        return f"{self.title}\n\n{self.body}"

    def select_for_clone(self, repo: Repo) -> bool:
        """Select what repos are returned by :meth:`list_repos`. Each repo from
        :meth:`list_all_repos` is passed, and will be used if this method returns
        true for it.

        For example, override this to return true if the repo name matches a set
        of names.

        :param repo: The repo to filter.
        """
        return True

    def select_for_modify(self, repo: Repo) -> bool:
        """Select whether :meth:`modify` will be called on the repo. Called by
        :meth:`run` while the current directory is the cloned repo dir.

        For example, override this to return false if the repo does not contain
        a file to be removed, or already contains a file to be added.

        :param repo: The repo to filter.
        """
        return True

    def modify(self, repo: Repo) -> None:
        """Perform modifications to the repo. Called by :meth:`run` while the
        current directory is the cloned repo dir.

        If this leaves uncommitted changes, :meth:`.Repo.commit_if_needed` will
        detect that and commit automatically. You can also add and commit
        manually to skip that behavior.

        :param repo: The repo to modify.
        """
        raise NotImplementedError

    def run(self) -> None:
        """Call :meth:`.Repo.run` for each selected repo."""
        for repo in self.list_repos():
            repo.run()

    def read_text(self, path: str | PathLike[str], strip: bool = True) -> str:
        """Read a text file, where `path` is relative to the script's directory
        :attr:`root_dir`. The file will be read as UTF-8.

        :param path: Path to file to read. Relative paths are relative to
            :attr:`root_dir`.
        :param strip: Strip leading and trailing empty spaces and lines. Enabled
            by default. The text is often formatted into an existing file, so
            stripping spaces makes working with it more predictable.
        """
        return read_text(self.root_dir / path, strip=strip)
