"""
GitHub Copilot CLI session management.

Provides utilities for starting and managing Copilot CLI sessions
programmatically, supporting both interactive and non-interactive modes,
with session ID tracking and state persistence.

Binary variants
---------------
Two Copilot CLI variants are supported:

* **Standalone binary** (preferred) – invoked as ``copilot -i <prompt>``
  (interactive) or ``copilot -p <prompt>`` (non-interactive).  The
  standalone binary has **no** ``suggest`` subcommand.
* **``gh copilot`` extension** (fallback) – invoked as
  ``gh copilot suggest <prompt>``.

The ``_build_copilot_args`` helper selects the correct variant at runtime.

Research notes
--------------
The standalone binary requires ``--allow-all`` when running in
non-interactive mode; without it shell-command tool invocations (including
``agdt-*`` commands, ``python``, ``gh``, etc.) are rejected with "Permission
denied and could not request permission from user".  ``--allow-all`` is
equivalent to ``--allow-all-tools --allow-all-paths --allow-all-urls`` and
grants the full set of permissions required for autonomous workflow execution.
``_build_copilot_args`` therefore appends ``--allow-all`` to the argument
list for the standalone-binary non-interactive path.  The ``gh copilot``
extension fallback does not support these flags and is left unchanged.

The standalone binary also supports ``--autopilot`` for interactive sessions.
When ``--autopilot`` is passed the agent executes tasks autonomously without
requiring the user to press Tab to activate autopilot mode.  This flag is only
meaningful for interactive (``-i``) sessions; non-interactive (``-p``) sessions
already run autonomously via ``--allow-all``.  The ``gh copilot`` extension
fallback does not support ``--autopilot``; a warning is emitted when autopilot
is requested but only the fallback is available.

For non-interactive sessions the full prompt text is written to a file on
disk, and a short file-reference instruction is passed via ``-p`` instead of
the raw prompt content.  This avoids reliability issues with multiline /
special-character content in Windows command-line argument passing and gives
the agent an unambiguous first action (read the instruction file).

Fallback behaviour
------------------
When neither the standalone binary nor the ``gh copilot`` extension is
available the session cannot be started.  The module logs a warning and
prints the prompt to stdout so that the user or pipeline can invoke a
session manually.
"""

import os
import shutil
import subprocess
import sys
import threading
import uuid
import warnings
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import IO

from agentic_devtools.state import get_state_dir, set_value

from ..subprocess_utils import run_safe

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

# Default Copilot model used as ultimate fallback when no model is configured
# in project config and none is supplied via --model.
DEFAULT_COPILOT_MODEL = "gpt-4o"


def get_default_copilot_model() -> str:
    """Return the default Copilot model for workflow sessions.

    Resolution order:
    1. ``default_copilot_model`` in ``.agdt/config/project.json`` (set by
       ``agdt-setup`` model selection prompt).
    2. :data:`DEFAULT_COPILOT_MODEL` hardcoded constant (``"gpt-4o"``).

    Returns:
        A non-empty model identifier string.
    """
    try:
        from agentic_devtools.cli.config.project_config import get_project_config_value

        configured = get_project_config_value("default_copilot_model")
        if configured and configured.strip():
            return configured.strip()
    except Exception:
        pass
    return DEFAULT_COPILOT_MODEL


# State key namespace
_COPILOT_NS = "copilot"

# Log file directory (relative to state dir)
_LOG_DIR_NAME = "background-tasks/logs"

# Prompt file naming pattern
_PROMPT_FILE_PATTERN = "copilot-session-{session_id}-prompt.md"

# Managed install path for the standalone copilot binary
_MANAGED_COPILOT = Path.home() / ".agdt" / "bin" / ("copilot.exe" if sys.platform == "win32" else "copilot")

# Maximum prompt length (in characters) that can safely be passed as a
# CLI argument.  The standalone binary receives the prompt via ``-i`` or
# ``-p``; the ``gh copilot`` extension receives it as a positional
# argument to ``gh copilot suggest``.  Windows' CreateProcess imposes a
# 32,767-character limit on the entire command line; we leave headroom
# for the command prefix and OS overhead.
_MAX_GH_COPILOT_ARGV_LENGTH = 30_000

