#!/usr/bin/env python3
# Only use builtin libraries for this script
import re
import readline
import shutil
import subprocess  # nosec
import sys
import urllib.error
import urllib.request
from argparse import ArgumentParser
from datetime import date
from enum import Enum
from pathlib import Path
from typing import Union

parser = ArgumentParser(description="Prepares python project repository.")
parser.add_argument("--no-verify", action="store_true", help="Disable input verification.")
cli_args = parser.parse_args()

UV_BIN = shutil.which("uv")
REPO = Path(__file__).parent


class col:  # noqa: N801
    HEADER = "\033[95m"
    OKBLUE = "\033[94m"
    OKCYAN = "\033[96m"
    OKGREEN = "\033[92m"
    WARNING = "\033[93m"
    FAIL = "\033[91m"
    ENDC = "\033[0m"
    BOLD = "\033[1m"
    UNDERLINE = "\033[4m"


class BadResponseError(Exception):
    """User gave a response that does not agree with rule set."""


class DocFormat(Enum):
    MARKDOWN = "md"
    RESTRUCTURED_TEXT = "rst"

    @classmethod
    def from_str(cls, label):
        label = label.lower()
        if label in ("markdown", "md"):
            return cls.MARKDOWN
        if label in ("restructuredtext", "rst"):
            return cls.RESTRUCTURED_TEXT
        raise BadResponseError("is not a valid choice.")


def _check_pypi_name_available(package_name) -> None | bool:
    try:
        urllib.request.urlopen(f"https://pypi.org/pypi/{package_name}/json")  # noqa: S310
    except urllib.error.HTTPError as e:
        if e.code == 404:
            return True  # Package does not exist (name is available)
    except urllib.error.URLError as e:
        print(f"Failed to reach PyPI server. Reason: {e.reason}")
    else:
        return False  # If we reach this point, the package exists (name is not available)
    return None  # Unable to determine


def is_pypi_name_available(package_name):
    availability = _check_pypi_name_available(package_name)
    if availability:
        return
    elif availability is None:
        msg = "Failed to check PyPI for name availability. Continue? [default: no]"
    else:
        msg = f"{package_name!r} is not available on PyPI. Continue? [default: no]"
    response = validate_input(msg, is_boolean, default="no")
    if response not in yes_terms:
        raise BadResponseError


def run(cmds, **kwargs):
    """Run bash cmd without capturing stdout."""
    subprocess.run(cmds, capture_output=False, check=True, **kwargs)  # noqa: S603


def check_and_install_uv():
    global UV_BIN
    if not UV_BIN:
        print("uv not found. Installing...")
        run(  # noqa: S604
            "curl -LsSf https://astral.sh/uv/install.sh | sh",
            shell=True,  # noqa: S604
        )
        UV_BIN = str(Path("~/.local/bin/uv").expanduser())

    # Check if the uv installation is potentially broken.
    try:
        subprocess.check_output([UV_BIN, "--version"])  # noqa: S603
    except subprocess.CalledProcessError:
        print(
            f"{col.FAIL}\n\nuv installation failed healthcheck. Manual debugging required. It is unlikely that python-template's bootstrapping is at fault.\n{col.ENDC}"
        )
        sys.exit(1)


def camel_to_snake(s):
    return "".join(["_" + c.lower() if c.isupper() else c for c in s]).lstrip("_")


def validate_input(prompt, validate=None, default=""):
    """Prompts user until response passes ``validate``."""
    while True:
        response = input(prompt + ": ")

        if not response:
            response = default

        if validate is None or cli_args.no_verify:
            break

        if callable(validate):
            validate = [validate]

        try:
            for v in validate:
                v(response)
            break
        except BadResponseError as e:
            if str(e):
                print(f'"{response}" {e}')
            print('!!! To disable these validation checks, run "./bootstrap --no-verify"')
    return response


def git(*args):
    return subprocess.check_output(["git"] + list(args))  # noqa: S603


yes_terms = {"yes", "y", "true", "t", "1"}
no_terms = {"no", "n", "false", "f", "0"}


def is_boolean(response):
    response = response.lower()
    if response not in yes_terms.union(no_terms):
        raise BadResponseError


def is_identifier(response):
    if not response.isidentifier():
        raise BadResponseError("is not a valid python identifier.")


def is_lower(response):
    if response.lower() != response:
        raise BadResponseError("should be all lower case.")


