import contextlib
import copy
import json
import os
import re
import shutil
import signal
import sys
from enum import Enum
from pathlib import Path
from typing import Union

import click

from .util import get_commands, get_config
from .util import run as _run


class GcovReportFormat(str, Enum):
    html = "html"
    xml = "xml"
    text = "text"
    sonarqube = "sonarqube"


# Allow specification of meson binary in configuration
# This is necessary for packages like NumPy that vendor meson
def _meson_cli():
    cfg = get_config()
    meson_cli = os.path.expanduser(cfg.get("tool.spin.meson.cli", "meson"))

    # Handle Python runner, mainly for Windows
    if meson_cli.endswith(".py"):
        return [sys.executable, meson_cli]
    else:
        return [meson_cli]


def editable_install_path(distname: str) -> Union[str, None]:
    """Return path of the editable install for package `distname`.

    If the package is not an editable install, return None.

    See Also
    --------
    is_editable_install
    """
    import importlib_metadata

    try:
        dist = importlib_metadata.Distribution.from_name(distname)
    except importlib_metadata.PackageNotFoundError:
        return None

    if dist.origin is None:
        return None

    if hasattr(dist.origin, "dir_info") and getattr(
        dist.origin.dir_info, "editable", False
    ):
        if sys.platform == "win32":
            return dist.origin.url.removeprefix("file:///")
        else:
            return dist.origin.url.removeprefix("file://")
    else:
        return None


# backward compat
_editable_install_path = editable_install_path


def is_editable_install(distname: str, verify_path: bool = False) -> bool:
    """Whether or not an editable install of `distname` is present.

    Parameters
    ----------
    `distname` : str
        Name of the package. E.g., ``numpy`` or ``scikit-image``.
        Not always the same as the module name (``numpy`` and
        ``skimage`` for the above).
    """
    return editable_install_path(distname) is not None


# backward compat
_is_editable_install = is_editable_install


def _is_editable_install_of_same_source(distname: str) -> bool:
    """Check whether the editable install was made from the current directory."""
    editable_path = editable_install_path(distname)
    return (editable_path is not None) and os.path.samefile(editable_path, ".")


def _set_pythonpath(build_dir: str, quiet: bool = False) -> str:
    """Set first entry of PYTHONPATH to site packages directory.

    For editable installs, leave the PYTHONPATH alone.

    Returns
    -------
    site_packages
    """
    cfg = get_config()
    distname = cfg.get("project.name", None)
    if distname:
        if is_editable_install(distname):
            if _is_editable_install_of_same_source(distname):
                if not (quiet):
                    click.secho(
                        "Editable install of same source directory detected; not setting PYTHONPATH",
                        fg="yellow",
                    )
                return ""
            else:
                # Ignoring the quiet flag, because picking up the wrong package is problematic
                click.secho(
                    f"Warning! Editable install of `{distname}`, from a different source location, detected.",
                    fg="bright_red",
                )
                click.secho("Spin commands will pick up that version.", fg="bright_red")
                click.secho(
                    f"Try removing the other installation by switching to its source and running `pip uninstall {distname}`.",
                    fg="bright_red",
                )

    site_packages = _get_site_packages(build_dir)
    env = os.environ

    if "PYTHONPATH" in env:
        env["PYTHONPATH"] = f"{site_packages}{os.pathsep}{env['PYTHONPATH']}"
    else:
        env["PYTHONPATH"] = site_packages

    if not quiet:
        click.secho(
            f'$ export PYTHONPATH="{env["PYTHONPATH"]}"', bold=True, fg="bright_blue"
        )

    return site_packages


def _get_install_dir(build_dir: str) -> str:
    return f"{build_dir}-install"