# Safe length limit for the pre-processed inline prompt passed via argv.
# Truncation is applied to the prompt content (before ``_build_copilot_args``
# checks ``_MAX_GH_COPILOT_ARGV_LENGTH``) so that the final single-line
# version produced by ``_inline_prompt`` (after ``\n`` → ``<br>`` replacement
# and appending the backup file-reference suffix) still sits at least 100
# characters below the hard cap.  This gap is a conservative safety margin
# for the rest of the command line (flags, executable path, OS overhead) and
# guards against minor miscounts in edge-case content.
_SAFE_ARGV_LENGTH = 29_900


# ---------------------------------------------------------------------------
# Public dataclass
# ---------------------------------------------------------------------------


@dataclass
class CopilotSessionResult:
    """Result returned by :func:`start_copilot_session`.

    Attributes:
        session_id: Unique identifier for the session (UUID4 hex).
        mode: ``"interactive"`` or ``"non-interactive"``.
        prompt_file: Absolute path to the temporary prompt file.
        start_time: ISO-8601 UTC timestamp when the session was started.
        pid: Process ID for non-interactive sessions; ``None`` for
            interactive sessions (where the process has already exited
            when this object is returned).
        process: The :class:`subprocess.Popen` handle for non-interactive
            sessions; ``None`` for interactive sessions.
    """

    session_id: str
    mode: str
    prompt_file: str
    start_time: str
    pid: int | None = field(default=None)
    process: subprocess.Popen | None = field(default=None, repr=False)  # type: ignore[type-arg]


# ---------------------------------------------------------------------------
# Availability check
# ---------------------------------------------------------------------------


def _get_copilot_binary() -> str | None:
    """Return the path to the copilot binary, or ``None`` if not found.

    Checks (in order):
    1. The standalone ``copilot`` binary on the system ``PATH``.
    2. The managed install at ``~/.agdt/bin/copilot[.exe]``.

    Returns:
        Absolute path string when found, ``None`` otherwise.
    """
    system_path = shutil.which("copilot")
    if system_path:
        return system_path
    if _MANAGED_COPILOT.is_file():
        return str(_MANAGED_COPILOT)
    return None


def is_gh_copilot_available() -> bool:
    """Return ``True`` if a usable Copilot CLI can be invoked on this machine.

    Checks (in order):
    1. A standalone ``copilot`` binary (system PATH or ``~/.agdt/bin/``).
    2. The ``gh copilot`` extension (legacy fallback): ``gh`` must be present
       and ``gh copilot --help`` must exit with return code 0.

    Returns:
        ``True`` when at least one check passes; ``False`` otherwise.
    """
    # Prefer standalone copilot binary
    if _get_copilot_binary() is not None:
        return True

    # Fallback: gh copilot extension
    if not shutil.which("gh"):
        return False
    try:
        result = run_safe(
            ["gh", "copilot", "--help"],
            capture_output=True,
            text=True,
            timeout=10,
            shell=False,
        )
        return result.returncode == 0
    except (OSError, subprocess.TimeoutExpired):
        return False


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------


def _make_session_id() -> str:
    """Generate a new unique session identifier (UUID4 hex string)."""
    return uuid.uuid4().hex


def _get_prompt_file_path(session_id: str) -> Path:
    """Return the path where the prompt file should be written.

    The file is placed inside the workflow state directory, following the
    same convention as other temporary files.

    Args:
        session_id: The session identifier.

    Returns:
        Absolute :class:`~pathlib.Path` for the prompt file.
    """
    state_dir = get_state_dir()
    return state_dir / _PROMPT_FILE_PATTERN.format(session_id=session_id)


def _get_log_file_path(session_id: str, start_time: str) -> Path:
    """Return the path for the non-interactive session log file.

    Args:
        session_id: The session identifier.
        start_time: ISO-8601 timestamp (used in the filename).

    Returns:
        Absolute :class:`~pathlib.Path` for the log file.
    """
    state_dir = get_state_dir()
    timestamp = start_time.replace(":", "").replace("-", "").replace(".", "_")[:18]
    filename = f"copilot_session_{timestamp}.log"
    return state_dir / _LOG_DIR_NAME / filename


