"""Thin Docker/compose subprocess abstraction used by Armorer.

Provides structured wrappers around Docker Engine and Docker Compose CLI
commands.  When the Docker SDK for Python is available, pure-Engine
operations (inspect, rm, build, version, info, logs) are routed through the
SDK for reliability.  Compose operations stay on the CLI because the SDK has
no Compose surface.
"""

from __future__ import annotations

import json as _json
import logging
import os
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, List, Mapping, Optional, Sequence

try:  # Docker SDK is optional; fall back to CLI when unavailable.
    import docker  # type: ignore[import-not-found]
except Exception:  # pragma: no cover - env-dependent
    docker = None  # type: ignore[assignment]

logger = logging.getLogger(__name__)


@dataclass
class DockerResult:
    """Structured result for Docker/compose invocations."""

    args: Sequence[str]
    returncode: int
    stdout: str
    stderr: str

    @property
    def ok(self) -> bool:
        return self.returncode == 0


def _env_map(env: Optional[Mapping[str, str]] = None) -> Optional[Dict[str, str]]:
    if env is None:
        return None
    return {str(k): str(v) for k, v in env.items()}


def docker_ps(
    *,
    args: Optional[Iterable[str]] = None,
) -> DockerResult:
    """Run `docker ps` with additional args and return structured result.

    For now this stays on the Docker CLI because several callers rely on the
    exact `--format` output. When those call sites are relaxed, this can be
    swapped to Docker SDK container listing.
    """
    cmd: List[str] = ["docker", "ps"]
    if args:
        cmd.extend(list(args))
    proc = subprocess.run(
        cmd,
        capture_output=True,
        text=True,
    )
    return DockerResult(
        args=cmd,
        returncode=proc.returncode,
        stdout=proc.stdout or "",
        stderr=proc.stderr or "",
    )


def docker_image_inspect(image: str) -> DockerResult:
    """Inspect a Docker image.

    Prefer Docker SDK when available; otherwise fall back to CLI.
    """
    if docker is not None:
        client = docker.from_env()
        try:
            # Use low-level inspect for maximum compatibility with existing callers.
            low_level = client.api
            data = low_level.inspect_image(image)
            import json

            stdout = json.dumps([data])
            return DockerResult(
                args=[f"docker image inspect {image}"],
                returncode=0,
                stdout=stdout,
                stderr="",
            )
        except docker.errors.ImageNotFound:  # type: ignore[attr-defined]
            return DockerResult(
                args=[f"docker image inspect {image}"],
                returncode=1,
                stdout="",
                stderr="Error: No such image",
            )
        except Exception as exc:
            # Fall through to CLI-style error.
            return DockerResult(
                args=[f"docker image inspect {image}"],
                returncode=1,
                stdout="",
                stderr=str(exc),
            )

    # Fallback: defer to Docker CLI.
    cmd = ["docker", "image", "inspect", image]
    proc = subprocess.run(
        cmd,
        capture_output=True,
        text=True,
    )
    return DockerResult(
        args=cmd,
        returncode=proc.returncode,
        stdout=proc.stdout or "",
        stderr=proc.stderr or "",
    )


def docker_run(
    args: Sequence[str],
    *,
    env: Optional[Mapping[str, str]] = None,
    cwd: Optional[Path] = None,
) -> DockerResult:
    """Run a generic Docker Engine operation via SDK.

    This is intentionally conservative: we only support the subset of commands
    that existing call sites use (`docker inspect`, not arbitrary `docker run`).
    """
    # Current call sites only use `docker inspect -f ... <id>`.
    if docker is not None and args[:2] == ["inspect", "-f"] and len(args) >= 4:
        target = str(args[3])
        client = docker.from_env()
        low_level = client.api
        try:
            data = low_level.inspect_container(target)
            # Emulate Go template used in compose_runtime.wait_for_compose_service_ready.
            state = data.get("State", {}) or {}
            status = state.get("Status", "")
            health_state = state.get("Health", {}) or {}
            health = health_state.get("Status", "none")
            restart_count = data.get("RestartCount", 0)
            stdout = f"{status}|{health}|{restart_count}"
            return DockerResult(
                args=["docker", *[str(a) for a in args]],
                returncode=0,
                stdout=stdout,
                stderr="",
            )
        except Exception as exc:
            # Fall back to CLI below.
            return DockerResult(
                args=["docker", *[str(a) for a in args]],
                returncode=1,
                stdout="",
                stderr=str(exc),
            )

    # Fallback to CLI for unsupported patterns to preserve behavior.
    cmd = ["docker", *[str(a) for a in args]]
    proc = subprocess.run(
        cmd,
        cwd=str(cwd) if cwd is not None else None,
        env=_env_map(env),
        capture_output=True,
        text=True,
    )
    return DockerResult(
        args=cmd,
        returncode=proc.returncode,
        stdout=proc.stdout or "",
        stderr=proc.stderr or "",
    )