def _get_site_packages(build_dir: str) -> str:
    install_dir = _get_install_dir(build_dir)
    try:
        cfg = get_config()
        distname = cfg.get("project.name", None)
        if _is_editable_install_of_same_source(distname):
            return ""
    except RuntimeError:
        # Probably not running in click
        pass

    candidate_paths = []
    for root, dirs, _files in os.walk(install_dir):
        for subdir in dirs:
            if subdir == "site-packages" or subdir == "dist-packages":
                candidate_paths.append(os.path.abspath(os.path.join(root, subdir)))

    X, Y = sys.version_info.major, sys.version_info.minor

    site_packages = None
    if any(f"python{X}." in p for p in candidate_paths):
        # We have a system that uses `python3.X/site-packages` or `python3.X/dist-packages`
        site_packages_paths = [p for p in candidate_paths if f"python{X}.{Y}" in p]
        if len(site_packages_paths) == 0:
            raise FileNotFoundError(
                f"No site-packages found in {install_dir} for Python {X}.{Y}"
            )
        site_packages = site_packages_paths[0]
    else:
        # A naming scheme that does not encode the Python major/minor version is used, so return
        # whatever site-packages path was found
        if len(candidate_paths) > 1:
            raise FileNotFoundError(
                f"Multiple `site-packages` found in `{install_dir}`, but cannot use Python version to disambiguate"
            )
        elif len(candidate_paths) == 1:
            site_packages = candidate_paths[0]

    if site_packages is None:
        raise FileNotFoundError(
            f"No `site-packages` or `dist-packages` found under `{install_dir}`"
        )

    return site_packages


def _meson_version() -> Union[str, None]:
    try:
        p = _run(_meson_cli() + ["--version"], output=False, echo=False)
        return p.stdout.decode("ascii").strip()
    except:
        return None


def _meson_version_configured(build_dir: str) -> Union[str, None]:
    try:
        meson_info_fn = os.path.join(build_dir, "meson-info", "meson-info.json")
        with open(meson_info_fn) as f:
            meson_info = json.load(f)
        return meson_info["meson_version"]["full"]
    except:
        return None


def _meson_coverage_configured() -> bool:
    try:
        build_options_fn = os.path.join(
            "build", "meson-info", "intro-buildoptions.json"
        )
        with open(build_options_fn) as f:
            build_options = json.load(f)
        for b in build_options:
            if (b["name"] == "b_coverage") and (b["value"] is True):
                return True
    except:
        pass

    return False


def _check_coverage_tool_installation(coverage_type: GcovReportFormat, build_dir: str):
    requirements = {  # https://github.com/mesonbuild/meson/blob/6e381714c7cb15877e2bcaa304b93c212252ada3/docs/markdown/Unit-tests.md?plain=1#L49-L62
        GcovReportFormat.html: ["Gcovr/GenHTML", "lcov"],
        GcovReportFormat.xml: ["Gcovr (version 3.3 or higher)"],
        GcovReportFormat.text: ["Gcovr (version 3.3 or higher)"],
        GcovReportFormat.sonarqube: ["Gcovr (version 4.2 or higher)"],
    }

    # First check the presence of a valid build
    if not (os.path.exists(build_dir)):
        raise click.ClickException(
            f"`{build_dir}` folder not found, cannot generate coverage reports. "
            "Generate coverage artefacts by running `spin test --gcov`"
        )

    debug_files = Path(build_dir).rglob("*.gcno")
    if len(list(debug_files)) == 0:
        raise click.ClickException(
            "Debug build not found, cannot generate coverage reports.\n\n"
            "Please rebuild using `spin build --clean --gcov` first."
        )

    # Verify the tools are installed prior to the build
    p = _run(["ninja", "-C", build_dir, "-t", "targets", "all"], output=False)
    if f"coverage-{coverage_type}" not in p.stdout.decode("ascii"):
        raise click.ClickException(
            f"coverage-{coverage_type} is not supported... "
            f"Ensure the following are installed: {', '.join(requirements[coverage_type])} "
            "and rerun `spin test --gcov`"
        )


if sys.platform.startswith("win"):
    DEFAULT_PREFIX = "C:/"
else:
    DEFAULT_PREFIX = "/usr"


build_option = click.option(
    "--no-build",
    "build",
    is_flag=True,
    callback=lambda ctx, param, value: not value,  # store opposite value in `build` var
    default=False,
    help="Disable building before executing command",
)

build_dir_option = click.option(
    "-C",
    "--build-dir",
    default="build",
    show_envvar=True,
    metavar="BUILD_DIR",
    envvar="SPIN_BUILD_DIR",
    help="Meson build directory; package is installed into './{build-dir}-install'.",
)