def _build_copilot_args(
    prompt: str, *, interactive: bool = True, autopilot: bool = True, model: str | None = None
) -> list[str] | None:
    """Build the copilot argument list.

    Uses the standalone ``copilot`` binary when available (preferred), falling
    back to the ``gh copilot`` extension for backward compatibility.

    Neither the standalone binary nor the ``gh copilot`` extension supports
    ``--file``.  The standalone binary accepts the prompt via ``-p``/``--prompt``
    for non-interactive use and ``-i`` for interactive use, while the
    ``gh copilot`` extension accepts the prompt as a positional ``[subject]``
    argument.  When the prompt exceeds :data:`_MAX_GH_COPILOT_ARGV_LENGTH`,
    ``None`` is returned so the caller can use a fallback.

    In non-interactive mode the standalone binary also receives
    ``--allow-all`` so that it can execute shell commands (``agdt-*``,
    ``python``, ``gh``, etc.) without prompting for permission.
    ``--allow-all`` is equivalent to ``--allow-all-tools --allow-all-paths
    --allow-all-urls``; ``--allow-all-tools`` alone is insufficient because
    shell commands such as ``gh`` and ``python`` also require path and URL
    permissions.  Without this flag every tool invocation is rejected with
    "Permission denied and could not request permission from user".

    Args:
        prompt: The full prompt text to pass to the copilot command.
        interactive: When ``True`` (default) the standalone binary receives
            ``-i``; when ``False`` it receives ``-p`` and
            ``--allow-all``.  Ignored for the ``gh copilot``
            extension path which always uses a positional arg.
        autopilot: When ``True`` (default) and ``interactive=True``, the
            standalone binary receives ``--autopilot`` so that the agent
            executes tasks autonomously without requiring the user to press
            Tab.  Has no effect when ``interactive=False`` (non-interactive
            sessions are already autonomous via ``--allow-all``).  When the
            ``gh copilot`` extension fallback is used and both
            ``interactive=True`` and ``autopilot=True``, a warning is emitted
            because the fallback does not support ``--autopilot``.
        model: Optional Copilot model ID (e.g. ``"gpt-4o"``).
            When not ``None`` and not empty, ``--model <model>`` is inserted
            into the standalone binary args before the ``-i``/``-p`` flag.
            The ``gh copilot suggest`` fallback does not support ``--model``;
            a warning is emitted and the flag is omitted.

    Returns:
        List of strings suitable for :func:`subprocess.Popen`, or ``None``
        when the prompt is too large for the argv path.
    """
    # Normalise: treat empty/whitespace-only model as None.
    if isinstance(model, str):
        model = model.strip() or None

    if len(prompt) > _MAX_GH_COPILOT_ARGV_LENGTH:
        return None
    standalone = _get_copilot_binary()
    if standalone:
        flag = "-i" if interactive else "-p"
        # --autopilot, --allow-all, and --model must come before -p/-i so
        # that argument parsers which stop processing flags after the first
        # positional argument still recognise them.
        args = [standalone]
        if interactive and autopilot:
            args.append("--autopilot")
        if not interactive:
            args.append("--allow-all")
        if model is not None:
            args.extend(["--model", model])
        args.extend([flag, prompt])
        return args
    if interactive and autopilot:
        warnings.warn(
            "--autopilot is not supported by the gh copilot extension fallback; autopilot mode will not be activated.",
            stacklevel=2,
        )
    if model is not None:
        warnings.warn(
            "--model is not supported by the gh copilot extension fallback; model selection will not be applied.",
            stacklevel=2,
        )
    return ["gh", "copilot", "suggest", prompt]


def build_copilot_args(
    prompt: str, *, interactive: bool = True, autopilot: bool = True, model: str | None = None
) -> list[str] | None:
    """Build the copilot argument list (public API).

    Public wrapper around the internal argument builder.  Use this when you
    need to obtain the argument list without starting a full session — for
    example, to inject it into a VS Code ``tasks.json`` auto-start task.

    Args:
        prompt: The full prompt text to pass to the copilot command.
        interactive: When ``True`` (default) the standalone binary receives
            ``-i``; when ``False`` it receives ``-p`` and
            ``--allow-all``.
        autopilot: When ``True`` (default) and ``interactive=True``, the
            standalone binary receives ``--autopilot`` so that the agent
            executes tasks autonomously without requiring the user to press
            Tab.  Has no effect for non-interactive mode.
        model: Optional Copilot model ID (e.g. ``"gpt-4o"``).
            When not ``None``, ``--model <model>`` is added for the
            standalone binary.  The ``gh copilot suggest`` fallback emits
            a warning and omits the flag.

    Returns:
        List of strings suitable for :func:`subprocess.Popen`, or ``None``
        when the prompt is too large for the argv path.

    Note:
        When ``interactive=True`` and ``autopilot=True`` but only the
        ``gh copilot`` fallback is available (for example, when the
        standalone Copilot CLI binary is not installed), this function may
        emit a warning message to :data:`sys.stderr` describing the degraded
        behavior.  Callers should be prepared for this additional stderr
        output in that configuration.
    """
    return _build_copilot_args(prompt, interactive=interactive, autopilot=autopilot, model=model)