def docker_rm(
    targets: Sequence[str],
    *,
    force: bool = True,
) -> DockerResult:
    """Remove one or more containers.

    Prefer SDK when available; otherwise use Docker CLI.
    """
    if docker is not None:
        client = docker.from_env()
        removed: List[str] = []
        errors: List[str] = []
        for name in targets:
            try:
                container = client.containers.get(name)
                container.remove(force=force)
                removed.append(str(name))
            except Exception as exc:
                # Match CLI semantics: non-existent containers are not fatal.
                errors.append(f"{name}: {exc}")
        if errors:
            return DockerResult(
                args=["docker", "rm", "-f" if force else "", *map(str, targets)],
                returncode=1,
                stdout="\n".join(removed),
                stderr="\n".join(errors),
            )
        return DockerResult(
            args=["docker", "rm", "-f" if force else "", *map(str, targets)],
            returncode=0,
            stdout="\n".join(removed),
            stderr="",
        )

    cmd = ["docker", "rm"]
    if force:
        cmd.append("-f")
    cmd.extend(list(targets))
    proc = subprocess.run(
        cmd,
        capture_output=True,
        text=True,
    )
    return DockerResult(
        args=cmd,
        returncode=proc.returncode,
        stdout=proc.stdout or "",
        stderr=proc.stderr or "",
    )


def docker_version_api() -> DockerResult:
    """Return server API version.

    Prefer SDK when available; otherwise fall back to CLI.
    """
    if docker is not None:
        client = docker.from_env()
        try:
            version = client.version() or {}
            api_version = str(version.get("ApiVersion", "")).strip()
            return DockerResult(
                args=["docker", "version", "--format", "{{.Server.APIVersion}}"],
                returncode=0,
                stdout=api_version,
                stderr="",
            )
        except Exception as exc:
            return DockerResult(
                args=["docker", "version", "--format", "{{.Server.APIVersion}}"],
                returncode=1,
                stdout="",
                stderr=str(exc),
            )

    cmd = ["docker", "version", "--format", "{{.Server.APIVersion}}"]
    proc = subprocess.run(
        cmd,
        capture_output=True,
        text=True,
    )
    return DockerResult(
        args=cmd,
        returncode=proc.returncode,
        stdout=proc.stdout or "",
        stderr=proc.stderr or "",
    )


def docker_build(
    *,
    context: Path,
    tag: str,
    build_args: Optional[Mapping[str, str]] = None,
) -> DockerResult:
    """Build a Docker image in `context` with the given tag.

    Prefer SDK when available; otherwise use Docker CLI.
    """
    if docker is not None:
        client = docker.from_env()
        try:
            _, logs = client.images.build(
                path=str(context),
                tag=tag,
                buildargs={k: str(v) for k, v in (build_args or {}).items()},
                rm=True,
            )
            stdout_lines: List[str] = []
            for chunk in logs:
                text = chunk.get("stream") or chunk.get("status") or ""
                if text:
                    stdout_lines.append(str(text).rstrip("\n"))
            stdout = "\n".join(stdout_lines)
            return DockerResult(
                args=["docker", "build", "-t", tag, "."],
                returncode=0,
                stdout=stdout,
                stderr="",
            )
        except Exception as exc:
            return DockerResult(
                args=["docker", "build", "-t", tag, "."],
                returncode=1,
                stdout="",
                stderr=str(exc),
            )

    cmd: List[str] = ["docker", "build", "-t", tag, "."]
    for k, v in (build_args or {}).items():
        cmd.extend(["--build-arg", f"{k}={v}"])
    proc = subprocess.run(
        cmd,
        cwd=str(context),
        capture_output=True,
        text=True,
    )
    return DockerResult(
        args=cmd,
        returncode=proc.returncode,
        stdout=proc.stdout or "",
        stderr=proc.stderr or "",
    )