@click.command()
@click.option(
    "-j",
    "--jobs",
    metavar="N_JOBS",
    help="Number of parallel tasks to launch",
    type=int,
)
@click.option("--clean", is_flag=True, help="Clean build directory before build")
@click.option(
    "-v", "--verbose", is_flag=True, help="Print detailed build and installation output"
)
@click.option(
    "--gcov",
    is_flag=True,
    help="Enable C code coverage using `gcov`. Use `spin test --gcov` to generate reports.",
)
@click.option(
    "--prefix",
    help="The build prefix, passed directly to meson.",
    type=str,
    metavar="PREFIX",
    default=DEFAULT_PREFIX,
)
@click.argument("meson_args", nargs=-1)
@build_dir_option
def build(
    *,
    meson_args,
    jobs=None,
    clean=False,
    verbose=False,
    gcov=False,
    quiet=False,
    build_dir=None,
    prefix=None,
    meson_compile_args=(),
    meson_install_args=(),
):
    """🔧 Build package with Meson/ninja

    The package is installed to `build-install` (unless a different
    build directory is specified with `-C`).

    MESON_ARGS are passed through to `meson setup` e.g.:

      spin build -- -Dpkg_config_path=/lib64/pkgconfig

    By default meson-python does release builds. To be able to use a debugger,
    tell meson to build in debug mode:

      spin build -- -Dbuildtype=debug

    or set CFLAGS appropriately:

      CFLAGS="-O0 -g" spin build

    Build into a different build/build-install directory via the
    `-C/--build-dir` flag:

      spin build -C build-for-feature-x

    This feature is useful in combination with a shell alias, e.g.:

      $ alias spin-clang="SPIN_BUILD_DIR=build-clang CC=clang spin"

    Which can then be used to build (`spin-clang build`), to test (`spin-clang test ...`), etc.

    """
    abs_build_dir = os.path.abspath(build_dir)
    install_dir = _get_install_dir(build_dir)
    abs_install_dir = os.path.abspath(install_dir)

    cfg = get_config()
    distname = cfg.get("project.name", None)
    if distname and _is_editable_install_of_same_source(distname):
        if not quiet:
            click.secho(
                "Editable install of same source detected; skipping build",
                fg="yellow",
            )
        return

    meson_args_setup = list(meson_args)

    if gcov:
        meson_args_setup = meson_args_setup + ["-Db_coverage=true"]

    setup_cmd = (
        _meson_cli() + ["setup", build_dir, f"--prefix={prefix}"] + meson_args_setup
    )

    if clean:
        print(f"Removing `{build_dir}`")
        if os.path.isdir(build_dir):
            shutil.rmtree(build_dir)
        print(f"Removing `{install_dir}`")
        if os.path.isdir(install_dir):
            shutil.rmtree(install_dir)

    if not (os.path.exists(build_dir) and _meson_version_configured(build_dir)):
        p = _run(setup_cmd, sys_exit=False, output=not quiet)
        if p.returncode != 0:
            raise RuntimeError(
                "Meson configuration failed; please try `spin build` again with the `--clean` flag."
            )
    else:
        # Build dir has been configured; check if it was configured by
        # current version of Meson

        if (_meson_version() != _meson_version_configured(build_dir)) or (
            gcov and not _meson_coverage_configured()
        ):
            _run(setup_cmd + ["--reconfigure"], output=not quiet)

        # Any other conditions that warrant a reconfigure?

    compile_flags = ["-v"] if verbose else []
    if jobs:
        compile_flags += ["-j", str(jobs)]

    p = _run(
        _meson_cli()
        + ["compile"]
        + compile_flags
        + ["-C", build_dir]
        + list(meson_compile_args),
        sys_exit=True,
        output=not quiet,
    )

    p = _run(
        _meson_cli()
        + [
            "install",
            "--only-changed",
            "-C",
            build_dir,
            "--destdir",
            install_dir
            if os.path.isabs(install_dir)
            else os.path.relpath(abs_install_dir, abs_build_dir),
        ]
        + list(meson_install_args),
        output=(not quiet) and verbose,
    )


