"""Run j4-dmenu-desktop with set environment and print good error messages."""

from __future__ import annotations

import os  # noqa: I001
import pathlib
import shlex
import subprocess
from dataclasses import dataclass
from typing import Callable, Iterable


class J4ddRunError(Exception):
    """J4-dmenu-desktop couldn't be executed/returned with nonzero exit status."""

    pass


def _assemble_to_reproduce_line(
    override_env: dict[str, str], args: Iterable[str]
) -> str:
    """Assemble 'To reproduce:' section contents of main error message."""
    if len(override_env) == 0:
        reproducer = ""
    else:
        reproducer = " ".join(
            f"{key}={shlex.quote(value)}" for key, value in override_env.items()
        )
        reproducer += " "

    reproducer += shlex.join(args)
    return reproducer


def _assemble_error_message(
    stdout: str,
    stderr: str,
    override_env: dict[str, str],
    args: Iterable[str],
) -> str:
    """Assemble an informative error message about j4-dmenu-desktop execution.

    Error message generated by this function doesn't include the cause of the
    problem, they only describe the environment. Functions should prepend the
    error cause to the returned value.

    Args:
        stdout: stdout contents
        stderr: stderr contents
        override_env: overridden environment variables
        args: commandline executed

    Returns:
        Error message.
    """
    message = ""
    if stdout not in ("", "\n"):
        message += "".join(f"    {line}" for line in stdout.splitlines(keepends=True))

    message += "Stderr:\n"

    if stderr not in ("", "\n"):
        message += "".join(f"    {line}" for line in stderr.splitlines(keepends=True))

    message += "To reproduce:\n"
    message += f"    {_assemble_to_reproduce_line(override_env, args)}\n"

    return message


@dataclass
class _SubprocessRunResult:
    """Abstract return value of subprocess run.

    This dataclass can contain process information of both synchronous and
    asynchronous processes.
    """
    returncode: int
    stdout: str
    stderr: str


@dataclass
class _AsyncData:
    """A messenger class.

    This is a messenger class between _run_j4dd_impl(),
    _subprocess_asynchronous() run and AsyncJ4ddResult.
    _subprocess_asynchronous() needs this additional class to be able to return
    extra information without actually returning it from the function (because
    the function must return _SubprocessRunResult).
    """

    result: _SubprocessRunResult
    process: subprocess.Popen[str] | None = None


class AsyncJ4ddResult:
    """Result of run_j4dd() with asynchronous=True."""

    def __init__(
        self,
        async_data: _AsyncData,
        run_j4dd_impl_generator,
    ):
        """Internal initor.

        This initor should be only called by run_j4dd().
        """
        self._async_data = async_data
        self._run_j4dd_impl_generator = run_j4dd_impl_generator

    def wait(self, timeout: None | int | float = None) -> None:
        """Wait for j4-dmenu-desktop to finish.

        Arguments:
            timeout: timeout for the wait

        Raises:
            Same as run_j4dd().
        """
        assert self._async_data.process is not None

        process = self._async_data.process
        stdout, stderr = process.communicate(timeout=timeout)
        # _run_j4dd_impl_generator has access to self._async_data. It expects
        # that it will be poppulated after next() is called and after the first
        # (and only) yield is passed in _run_j4dd_impl()
        self._async_data.result.returncode = process.returncode
        self._async_data.result.stdout = stdout
        self._async_data.result.stderr = stderr
        next(self._run_j4dd_impl_generator, None)


def _subprocess_synchronous_run(
    args: list[str], env: dict[str, str]
) -> _SubprocessRunResult:
    result = subprocess.run(args, capture_output=True, text=True, env=env)
    return _SubprocessRunResult(result.returncode, result.stdout, result.stderr)


def _subprocess_asynchronous_run(
    async_data: _AsyncData, args: list[str], env: dict[str, str]
) -> _SubprocessRunResult:
    async_data.process = subprocess.Popen(
        args,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        env=env,
        text=True,
    )
    return async_data.result