def docker_build_file(
    *,
    context: Path,
    tag: str,
    dockerfile: Path,
    build_args: Optional[Mapping[str, str]] = None,
) -> DockerResult:
    """Build a Docker image with an explicit Dockerfile path."""
    if docker is not None:
        client = docker.from_env()
        try:
            _, logs = client.images.build(
                path=str(context),
                tag=tag,
                dockerfile=str(dockerfile),
                buildargs={k: str(v) for k, v in (build_args or {}).items()},
                rm=True,
            )
            stdout_lines: List[str] = []
            for chunk in logs:
                text = chunk.get("stream") or chunk.get("status") or ""
                if text:
                    stdout_lines.append(str(text).rstrip("\n"))
            return DockerResult(
                args=["docker", "build", "-t", tag, "-f", str(dockerfile), "."],
                returncode=0,
                stdout="\n".join(stdout_lines),
                stderr="",
            )
        except Exception as exc:
            return DockerResult(
                args=["docker", "build", "-t", tag, "-f", str(dockerfile), "."],
                returncode=1,
                stdout="",
                stderr=str(exc),
            )

    cmd: List[str] = ["docker", "build", "-t", tag, "-f", str(dockerfile), "."]
    for k, v in (build_args or {}).items():
        cmd.extend(["--build-arg", f"{k}={v}"])
    proc = subprocess.run(
        cmd,
        cwd=str(context),
        capture_output=True,
        text=True,
    )
    return DockerResult(
        args=cmd,
        returncode=proc.returncode,
        stdout=proc.stdout or "",
        stderr=proc.stderr or "",
    )


def docker_container_inspect_format(container: str, format_template: str) -> DockerResult:
    """Inspect a container with a limited set of Go-template formats.

    Supports only the formats used by Armorer call sites; falls back to CLI for others.
    """
    supported = {
        "{{.Config.Image}}": ("Config", "Image"),
        "{{.Config.User}}": ("Config", "User"),
    }
    if docker is not None and format_template in supported:
        client = docker.from_env()
        try:
            data = client.api.inspect_container(container)
            section, key = supported[format_template]
            value = str((data.get(section, {}) or {}).get(key, "") or "")
            return DockerResult(
                args=["docker", "inspect", container, "--format", format_template],
                returncode=0,
                stdout=value,
                stderr="",
            )
        except Exception as exc:
            return DockerResult(
                args=["docker", "inspect", container, "--format", format_template],
                returncode=1,
                stdout="",
                stderr=str(exc),
            )

    cmd = ["docker", "inspect", container, "--format", format_template]
    proc = subprocess.run(cmd, capture_output=True, text=True)
    return DockerResult(
        args=cmd,
        returncode=proc.returncode,
        stdout=proc.stdout or "",
        stderr=proc.stderr or "",
    )


def compose_ps(
    *,
    cwd: Path,
    args: Optional[Iterable[str]] = None,
    timeout: Optional[int] = None,
) -> DockerResult:
    """Run `docker compose ps` from a given directory."""
    cmd: List[str] = ["docker", "compose", "ps"]
    if args:
        cmd.extend(list(args))
    proc = subprocess.run(
        cmd,
        cwd=str(cwd),
        capture_output=True,
        text=True,
        timeout=timeout,
    )
    return DockerResult(
        args=cmd,
        returncode=getattr(proc, "returncode", 1),
        stdout=getattr(proc, "stdout", None) or "",
        stderr=getattr(proc, "stderr", None) or "",
    )


def compose_generic(
    *,
    cwd: Path,
    subcommand: Sequence[str],
    env: Optional[Mapping[str, str]] = None,
    check: bool = False,
) -> DockerResult:
    """Run a generic `docker compose ...` command."""
    cmd: List[str] = ["docker", "compose", *[str(p) for p in subcommand]]
    proc = subprocess.run(
        cmd,
        cwd=str(cwd),
        env=_env_map(env),
        capture_output=True,
        text=True,
    )
    if check and proc.returncode != 0:
        raise subprocess.CalledProcessError(
            proc.returncode,
            cmd,
            output=proc.stdout,
            stderr=proc.stderr,
        )
    return DockerResult(
        args=cmd,
        returncode=getattr(proc, "returncode", 1),
        stdout=getattr(proc, "stdout", None) or "",
        stderr=getattr(proc, "stderr", None) or "",
    )


# -----------------------------------------------------------------------
# Phase 2 additions
# -----------------------------------------------------------------------