def _get_configured_command(command_name):
    command_groups = get_commands()
    commands = [cmd for section in command_groups for cmd in command_groups[section]]
    return next((cmd for cmd in commands if cmd.name == command_name), None)


@click.command()
@click.argument("pytest_args", nargs=-1)
@click.option(
    "-j",
    "n_jobs",
    metavar="N_JOBS",
    default="1",
    help=(
        "Number of parallel jobs for testing. Can be set to `auto` to use all cores."
    ),
)
@click.option(
    "--tests",
    "-t",
    metavar="TESTS",
    help=(
        """
Which tests to run. Can be a module, function, class, or method:

 \b
 numpy.random
 numpy.random.tests.test_generator_mt19937
 numpy.random.tests.test_generator_mt19937::TestMultivariateHypergeometric
 numpy.random.tests.test_generator_mt19937::TestMultivariateHypergeometric::test_edge_cases
 \b
"""
    ),
)
@click.option("--verbose", "-v", is_flag=True, default=False)
@click.option(
    "-c",
    "--coverage",
    is_flag=True,
    help="Generate a Python coverage report of executed tests. An HTML copy of the report is written to `build/coverage`.",
)
@click.option(
    "--gcov",
    is_flag=True,
    help="Generate a C coverage report in `build/meson-logs/coveragereport`.",
)
@click.option(
    "--gcov-format",
    type=click.Choice([e.name for e in GcovReportFormat]),
    default="html",
    help=f"Format of the gcov report. Can be one of {', '.join(e.value for e in GcovReportFormat)}.",
)
@build_option
@build_dir_option
@click.pass_context
def test(
    ctx,
    *,
    pytest_args,
    n_jobs,
    tests,
    verbose,
    coverage=False,
    gcov=None,
    gcov_format=None,
    build=None,
    build_dir=None,
):
    """🔧 Run tests

    PYTEST_ARGS are passed through directly to pytest, e.g.:

      spin test -- --pdb

    To run tests on a directory or file:

     \b
     spin test numpy/linalg
     spin test numpy/linalg/tests/test_linalg.py

    To run test modules, functions, classes, or methods:

      spin test -t numpy.random

    To report the durations of the N slowest tests:

      spin test -- --durations=N

    To run tests that match a given pattern:

     \b
     spin test -- -k "geometric"
     spin test -- -k "geometric and not rgeometric"

    To run tests with a given marker:

      \b
      spin test -- -m slow
      spin test -- -m "not slow"

    If python-xdist is installed, you can run tests in parallel:

      spin test -j auto

    For more, see `pytest --help`.
    """  # noqa: E501
    cfg = get_config()
    distname = cfg.get("project.name", None)
    pytest_args = pytest_args or ()

    # User specified tests without -t flag
    # Rewrite arguments as though they specified using -t and proceed
    if (len(pytest_args) == 1) and (not tests):
        tests = pytest_args[0]
        pytest_args = ()

    package = cfg.get("tool.spin.package", None)
    if package is None:
        click.secho(
            "Please specify `package = packagename` under `tool.spin` section of `pyproject.toml`",
            fg="bright_red",
        )
        raise SystemExit(1)

    # User did not specify what to test, so we test
    # the full package, or the tests directory if that is present
    if not (pytest_args or tests):
        if os.path.isdir("./tests"):
            # tests dir exists, presuming you are not shipping tests
            # with your package, and prefer to run those instead
            pytest_args = (os.path.abspath("./tests"),)
        else:
            pytest_args = ("--pyargs", package)
    elif tests:
        if (os.path.sep in tests) or ("/" in tests):
            pytest_args = pytest_args + (tests,)
        else:
            # Otherwise tests given as modules
            pytest_args = pytest_args + ("--pyargs", tests)

    is_editable_install = distname and _is_editable_install_of_same_source(distname)
    if gcov and is_editable_install:
        click.secho(
            "Error: cannot generate coverage report for editable installs",
            fg="bright_red",
        )
        raise SystemExit(1)

    if build:
        build_cmd = _get_configured_command("build")
        if build_cmd:
            click.secho(
                "Invoking `build` prior to running tests:", bold=True, fg="bright_green"
            )
            if gcov is not None:
                ctx.invoke(build_cmd, build_dir=build_dir, gcov=bool(gcov))
            else:
                ctx.invoke(build_cmd, build_dir=build_dir)

    site_path = _set_pythonpath(build_dir)

    # Sanity check that library built properly
    #
    # We do this because `pytest` swallows exception messages originating from `conftest.py`.
    # This can sometimes suppress useful information raised by the package on init.
    if sys.version_info[:2] >= (3, 11):
        p = _run([sys.executable, "-P", "-c", f"import {package}"], sys_exit=False)
    else:
        p = _run(
            [sys.executable, "-c", f"import sys; del sys.path[0]; import {package}"],
            sys_exit=False,
        )
    if p.returncode != 0:
        print(f"As a sanity check, we tried to import {package}.")
        print("Stopping. Please investigate the build error.")
        sys.exit(1)

    if (n_jobs != "1") and ("-n" not in pytest_args):
        pytest_args = ("-n", str(n_jobs)) + pytest_args

    if verbose:
        pytest_args = ("-v",) + pytest_args

    if coverage:
        coverage_dir = os.path.join(os.getcwd(), "build/coverage/")
        if os.path.isdir(coverage_dir):
            print(f"Removing `{coverage_dir}`")
            shutil.rmtree(coverage_dir)
        os.makedirs(coverage_dir)
        pytest_args = [
            *pytest_args,
            "--cov-report=term",
            f"--cov-report=html:{coverage_dir}",
            f"--cov={package}",
        ]

    if sys.version_info[:2] >= (3, 11):
        cmd = [sys.executable, "-P", "-m", "pytest"]
    else:
        cmd = ["pytest"]

    install_dir = _get_install_dir(build_dir)
    if not os.path.exists(install_dir):
        os.mkdir(install_dir)

    # Unless we have a src layout, we need to switch away from the current directory into build install to avoid importing ./package instead of the built package.
    test_path = site_path if not os.path.isdir("./src") else None

    cwd = os.getcwd()
    pytest_p = _run(cmd + list(pytest_args), cwd=test_path)
    os.chdir(cwd)

    if gcov:
        # Verify the tools are present
        click.secho(
            "Verifying gcov dependencies...",
            bold=True,
            fg="bright_yellow",
        )
        _check_coverage_tool_installation(gcov_format, build_dir)

        # Generate report
        click.secho(
            f"Generating {gcov_format} coverage report...",
            bold=True,
            fg="bright_yellow",
        )
        p = _run(
            [
                "ninja",
                "-C",
                build_dir,
                f"coverage-{gcov_format.lower()}",
            ],
            output=False,
        )
        coverage_folder = click.style(
            re.search(r"file://(.*)", p.stdout.decode("utf-8")).group(1),
            bold=True,
            fg="bright_yellow",
        )
        click.secho(
            f"Coverage report generated successfully and written to {coverage_folder}",
            bold=True,
            fg="bright_green",
        )

    raise SystemExit(pytest_p.returncode)