def is_not_empty(response):
    if not response:
        raise BadResponseError("Cannot be empty.")


def good_module_name(response):
    is_identifier(response)
    is_lower(response)
    if "_" in response:
        raise BadResponseError("should not contain an underscore _")
    if len(response) > 20:
        raise BadResponseError("is too long (max 20 char limit).")


def good_class_name(response):
    is_identifier(response)
    if not response[0].isupper():
        raise BadResponseError("first letter should be capitalized.")


def remove_lines_in_file(file, *lines_to_remove):
    file = Path(file)
    lines_to_remove = {line + "\n" for line in lines_to_remove}
    with file.open("r+") as f:
        lines = f.readlines()
        f.seek(0)

        lines = [line for line in lines if line not in lines_to_remove]
        # Remove all trailing newlines
        while lines[-1] == "\n":
            lines.pop()

        for line in lines:
            f.write(line)

        f.truncate()


def git_remote_to_username_repo(git_remote_url):
    ssh_prefix = "git@github.com:"
    https_prefix = "https://github.com/"
    if git_remote_url.startswith(ssh_prefix):
        username_repo = git_remote_url[len(ssh_prefix) : -4]
    elif git_remote_url.startswith(https_prefix):
        username_repo = git_remote_url[len(https_prefix) : -4]
    else:
        raise ValueError(f"Unknown remote url {git_remote_url}")

    return username_repo.split("/", 1)


def add_to_pyproject(after, new_line, replace=False):
    if not new_line.endswith("\n"):
        new_line += "\n"

    if not after.endswith("\n"):
        after += "\n"

    with Path("pyproject.toml").open() as f:
        pyproject_contents = f.readlines()

    with Path("pyproject.toml").open("w") as f:
        for line in pyproject_contents:
            if line == after:
                if not replace:
                    f.write(line)
                line = new_line
            f.write(line)