def docker_info(
    *,
    format_template: Optional[str] = None,
) -> DockerResult:
    """Run `docker info`, optionally with --format.

    SDK path returns a JSON blob when no template is given.
    """
    if docker is not None:
        try:
            client = docker.from_env()
            data = client.info() or {}
            stdout = _json.dumps(data)
            return DockerResult(
                args=["docker", "info"],
                returncode=0,
                stdout=stdout,
                stderr="",
            )
        except Exception as exc:
            return DockerResult(
                args=["docker", "info"],
                returncode=1,
                stdout="",
                stderr=str(exc),
            )

    cmd: List[str] = ["docker", "info"]
    if format_template:
        cmd.extend(["--format", format_template])
    proc = subprocess.run(cmd, capture_output=True, text=True)
    return DockerResult(args=cmd, returncode=proc.returncode,
                        stdout=proc.stdout or "", stderr=proc.stderr or "")


def docker_logs(
    container: str,
    *,
    tail: Optional[int] = None,
) -> DockerResult:
    """Fetch container logs.

    Prefer SDK when available.
    """
    if docker is not None:
        try:
            client = docker.from_env()
            ctr = client.containers.get(container)
            output = ctr.logs(tail=tail if tail is not None else "all")
            text = output.decode("utf-8", errors="replace") if isinstance(output, bytes) else str(output)
            return DockerResult(
                args=["docker", "logs", container],
                returncode=0,
                stdout=text,
                stderr="",
            )
        except Exception as exc:
            return DockerResult(
                args=["docker", "logs", container],
                returncode=1,
                stdout="",
                stderr=str(exc),
            )

    cmd: List[str] = ["docker", "logs"]
    if tail is not None:
        cmd.extend(["--tail", str(tail)])
    cmd.append(container)
    proc = subprocess.run(cmd, capture_output=True, text=True)
    return DockerResult(args=cmd, returncode=proc.returncode,
                        stdout=proc.stdout or "", stderr=proc.stderr or "")


def docker_image_inspect_format(
    image: str,
    format_template: str,
) -> DockerResult:
    """Inspect an image with a Go-template ``--format``."""
    cmd = ["docker", "image", "inspect", image, "--format", format_template]
    proc = subprocess.run(cmd, capture_output=True, text=True)
    return DockerResult(
        args=cmd,
        returncode=getattr(proc, "returncode", 1),
        stdout=getattr(proc, "stdout", None) or "",
        stderr=getattr(proc, "stderr", None) or "",
    )


def docker_image_repo_digests(image: str) -> DockerResult:
    """Return RepoDigests for an image via SDK or CLI."""
    if docker is not None:
        try:
            client = docker.from_env()
            img = client.images.get(image)
            digests = img.attrs.get("RepoDigests") or []
            return DockerResult(
                args=["docker", "image", "inspect", image, "--format", "{{json .RepoDigests}}"],
                returncode=0,
                stdout=_json.dumps(digests),
                stderr="",
            )
        except Exception as exc:
            return DockerResult(
                args=["docker", "image", "inspect", image],
                returncode=1,
                stdout="",
                stderr=str(exc),
            )

    return docker_image_inspect_format(image, "{{json .RepoDigests}}")


def docker_ps_filter(
    *,
    name_filter: Optional[str] = None,
    format_template: str = "{{.ID}}",
    all_containers: bool = True,
) -> DockerResult:
    """Run `docker ps` with --filter and --format flags."""
    cmd: List[str] = ["docker", "ps"]
    if all_containers:
        cmd.append("-a")
    if name_filter:
        cmd.extend(["--filter", f"name={name_filter}"])
    cmd.extend(["--format", format_template])
    proc = subprocess.run(cmd, capture_output=True, text=True)
    return DockerResult(args=cmd, returncode=proc.returncode,
                        stdout=proc.stdout or "", stderr=proc.stderr or "")


# -- Compose helpers (stay on CLI — no SDK surface for Compose) -----------


def compose_up(
    *,
    cwd: Path,
    services: Optional[Sequence[str]] = None,
    profiles: Optional[Sequence[str]] = None,
    env: Optional[Mapping[str, str]] = None,
    remove_orphans: bool = True,
    detach: bool = True,
    force_recreate: bool = False,
    timeout: Optional[int] = None,
) -> DockerResult:
    """Run `docker compose up [-d] [--remove-orphans] [services]`."""
    cmd: List[str] = ["docker", "compose"]
    for p in (profiles or []):
        cmd.extend(["--profile", str(p)])
    cmd.append("up")
    if detach:
        cmd.append("-d")
    if remove_orphans:
        cmd.append("--remove-orphans")
    if force_recreate:
        cmd.append("--force-recreate")
    for svc in (services or []):
        cmd.append(str(svc))
    try:
        proc = subprocess.run(
            cmd,
            cwd=str(cwd),
            env=_env_map(env),
            capture_output=True,
            text=True,
            timeout=timeout,
        )
    except subprocess.TimeoutExpired:
        return DockerResult(
            args=cmd,
            returncode=124,
            stdout="",
            stderr="timeout expired",
        )
    return DockerResult(
        args=cmd,
        returncode=getattr(proc, "returncode", 1),
        stdout=getattr(proc, "stdout", None) or "",
        stderr=getattr(proc, "stderr", None) or "",
    )