@click.command()
@click.option(
    "--code", "-c", metavar="CODE", help="Python program passed in as a string"
)
@click.argument("gdb_args", nargs=-1)
@build_option
@build_dir_option
@click.pass_context
def gdb(ctx, *, code, gdb_args, build=None, build_dir=None):
    """👾 Execute code through GDB

      spin gdb -c 'import numpy as np; print(np.__version__)'

    Or pass arguments to gdb:

      spin gdb -c 'import numpy as np; print(np.__version__)' -- --fullname

    Or run another program, they way you normally would with gdb:

     \b
     spin gdb ls
     spin gdb -- --args ls -al

    You can also run Python programs:

     \b
     spin gdb my_tests.py
     spin gdb -- my_tests.py --mytest-flag
    """
    if build:
        build_cmd = _get_configured_command("build")
        if build_cmd:
            click.secho(
                "Invoking `build` prior to invoking gdb:", bold=True, fg="bright_green"
            )
            ctx.invoke(build_cmd, build_dir=build_dir)

    _set_pythonpath(build_dir)
    gdb_args = list(gdb_args)

    if gdb_args and gdb_args[0].endswith(".py"):
        gdb_args = ["--args", sys.executable] + gdb_args

    if sys.version_info[:2] >= (3, 11):
        PYTHON_FLAGS = ["-P"]
        code_prefix = ""
    else:
        PYTHON_FLAGS = []
        code_prefix = "import sys; sys.path.pop(0); "

    if code:
        PYTHON_ARGS = ["-c", code_prefix + code]
        gdb_args += ["--args", sys.executable] + PYTHON_FLAGS + PYTHON_ARGS

    gdb_cmd = ["gdb", "-ex", "set detach-on-fork on"] + gdb_args
    _run(gdb_cmd, replace=True)