def _run_j4dd_impl(
    subprocess_run: Callable[[list[str], dict[str, str]], _SubprocessRunResult],
    j4dd_executable_path: pathlib.Path,
    override_env: dict[str, str],
    *j4dd_arguments: str,
    shouldfail: bool = False,
):
    # This function doesn't accept j4dd_exe as str directly because having the
    # location as a pathlib.Path could be useful in the future (although it
    # isn't currently used in this implementation).
    j4dd_path_string = str(j4dd_executable_path)

    args: list[str]
    if "MESON_EXE_WRAPPER" in os.environ:
        args = (
            shlex.split(os.environ["MESON_EXE_WRAPPER"])
            + [j4dd_path_string]
            + list(j4dd_arguments)
        )
    else:
        args = [j4dd_path_string] + list(j4dd_arguments)

    env = os.environ.copy()
    for key, val in override_env.items():
        env[key] = val

    # Execute subprocess either synchronously or asynchronously
    try:
        result = subprocess_run(args, env)
    except OSError as exc:
        raise J4ddRunError(
            f"Couldn't execute j4-dmenu-desktop: {exc}\n"
            f"To reproduce:\n"
            f"    {_assemble_to_reproduce_line(override_env, args)}\n"
        ) from exc

    # Return execution to the caller temporarily. If calling synchronously,
    # run_j4dd() will immediately reactivate this function as if the yield
    # wasn't there.
    # If calling asynchronously, execution will continue until
    # AsyncJ4ddResult.wait() is called, which will poppulate the result variable
    # here.
    yield
    if shouldfail:
        if result.returncode == 0:
            raise J4ddRunError(
                "j4-dmenu-desktop exitted with return code 0!\n"
                + _assemble_error_message(
                    result.stdout, result.stderr, override_env, args
                )
            )
    else:
        if result.returncode != 0:
            raise J4ddRunError(
                f"j4-dmenu-desktop exitted with return code "
                f"{result.returncode}!\n"
                + _assemble_error_message(
                    result.stdout, result.stderr, override_env, args
                )
            )


def run_j4dd(
    j4dd_executable_path: pathlib.Path,
    override_env: dict[str, str],
    *j4dd_arguments: str,
    shouldfail: bool = False,
    asynchronous: bool = False,
) -> None | AsyncJ4ddResult:
    """Run j4-dmenu-desktop.

    Args:
        j4dd_executable_path: path to j4-dmenu-desktop executable
        override_env: environment variables to override
        j4dd_arguments: arguments given to j4-dmenu-desktop
        shouldfail: should j4-dmenu-desktop fail? If yes, J4ddRunError will be
            raised if j4-dmenu-desktop exits with zero exit status.
        asynchronous: if true, do not wait for j4-dmenu-desktop to finish
            execution. If true, return an object that can be waited for later.

    Raises:
        J4ddRunErrorr: If j4-dmenu-desktop doesn't exit successfully.

    Returns:
        None if asynchronous is False,
    """
    if asynchronous:
        async_data = _AsyncData(_SubprocessRunResult(1000, "", ""))

        def async_run(*args):
            nonlocal async_data
            return _subprocess_asynchronous_run(async_data, *args)

        generator = _run_j4dd_impl(
            async_run,
            j4dd_executable_path,
            override_env,
            *j4dd_arguments,
            shouldfail=shouldfail,
        )
        # Execute first part of _run_j4dd_impl(), which executes
        # j4-dmenu-desktop but doesn't check the result of the execution.
        next(generator)
        return AsyncJ4ddResult(async_data, generator)
    else:
        result = _run_j4dd_impl(
            _subprocess_synchronous_run,
            j4dd_executable_path,
            override_env,
            *j4dd_arguments,
            shouldfail=shouldfail,
        )
        # Execute first part of _run_j4dd_impl(), which executes
        # j4-dmenu-desktop but doesn't check the result of the execution.
        next(result)
        # Immediately execute the rest of _run_j4dd_impl(), which may throw
        # J4ddRunError to signify that j4-dmenu-desktop exited with unexpected
        # exit status.
        next(result, None)
    return None