def _persist_session_state(result: CopilotSessionResult, model: str | None = None) -> None:
    """Write session metadata to ``agdt-state.json``.

    Keys written (all under the ``copilot.`` namespace):
    - ``copilot.session_id``
    - ``copilot.mode``
    - ``copilot.prompt_file``
    - ``copilot.start_time``
    - ``copilot.pid`` (empty string when not applicable)
    - ``copilot.model_id`` (only when *model* is not ``None``)

    Args:
        result: The :class:`CopilotSessionResult` to persist.
        model: Optional Copilot model ID to persist.
    """
    set_value(f"{_COPILOT_NS}.session_id", result.session_id)
    set_value(f"{_COPILOT_NS}.mode", result.mode)
    set_value(f"{_COPILOT_NS}.prompt_file", result.prompt_file)
    set_value(f"{_COPILOT_NS}.start_time", result.start_time)
    set_value(f"{_COPILOT_NS}.pid", result.pid if result.pid is not None else "")
    if model is not None:
        set_value(f"{_COPILOT_NS}.model_id", model)


def _print_fallback_prompt(prompt: str) -> None:
    """Print the prompt to stdout as a fallback when ``gh copilot`` is unavailable.

    Args:
        prompt: The full prompt text to display.
    """
    print(
        "WARNING: gh copilot is not available. Please start a session manually with the following prompt:\n",
        file=sys.stderr,
    )
    print(prompt)