@click.command()
@click.argument("ipython_args", nargs=-1)
@build_option
@build_dir_option
@click.pass_context
def ipython(ctx, *, ipython_args, build=None, build_dir=None, pre_import=""):
    """💻 Launch IPython shell with PYTHONPATH set

    IPYTHON_ARGS are passed through directly to IPython, e.g.:

    spin ipython -- -i myscript.py
    """
    if build:
        build_cmd = _get_configured_command("build")
        if build_cmd:
            click.secho(
                "Invoking `build` prior to launching ipython:",
                bold=True,
                fg="bright_green",
            )
            ctx.invoke(build_cmd, build_dir=build_dir)

    p = _set_pythonpath(build_dir)
    if p:
        print(f'💻 Launching IPython with PYTHONPATH="{p}"')
    if pre_import:
        ipython_args = (f"--TerminalIPythonApp.exec_lines={pre_import}",) + ipython_args
    _run(["ipython", "--ignore-cwd"] + list(ipython_args), replace=True)


@click.command()
@click.argument("shell_args", nargs=-1)
@build_option
@build_dir_option
@click.pass_context
def shell(ctx, shell_args=[], build=None, build_dir=None):
    """💻 Launch shell with PYTHONPATH set

    SHELL_ARGS are passed through directly to the shell, e.g.:

    spin shell -- -c 'echo $PYTHONPATH'

    Ensure that your shell init file (e.g., ~/.zshrc) does not override
    the PYTHONPATH.
    """
    if build:
        build_cmd = _get_configured_command("build")
        if build_cmd:
            click.secho(
                "Invoking `build` prior to invoking shell:",
                bold=True,
                fg="bright_green",
            )
            ctx.invoke(build_cmd, build_dir=build_dir)

    p = _set_pythonpath(build_dir)
    if p:
        print(f'💻 Launching shell with PYTHONPATH="{p}"')

    shell = os.environ.get("SHELL", "sh")
    cmd = [shell] + list(shell_args)
    print("⚠  Change directory to avoid importing source instead of built package")
    print("⚠  Ensure that your ~/.shellrc does not unset PYTHONPATH")
    _run(cmd, replace=True)


@click.command()
@click.argument("python_args", nargs=-1)
@build_option
@build_dir_option
@click.pass_context
def python(ctx, *, python_args, build=None, build_dir=None):
    """🐍 Launch Python shell with PYTHONPATH set

    PYTHON_ARGS are passed through directly to Python, e.g.:

    spin python -- -c 'import sys; print(sys.path)'
    """
    if build:
        build_cmd = _get_configured_command("build")
        if build_cmd:
            click.secho(
                "Invoking `build` prior to invoking Python:",
                bold=True,
                fg="bright_green",
            )
            ctx.invoke(build_cmd, build_dir=build_dir)

    p = _set_pythonpath(build_dir)
    if p:
        print(f'🐍 Launching Python with PYTHONPATH="{p}"')

    v = sys.version_info
    if (v.major < 3) or (v.major == 3 and v.minor < 11):
        print("We're sorry, but this feature only works on Python 3.11 and greater 😢")
        print()
        print(
            "Why? Because we need the '-P' flag so the interpreter doesn't muck with PYTHONPATH"
        )
        print()
        print("However! You can still launch your own interpreter:")
        print()
        print(f"  PYTHONPATH='{p}' python")
        print()
        print("And then call:")
        print()
        print("import sys; del(sys.path[0])")
        sys.exit(-1)

    _run([sys.executable, "-P"] + list(python_args), replace=True)