def main():
    check_and_install_uv()

    replacements: dict[str, str] = {
        "CURRENT_YEAR_HERE": str(date.today().year),
        "README_TEMPLATE": "README",
    }

    git_remote_url = git("config", "--get", "remote.origin.url").decode().strip()
    try:
        (
            replacements["GIT_USERNAME"],
            replacements["GIT_REPONAME"],
        ) = git_remote_to_username_repo(git_remote_url)
    except ValueError:
        print("Unable to get github URL. You will manually have to replace GIT_USERNAME and GIT_REPONAME in README.rst")

    ########################
    # User Questions Begin #
    ########################
    replacements["YOUR_NAME_HERE"] = validate_input("Enter your name", is_not_empty)
    replacements["pythontemplate"] = validate_input("Python Module Name", (good_module_name, is_pypi_name_available))
    replacements["cpythontemplate"] = "c" + replacements["pythontemplate"]
    is_library = validate_input(
        "Is this going to be a library? [default: yes]",
        is_boolean,
        default="yes",
    )
    is_library = is_library in yes_terms

    is_typed = validate_input(
        "Are you going to add type hints? [default: yes]",
        is_boolean,
        default="yes",
    )
    is_typed = is_typed in yes_terms

    include_cli = validate_input(
        "Would you like to include a CLI using Cyclopts? [default: yes]",
        is_boolean,
        default="yes",
    )
    include_cli = include_cli in yes_terms

    use_cython = validate_input(
        "Will your project use Cython? [default: no]",
        is_boolean,
        default="no",
    )
    use_cython = use_cython in yes_terms

    doc_format = DocFormat.from_str(
        validate_input(
            "Do you want README to be markdown or restructuredtext? [default: markdown]",
            DocFormat.from_str,
            default="markdown",
        )
    )
    replacements["README.rst"] = f"README.{doc_format.value}"

    ######################
    # User Questions End #
    ######################

    if is_library:
        # Don't need docker for library.
        Path("Dockerfile").unlink()
        Path(".dockerignore").unlink()
        Path(".github/workflows/docker.yaml").unlink()

    if not is_typed:
        # Remove the py.typed file
        Path("pythontemplate/py.typed").unlink()
        Path("pythontemplate/_c_extension.pyi").unlink()

    if not include_cli:
        # Delete CLI-replated files & config
        Path("pythontemplate/__main__.py").unlink()
        shutil.rmtree("pythontemplate/cli")
        remove_lines_in_file("pyproject.toml", 'pythontemplate = "pythontemplate.cli.main:run_app"')

    if use_cython:
        # Forgo the simple deploy action; CIBuildWheel (build_wheels.yaml) handles deployment.
        Path(".github/workflows/deploy.yaml").unlink()
    else:
        # Delete cython-related files
        paths_to_delete: list[str | Path] = [
            ".github/workflows/build_wheels.yaml",
            "setup.py",
            "MANIFEST.in",
            "pythontemplate/_c_src",
            "pythontemplate/_c_extension.pyx",
            "pythontemplate/cpythontemplate.pxd",
            # Do not include "pythontemplate/_c_extension.pyi" here,
            # it is removed in the ``is_typed`` section above.
        ]

        for path_to_delete in paths_to_delete:
            path_to_delete = Path(path_to_delete)
            if path_to_delete.is_file():
                path_to_delete.unlink()
            else:
                shutil.rmtree(str(path_to_delete))

        # Remove cython-related configuration from pyproject.toml
        add_to_pyproject(
            'requires = ["setuptools>=77", "setuptools-scm>=8", "cython>=3.0.3"]',
            'requires = ["setuptools>=77", "setuptools-scm>=8"]',
            replace=True,
        )
        remove_lines_in_file(
            "pyproject.toml",
            '    "cython>=3.0.3",',
            "# Rebuild the Cython extension whenever its sources change.",
            '    { file = "setup.py" },',
            '    { file = "pythontemplate/_c_extension.pyx" },',
            '    { file = "pythontemplate/cpythontemplate.pxd" },',
            '    { file = "pythontemplate/_c_src/**/*" },',
        )

    (REPO / "README.rst").unlink()  # Delete python-template's README
    if doc_format == DocFormat.MARKDOWN:
        (REPO / "README_TEMPLATE.md").replace(REPO / "README.md")
        (REPO / "README_TEMPLATE.rst").unlink()
        add_to_pyproject(
            "docs = [",
            '    "myst-parser[linkify]>=3.0.1",',
        )
        replacements[":start-after: inclusion-marker-do-not-remove"] = ":parser: myst_parser.sphinx_"
    elif doc_format == DocFormat.RESTRUCTURED_TEXT:
        (REPO / "README_TEMPLATE.rst").replace(REPO / "README.rst")
        (REPO / "README_TEMPLATE.md").unlink()
    else:
        raise NotImplementedError

    def replace(string):
        """Replace whole words only."""

        def _replace(match):
            return replacements[match.group(0)]

        # notice that the 'this' in 'thistle' is not matched
        pattern = "|".join(rf"\b{re.escape(s)}\b" if " " not in s else re.escape(s) for s in replacements)
        return re.sub(pattern, _replace, string)

    files: list[Path] = list(REPO.rglob("*.py"))
    files.extend(REPO.rglob("*.pyx"))
    files.extend(REPO.rglob("*.pxd"))
    files.extend(REPO.rglob("*.yaml"))
    files.extend(REPO.rglob("*.rst"))
    files.extend(REPO.rglob("*.md"))
    files.append(Path("pyproject.toml"))
    files.append(Path(".gitignore"))
    files.append(Path("docs/source/index.rst"))
    if use_cython:
        files.append(Path("MANIFEST.in"))

    if not is_library:
        files.append(Path("Dockerfile"))
    for file in files:
        contents = file.read_text()
        contents = replace(contents)
        file.write_text(contents)
        if file.stem in replacements:
            dst = file.with_name(replacements[file.stem] + file.suffix)
            file.replace(dst)

    # Move the app folder
    (REPO / "pythontemplate").replace(REPO / replacements["pythontemplate"])

    if not include_cli:
        run([UV_BIN, "remove", "cyclopts", "rich"])

    run([UV_BIN, "lock", "--upgrade"])
    run([UV_BIN, "sync"])
    run([UV_BIN, "run", "pre-commit", "install"])
    run([UV_BIN, "run", "pre-commit", "autoupdate"])

    # Delete this script
    Path(__file__).unlink()

    git("add", "-A")
    git("commit", "-m", "bootstrap from template", "--no-verify")

    print(col.OKGREEN)
    print("Bootstrapping complete. Changes committed. Please run:")
    print("    git push")
    print(col.ENDC)


if __name__ == "__main__":
    main()