def compose_down(
    *,
    cwd: Path,
    env: Optional[Mapping[str, str]] = None,
    volumes: bool = False,
    remove_orphans: bool = False,
    timeout: Optional[int] = None,
) -> DockerResult:
    """Run `docker compose down` (optionally with -v / --remove-orphans)."""
    cmd: List[str] = ["docker", "compose", "down"]
    if volumes:
        cmd.append("-v")
    if remove_orphans:
        cmd.append("--remove-orphans")
    proc = subprocess.run(
        cmd,
        cwd=str(cwd),
        env=_env_map(env),
        capture_output=True,
        text=True,
        timeout=timeout,
    )
    return DockerResult(
        args=cmd,
        returncode=getattr(proc, "returncode", 1),
        stdout=getattr(proc, "stdout", None) or "",
        stderr=getattr(proc, "stderr", None) or "",
    )


def compose_run(
    *,
    cwd: Path,
    service: str,
    run_args: Sequence[str] = (),
    rm: bool = True,
    no_tty: bool = True,
    entrypoint: Optional[str] = None,
    env: Optional[Mapping[str, str]] = None,
    timeout: Optional[int] = None,
    interactive: bool = False,
    stdin: Optional[int] = None,
    stdout: Optional[int] = None,
    stderr: Optional[int] = None,
) -> DockerResult:
    """Run `docker compose run [--rm] [-T] [--entrypoint X] <service> [args]`."""
    cmd: List[str] = ["docker", "compose", "run"]
    if rm:
        cmd.append("--rm")
    if no_tty:
        cmd.append("-T")
    if entrypoint is not None:
        cmd.extend(["--entrypoint", str(entrypoint)])
    cmd.append(service)
    cmd.extend(str(a) for a in run_args)
    kwargs: dict = {"cwd": str(cwd), "env": _env_map(env), "text": True}
    if timeout is not None:
        kwargs["timeout"] = timeout
    if interactive:
        kwargs["stdin"] = stdin
        kwargs["stdout"] = stdout
        kwargs["stderr"] = stderr
    else:
        kwargs["capture_output"] = True
    try:
        proc = subprocess.run(cmd, **kwargs)
    except subprocess.TimeoutExpired:
        return DockerResult(args=cmd, returncode=124,
                            stdout="", stderr="timeout expired")
    return DockerResult(
        args=cmd, returncode=proc.returncode,
        stdout=getattr(proc, "stdout", None) or "",
        stderr=getattr(proc, "stderr", None) or "",
    )


def compose_logs(
    *,
    cwd: Path,
    tail: Optional[int] = None,
    services: Optional[Sequence[str]] = None,
    timeout: Optional[int] = None,
) -> DockerResult:
    """Run `docker compose logs [--tail N] [services]`."""
    cmd: List[str] = ["docker", "compose", "logs"]
    if tail is not None:
        cmd.extend(["--tail", str(tail)])
    for svc in (services or []):
        cmd.append(str(svc))
    proc = subprocess.run(
        cmd,
        cwd=str(cwd),
        capture_output=True,
        text=True,
        timeout=timeout,
    )
    return DockerResult(
        args=cmd,
        returncode=getattr(proc, "returncode", 1),
        stdout=getattr(proc, "stdout", None) or "",
        stderr=getattr(proc, "stderr", None) or "",
    )


def compose_build(
    *,
    cwd: Path,
    services: Optional[Sequence[str]] = None,
) -> DockerResult:
    """Run `docker compose build [services]`."""
    cmd: List[str] = ["docker", "compose", "build"]
    for svc in (services or []):
        cmd.append(str(svc))
    proc = subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True)
    return DockerResult(args=cmd, returncode=proc.returncode,
                        stdout=getattr(proc, "stdout", None) or "", stderr=getattr(proc, "stderr", None) or "")