@click.command(context_settings={"ignore_unknown_options": True})
@build_option
@build_dir_option
@click.argument("args", nargs=-1)
@click.pass_context
def run(ctx, *, args, build=None, build_dir=None):
    """🏁 Run a shell command with PYTHONPATH set

    \b
    spin run make
    spin run 'echo $PYTHONPATH'
    spin run python -c 'import sys; del sys.path[0]; import mypkg'

    If you'd like to expand shell variables, like `$PYTHONPATH` in the example
    above, you need to provide a single, quoted command to `run`:

    spin run 'echo $SHELL && echo $PWD'

    On Windows, all shell commands are run via Bash.
    Install Git for Windows if you don't have Bash already.
    """
    if not len(args) > 0:
        raise RuntimeError("No command given")

    if build:
        build_cmd = _get_configured_command("build")
        if build_cmd:
            # Redirect spin generated output
            with contextlib.redirect_stdout(sys.stderr):
                # Also ask build to be quiet
                ctx.invoke(build_cmd, build_dir=build_dir, quiet=True)

    is_posix = sys.platform in ("linux", "darwin")
    shell = len(args) == 1
    cmd_args = copy.copy(args)
    if shell:
        cmd_args = args[0]
        if not is_posix:
            # On Windows, we're going to try to use bash
            cmd_args = ["bash", "-c", cmd_args]

    _set_pythonpath(build_dir, quiet=True)

    is_windows = sys.platform == "win32"

    if not is_windows:
        # Let the subprocess handle its own signals
        # Except on Windows, where it already seems to work as intended,
        # and `preexec_fn` is not supported
        signal.signal(signal.SIGINT, signal.SIG_IGN)

    def attach_sigint():
        # Reset SIGINT handler to default
        signal.signal(signal.SIGINT, signal.SIG_DFL)

    # --- launch subprocess ---
    p = _run(
        cmd_args,
        echo=False,
        shell=shell,
        sys_exit=False,
        preexec_fn=None if is_windows else attach_sigint,
    )

    # Is the user trying to run a Python script, without calling the Python interpreter?
    executable = args[0]
    if (
        (p.returncode != 0)
        and args[0].endswith(".py")
        and os.path.exists(executable)
        and (not os.access(executable, os.X_OK))
    ):
        click.secho(
            f"Did you mean to call `spin run python {' '.join(args)}`?", fg="bright_red"
        )
    sys.exit(p.returncode)