def _inline_prompt(prompt_text: str, prompt_file_path: str) -> str:
    """Convert multi-line prompt to single-line with ``<br>`` separators and backup reference.

    If the result exceeds :data:`_SAFE_ARGV_LENGTH`, the ``Repo-Specific
    Review Focus Areas`` content is truncated first (with ``...`` appended).
    If that is insufficient or the section is absent, a short
    file-reference-only prompt is returned instead so the backup path is
    always preserved.  A :func:`warnings.warn` is emitted whenever
    truncation occurs.
    """
    suffix = f"   <br>   The full prompt is also saved at: {prompt_file_path}"
    single_line = prompt_text.replace("\n", "   <br>   ") + suffix

    if len(single_line) <= _SAFE_ARGV_LENGTH:
        return single_line

    # --- Truncation path: prefer trimming Repo-Specific Focus Areas ---
    focus_marker = "## Repo-Specific Review Focus Areas"
    next_section_prefix = "\n## "

    focus_start = prompt_text.find(focus_marker)
    if focus_start != -1:
        content_start = prompt_text.find("\n", focus_start) + 1
        next_section = prompt_text.find(next_section_prefix, content_start)
        if next_section != -1:
            before = prompt_text[:content_start]
            after = prompt_text[next_section:]
            # Try with focus areas fully removed first
            stripped = before + "...\n" + after
            stripped_line = stripped.replace("\n", "   <br>   ") + suffix
            if len(stripped_line) <= _SAFE_ARGV_LENGTH:
                # Partially include focus areas to use available space
                focus_content = prompt_text[content_start:next_section]
                overhead = len(stripped_line)
                available = _SAFE_ARGV_LENGTH - overhead
                if available > 0:
                    keep = focus_content[:available]
                    last_newline = keep.rfind("\n")
                    if last_newline > 0:
                        keep = keep[:last_newline]
                    partial = before + keep + "\n...\n" + after
                    partial_line = partial.replace("\n", "   <br>   ") + suffix
                    if len(partial_line) <= _SAFE_ARGV_LENGTH:
                        warnings.warn(
                            "Prompt truncated: Repo-Specific Review Focus Areas was trimmed to fit argv limit.",
                            stacklevel=2,
                        )
                        return partial_line
                # Fall through to fully removed version
                warnings.warn(
                    "Prompt truncated: Repo-Specific Review Focus Areas fully removed to fit argv limit.",
                    stacklevel=2,
                )
                return stripped_line

    # No focus areas section or still too long — fall back to a short
    # file-reference-only prompt so the backup path is always preserved.
    warnings.warn(
        "Prompt too large for inline use; falling back to file-reference-only prompt.",
        stacklevel=2,
    )
    return f"Prompt too large to pass inline safely. The full prompt is also saved at: {prompt_file_path}"


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def start_copilot_session(
    prompt: str,
    working_directory: str,
    interactive: bool = True,
    session_id: str | None = None,
    *,
    autopilot: bool = True,
    model: str | None = None,
) -> CopilotSessionResult:
    """Start a ``gh copilot`` CLI session with the given prompt.

    Behaviour:
    - Generates (or reuses) a unique session ID.
    - Writes *prompt* to a temporary file for persistence and manual
      reuse.  The full prompt text is inlined as a single line (newlines
      replaced with ``   <br>   ``) and passed as a CLI argument to the
      Copilot process in both interactive and non-interactive modes.
      A backup file reference is appended to the inlined prompt.
    - If the inlined prompt exceeds the safe argv-length limit
      (``_SAFE_ARGV_LENGTH``), the ``Repo-Specific Review Focus Areas``
      section is truncated first.  If that is insufficient or absent,
      a short file-reference-only prompt is used instead.  A warning is
      emitted whenever truncation occurs.
    - If ``_build_copilot_args`` still returns ``None`` (prompt exceeds
      ``_MAX_GH_COPILOT_ARGV_LENGTH``), the full prompt is printed to
      stdout as a fallback.
    - Starts ``copilot -i/-p <prompt>`` (standalone binary) or
      ``gh copilot suggest <prompt>`` (extension fallback).
    - In **interactive** mode the child process inherits the current
      terminal (stdin / stdout / stderr), so the user can interact with
      it directly.  This call blocks until the interactive session ends.
    - In **non-interactive** mode the child process runs in the
      background with stdout and stderr captured to a log file.  The
      call returns immediately.
    - Session metadata is written to ``agdt-state.json`` under the
      ``copilot.*`` namespace.
    - If ``gh copilot`` is not available, a warning is emitted and the
      prompt is printed to stdout so the user can start a session
      manually.

    Args:
        prompt: The full prompt text to send to the Copilot session.
        working_directory: The directory in which to run the command.
            Typically the worktree root.
        interactive: When ``True`` (default) the process runs with an
            attached terminal.  When ``False`` the process runs
            detached in the background.
        session_id: Optional pre-generated session ID.  A new UUID4 hex
            string is generated when this is ``None``.
        autopilot: When ``True`` (default) and ``interactive=True``, the
            standalone binary receives ``--autopilot`` so that the agent
            executes tasks autonomously without requiring the user to press
            Tab.  Has no effect for non-interactive mode.  When the
            ``gh copilot`` extension fallback is used and both
            ``interactive=True`` and ``autopilot=True``, a warning is
            emitted because the fallback does not support ``--autopilot``.
        model: Optional Copilot model ID (e.g. ``"gpt-4o"``).
            Forwarded to ``_build_copilot_args`` and persisted as
            ``copilot.model_id`` in state.

    Returns:
        A :class:`CopilotSessionResult` with session metadata.

    Raises:
        OSError: If the prompt file cannot be written to disk.
    """
    if session_id is None:
        session_id = _make_session_id()

    # Normalise model: treat empty/whitespace-only strings as None so that
    # stdout, state, and argv all behave consistently.
    if isinstance(model, str):
        model = model.strip() or None

    start_time = datetime.now(timezone.utc).isoformat()
    mode = "interactive" if interactive else "non-interactive"

    # --- Write prompt to temp file -------------------------------------------
    prompt_file_path = _get_prompt_file_path(session_id)
    prompt_file_path.parent.mkdir(parents=True, exist_ok=True)
    prompt_file_path.write_text(prompt, encoding="utf-8")
    prompt_file = str(prompt_file_path)

    # --- Log model selection -------------------------------------------------
    if model:
        print(f"Copilot model: {model}")

    # --- Check availability --------------------------------------------------
    if not is_gh_copilot_available():
        warnings.warn(
            "gh copilot is not available; printing prompt to stdout as fallback.",
            stacklevel=2,
        )
        _print_fallback_prompt(prompt)
        result = CopilotSessionResult(
            session_id=session_id,
            mode=mode,
            prompt_file=prompt_file,
            start_time=start_time,
            pid=None,
            process=None,
        )
        _persist_session_state(result, model=model)
        return result

    # --- Build command -------------------------------------------------------
    # Inline the full prompt as a single line (newlines replaced with <br>
    # separators) for both interactive and non-interactive modes.  The file
    # on disk still contains the multi-line version for manual reuse.
    argv_prompt = _inline_prompt(prompt, prompt_file)
    args = _build_copilot_args(argv_prompt, interactive=interactive, autopilot=autopilot, model=model)

    # When the prompt is too large for safe argv passing, fall back to
    # printing the prompt.  This applies regardless of binary variant.
    if args is None:
        warnings.warn(
            "Prompt too large for copilot argv; printing prompt to stdout as fallback.",
            stacklevel=2,
        )
        _print_fallback_prompt(prompt)
        result = CopilotSessionResult(
            session_id=session_id,
            mode=mode,
            prompt_file=prompt_file,
            start_time=start_time,
            pid=None,
            process=None,
        )
        _persist_session_state(result, model=model)
        return result

    # --- Launch process -------------------------------------------------------
    if interactive:
        # Inherit stdio so the user can interact with the session.
        # This call blocks until the interactive session ends.
        # shell=False is required: gh is a proper .exe (not a .cmd batch script),
        # and the argument list contains a file path derived from user-supplied
        # prompt content; shell=True on Windows would allow cmd.exe to expand
        # %VAR% patterns inside those values.
        # Strip NODE_OPTIONS: on some systems it contains flags such as
        # ``--no-warnings`` that are intended for Node.js but are forwarded as
        # CLI arguments to the copilot binary, causing repeated
        # "error: unknown option '--no-warnings'" messages.
        env = {k: v for k, v in os.environ.items() if k != "NODE_OPTIONS"}
        process = subprocess.Popen(
            args,
            cwd=working_directory,
            shell=False,
            env=env,
        )
        process.wait()
        result = CopilotSessionResult(
            session_id=session_id,
            mode=mode,
            prompt_file=prompt_file,
            start_time=start_time,
            pid=None,  # process has already exited; PID not meaningful
            process=None,
        )
    else:
        # Non-interactive: run as background process, tee output to both the
        # log file AND the current process's stdout so that CI/pipeline systems
        # (e.g., Azure DevOps) capture the detailed Copilot output in their
        # build logs in addition to the persistent log file.
        log_file_path = _get_log_file_path(session_id, start_time)
        log_file_path.parent.mkdir(parents=True, exist_ok=True)

        # Strip NODE_OPTIONS from the subprocess environment: on some systems
        # NODE_OPTIONS contains flags such as ``--no-warnings`` that are
        # intended for the Node.js runtime but are forwarded as CLI arguments
        # to the copilot binary, producing repeated
        # "error: unknown option '--no-warnings'" entries in the session log.
        env = {k: v for k, v in os.environ.items() if k != "NODE_OPTIONS"}
        process = subprocess.Popen(
            args,
            cwd=working_directory,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            stdin=subprocess.DEVNULL,
            start_new_session=True if sys.platform != "win32" else False,
            shell=False,
            env=env,
        )

        # Open log file without a context manager; the tee thread closes it
        # after the subprocess pipe reaches EOF.
        # shell=False: same reasoning as the interactive case above.
        log_fh = open(log_file_path, "w", encoding="utf-8", errors="replace")  # noqa: WPS515
        stdout_ref = sys.stdout

        def _tee(pipe: IO[bytes] | None, log_file: IO[str], stdout: IO[str] | None) -> None:
            """Read from *pipe* and mirror every line to *log_file* and *stdout*.

            Handles *pipe* or *stdout* being ``None`` gracefully, and
            continues draining to *log_file* if stdout writes fail.
            """
            try:
                if pipe is None:
                    return

                stdout_ok = stdout is not None

                for raw_line in pipe:
                    line = raw_line.decode("utf-8", errors="replace")
                    log_file.write(line)
                    log_file.flush()

                    if stdout_ok:
                        try:
                            stdout.write(line)  # type: ignore[union-attr]
                            stdout.flush()  # type: ignore[union-attr]
                        except (OSError, ValueError):
                            # stdout closed/not writable (common in some CI runners).
                            # Stop mirroring but keep draining the pipe to the log.
                            stdout_ok = False
            finally:
                log_file.close()

        # daemon=False: the non-daemon thread keeps the parent process alive
        # until the subprocess finishes and its stdout pipe reaches EOF.  This
        # prevents SIGPIPE / broken-pipe in the child and ensures CI/pipeline
        # steps wait for the full Copilot session output.
        tee_thread = threading.Thread(target=_tee, args=(process.stdout, log_fh, stdout_ref), daemon=False)
        tee_thread.start()

        result = CopilotSessionResult(
            session_id=session_id,
            mode=mode,
            prompt_file=prompt_file,
            start_time=start_time,
            pid=process.pid,
            process=process,
        )

    _persist_session_state(result, model=model)
    return result