def compose_config(
    *,
    cwd: Path,
    quiet: bool = False,
    env: Optional[Mapping[str, str]] = None,
    timeout: Optional[int] = None,
) -> DockerResult:
    """Run `docker compose config` to validate compose files."""
    cmd = ["docker", "compose", "config"]
    if quiet:
        cmd.append("-q")
    proc = subprocess.run(
        cmd,
        cwd=str(cwd),
        env=_env_map(env),
        capture_output=True,
        text=True,
        timeout=timeout,
    )
    return DockerResult(
        args=cmd,
        returncode=getattr(proc, "returncode", 1),
        stdout=getattr(proc, "stdout", None) or "",
        stderr=getattr(proc, "stderr", None) or "",
    )


def compose_stop(
    *,
    cwd: Path,
    timeout: Optional[int] = None,
) -> DockerResult:
    """Run `docker compose stop`."""
    cmd = ["docker", "compose", "stop"]
    proc = subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True, timeout=timeout)
    return DockerResult(args=cmd, returncode=proc.returncode,
                        stdout=getattr(proc, "stdout", None) or "", stderr=getattr(proc, "stderr", None) or "")


def compose_restart(
    *,
    cwd: Path,
    timeout: Optional[int] = None,
) -> DockerResult:
    """Run `docker compose restart`."""
    cmd = ["docker", "compose", "restart"]
    proc = subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True, timeout=timeout)
    return DockerResult(args=cmd, returncode=proc.returncode,
                        stdout=getattr(proc, "stdout", None) or "", stderr=getattr(proc, "stderr", None) or "")


def compose_version(*, short: bool = False) -> DockerResult:
    """Check if `docker compose` (v2) is available."""
    cmd = ["docker", "compose", "version"]
    if short:
        cmd.append("--short")
    try:
        proc = subprocess.run(cmd, capture_output=True, text=True)
        return DockerResult(args=cmd, returncode=proc.returncode,
                            stdout=getattr(proc, "stdout", None) or "", stderr=getattr(proc, "stderr", None) or "")
    except FileNotFoundError:
        return DockerResult(args=cmd, returncode=127,
                            stdout="", stderr="docker compose not found")


def compose_exec(
    *,
    cwd: Path,
    service: str,
    exec_args: Sequence[str] = (),
    env: Optional[Mapping[str, str]] = None,
    no_tty: bool = True,
    timeout: Optional[int] = None,
) -> DockerResult:
    """Run `docker compose exec [-T] <service> [args]`."""
    cmd: List[str] = ["docker", "compose", "exec"]
    if no_tty:
        cmd.append("-T")
    cmd.append(str(service))
    cmd.extend(str(a) for a in exec_args)
    proc = subprocess.run(
        cmd,
        cwd=str(cwd),
        env=_env_map(env),
        capture_output=True,
        text=True,
        timeout=timeout,
    )
    return DockerResult(
        args=cmd,
        returncode=getattr(proc, "returncode", 1),
        stdout=getattr(proc, "stdout", None) or "",
        stderr=getattr(proc, "stderr", None) or "",
    )


def run_streaming(
    cmd: Sequence[str] | str,
    *,
    cwd: Optional[Path] = None,
    env: Optional[Mapping[str, str]] = None,
    shell: bool = False,
    text: bool = True,
    stdin=None,
    stdout=None,
    stderr=None,
) -> subprocess.Popen:
    """Start a streaming process (Popen) with consistent defaults.

    This is intentionally thin: callers manage lifecycle (wait/terminate/kill).
    """
    if stdin is None:
        stdin = subprocess.DEVNULL
    if stdout is None:
        stdout = subprocess.PIPE
    if stderr is None:
        stderr = subprocess.STDOUT
    return subprocess.Popen(
        cmd,
        cwd=str(cwd) if cwd is not None else None,
        env=_env_map(env),
        shell=shell,
        stdin=stdin,
        stdout=stdout,
        stderr=stderr,
        text=text,
    )


def compose_up_streaming(
    *,
    cwd: Path,
    services: Optional[Sequence[str]] = None,
    profiles: Optional[Sequence[str]] = None,
    env: Optional[Mapping[str, str]] = None,
    remove_orphans: bool = True,
    detach: bool = True,
) -> subprocess.Popen:
    """Start `docker compose up` with streaming output."""
    cmd: List[str] = ["docker", "compose"]
    for p in (profiles or []):
        cmd.extend(["--profile", str(p)])
    cmd.append("up")
    if detach:
        cmd.append("-d")
    if remove_orphans:
        cmd.append("--remove-orphans")
    for svc in (services or []):
        cmd.append(str(svc))
    return run_streaming(cmd, cwd=cwd, env=env, shell=False, text=True)