@click.command()
@click.argument("sphinx_target", default="html")
@click.option(
    "--clean",
    is_flag=True,
    default=False,
    help="Clean previously built docs before building",
)
@click.option(
    "--plot/--no-plot",
    "sphinx_gallery_plot",
    default=True,
    help="Sphinx gallery: enable/disable plots",
)
@click.option(
    "--jobs",
    "-j",
    default="auto",
    metavar="N_JOBS",
    help="Number of parallel build jobs",
)
@build_option
@build_dir_option
@click.pass_context
def docs(
    ctx,
    *,
    sphinx_target,
    clean,
    jobs,
    sphinx_gallery_plot,
    clean_dirs=None,
    build=None,
    build_dir=None,
):
    """📖 Build Sphinx documentation

    By default, SPHINXOPTS="-W", raising errors on warnings.
    To build without raising on warnings:

      SPHINXOPTS="" spin docs

    To list all Sphinx targets:

      spin docs targets

    To build another Sphinx target:

      spin docs TARGET

    """
    # Detect docs dir
    doc_dir_candidates = ("doc", "docs")
    doc_dir = next((d for d in doc_dir_candidates if os.path.exists(d)), None)
    if doc_dir is None:
        print(
            f"No documentation folder found; one of {', '.join(doc_dir_candidates)} must exist"
        )
        sys.exit(1)

    if sphinx_target in ("targets", "help"):
        clean = False
        build = False
        sphinx_target = "help"

    if clean:
        if clean_dirs is None:
            clean_dirs = []
            for prefix in ("", "_"):
                clean_dirs += [
                    f"./{doc_dir}/{prefix}build/",
                    f"./{doc_dir}/{prefix}build/",
                    f"./{doc_dir}/{prefix}source/api/",
                    f"./{doc_dir}/{prefix}source/auto_examples/",
                    f"./{doc_dir}/{prefix}source/jupyterlite_contents/",
                ]

        for target_dir in clean_dirs:
            if os.path.isdir(target_dir):
                print(f"Removing {target_dir!r}")
                shutil.rmtree(target_dir)

    build_cmd = _get_configured_command("build")

    if build_cmd and build:
        click.secho(
            "Invoking `build` prior to building docs:", bold=True, fg="bright_green"
        )
        ctx.invoke(build_cmd, build_dir=build_dir)

    try:
        site_path = _get_site_packages(build_dir)
    except FileNotFoundError:
        cfg = get_config()
        distname = cfg.get("project.name", None)
        print(f"{distname} build not found; run `spin build` or `spin install` first.")
        sys.exit(1)

    opts = os.environ.get("SPHINXOPTS", "-W")
    if not sphinx_gallery_plot:
        opts = f"{opts} -D plot_gallery=0"

    os.environ["SPHINXOPTS"] = f"{opts} -j {jobs}"

    click.secho(
        f"$ export SPHINXOPTS={os.environ['SPHINXOPTS']}", bold=True, fg="bright_blue"
    )

    if site_path:
        os.environ["PYTHONPATH"] = (
            f"{site_path}{os.sep}:{os.environ.get('PYTHONPATH', '')}"
        )
        click.secho(
            f"$ export PYTHONPATH={os.environ['PYTHONPATH']}",
            bold=True,
            fg="bright_blue",
        )

    make_bat_exists = (Path(doc_dir) / "make.bat").exists()
    make_cmd = "make.bat" if sys.platform == "win32" and make_bat_exists else "make"
    _run([make_cmd, sphinx_target], cwd=doc_dir, replace=True)


@click.command()
@click.option(
    "--code", "-c", metavar="CODE", help="Python program passed in as a string"
)
@click.argument("lldb_args", nargs=-1)
@build_option
@build_dir_option
@click.pass_context
def lldb(ctx, *, code, lldb_args, build=None, build_dir=None):
    """👾 Execute code through LLDB

      spin lldb -c 'import numpy as np; print(np.__version__)'

    Or run another program, they way you normally would with LLDB:

     \b
     spin lldb -- ls -al

    You can also run Python programs:

     \b
     spin lldb -- my_tests.py
     spin lldb -- my_tests.py --mytest-flag

    And specify LLDB-specific flags:

     \b
     spin lldb -- --arch x86_64 -- ls -al
     spin lldb -- --arch x86_64 -- my_tests.py
     spin lldb -c 'import numpy as np; print(np.__version__)' -- --arch x86_64
    """
    if build:
        build_cmd = _get_configured_command("build")
        if build_cmd:
            click.secho(
                "Invoking `build` prior to invoking lldb:", bold=True, fg="bright_green"
            )
            ctx.invoke(build_cmd, build_dir=build_dir)

    _set_pythonpath(build_dir)
    lldb_args = list(lldb_args)

    if code:
        if sys.version_info[:2] >= (3, 11):
            PYTHON_FLAGS = ["-P"]
            code_prefix = ""
        else:
            PYTHON_FLAGS = []
            code_prefix = "import sys; sys.path.pop(0); "

        PYTHON_ARGS = ["-c", code_prefix + code]
        program = [sys.executable] + PYTHON_FLAGS + PYTHON_ARGS
    else:
        if "--" in lldb_args:
            ix = lldb_args.index("--")
            lldb_args, program = lldb_args[:ix], lldb_args[ix + 1 :]
        else:
            program, lldb_args = lldb_args, []

    if program and program[0].endswith(".py"):
        program = [sys.executable] + program

    lldb_cmd = (
        ["lldb", "-O", "settings set target.process.follow-fork-mode child"]
        + lldb_args
        + ["--"]
        + program
    )
    _run(lldb_cmd, replace=True)
