"""Top-down orchestration for the bootstrap flow.

Implementation is intentionally skeletal; individual steps will be filled in once
design details are finalized.
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any
import os
import sys
import threading
import json
import re
import shutil
from typing import Literal

from bootstrap.constants import (
    CONTROL_POLL_INTERVAL_SECONDS,
    DEFAULT_ARTIFACT_BLOB_THRESHOLD_BYTES,
    DEFAULT_ARTIFACT_MANIFEST,
    DEFAULT_RUN_ROOT,
    DEFAULT_SERVER_URL,
    ENV_ARTIFACT_BLOB_THRESHOLD_BYTES,
    ENV_RUN_ID,
    ENV_RUN_TOKEN,
    ENV_SERVER_URL,
    HEARTBEAT_INTERVAL_SECONDS,
    MAX_ARTIFACT_RETRIES,
    MAX_TERMINAL_OUTPUT_CHARS,
)
from bootstrap.config_loader import UserConfig, load_codex_config
from bootstrap.git_ops import (
    GitConfig,
    commit_changes,
    initialize_repo,
    push_changes,
    update_git_auth,
)
from bootstrap.install import codex_login_status_ok, codex_on_path, ensure_codex
from bootstrap.payload import (
    BootstrapAlreadyClaimed,
    BootstrapPayload,
    fetch_bootstrap_payload,
    fetch_github_token,
)
from bootstrap.prompts import build_prompt_text
from bootstrap.runner import (
    CodexEvent,
    CodexInvocation,
    build_invocation,
    build_resume_invocation,
    run_and_stream,
)
from bootstrap.telemetry import (
    ack_run_control,
    poll_run_control,
    post_artifacts,
    post_completion,
    post_error,
    post_heartbeat,
    post_log,
)
from bootstrap.artifacts import ManifestResult, ManifestStatus, read_manifest

_ANSI_ESCAPE_PATTERN = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
_AUTONOMOUS_RESUME_PROMPT = (
    "Continue autonomously on the active Flywheel task. "
    "If all requested work is complete, finalize artifacts and exit."
)


def _resolve_artifact_blob_threshold_bytes() -> int:
    """Resolve artifact-to-blob transfer threshold from environment."""
    raw = os.environ.get(ENV_ARTIFACT_BLOB_THRESHOLD_BYTES)
    if raw is None:
        return DEFAULT_ARTIFACT_BLOB_THRESHOLD_BYTES
    try:
        value = int(raw)
    except ValueError:
        return DEFAULT_ARTIFACT_BLOB_THRESHOLD_BYTES
    if value <= 0:
        return DEFAULT_ARTIFACT_BLOB_THRESHOLD_BYTES
    return value


@dataclass
class BootstrapConfig:
    """User-supplied CLI arguments plus derived defaults."""

    run_id: str
    capability_token: str
    config_path: Path
    server_url: str = DEFAULT_SERVER_URL
    run_root: Path = DEFAULT_RUN_ROOT
    artifact_manifest: str = DEFAULT_ARTIFACT_MANIFEST


class BootstrapOrchestrator:
    """Coordinates install, payload fetch, Codex launch, telemetry, and artifacts."""

    def __init__(self, config: BootstrapConfig) -> None:
        self.config = config
        self._mock_codex = bool(os.environ.get("BOOTSTRAP_MOCK_CODEX"))
        self.user_config: UserConfig | None = None
        self.bootstrap_payload: BootstrapPayload | None = None
        self.workspace: Path | None = None
        self.codex_executable: Path | None = None
        self.codex_run_id: str | None = None
        self.codex_thread_id: str | None = None
        self.heartbeat_thread: threading.Thread | None = None
        self.control_thread: threading.Thread | None = None
        self._stop_heartbeats = threading.Event()
        self._stop_control = threading.Event()
        self._active_invocation_lock = threading.Lock()
        self._active_invocation: CodexInvocation | None = None
        self._interactive_input_lock = threading.Lock()
        self._interactive_input_thread: threading.Thread | None = None
        self._interactive_input_command_id: str | None = None
        self._interaction_mode_lock = threading.Lock()
        self._interaction_mode: Literal["autonomous", "interactive"] = "autonomous"
        self._codex_env: dict[str, str] | None = None
        self._codex_extra_flags: tuple[str, ...] = ()
        self._codex_command: Path | None = None
        self.last_stderr: str = ""  # Captured stderr for error reporting
        self.git_config: GitConfig | None = None  # Git config for code persistence
        self._artifact_blob_threshold_bytes = _resolve_artifact_blob_threshold_bytes()

    def _ensure_workspace_gitignore_entry(self, entry: str) -> None:
        """Ensure a workspace-level .gitignore contains the provided entry."""
        assert self.workspace is not None

        gitignore_path = self.workspace / ".gitignore"
        existing = ""
        if gitignore_path.exists():
            existing = gitignore_path.read_text(encoding="utf-8")
            if any(line.strip() == entry for line in existing.splitlines()):
                return

        separator = ""
        if existing:
            separator = "" if existing.endswith("\n") else "\n"
        gitignore_path.write_text(
            f"{existing}{separator}{entry}\n",
            encoding="utf-8",
        )

    def run(self) -> int:
        """Execute the bootstrap flow.

        Returns:
            Process exit code (0 for success, non-zero for failure).
        """

        try:
            self._ensure_prerequisites()
            self._load_user_config()
            self._resolve_workspace()
            self._ensure_codex_available()
            try:
                self._fetch_bootstrap_payload()
            except BootstrapAlreadyClaimed as exc:
                self._log(str(exc), level="warning")
                return 0
            self._initialize_git_repo()  # Clone repo if code persistence enabled
            exit_code = self._launch_codex_and_stream()
            self._finalize_git_repo(
                exit_code
            )  # Commit and push if code persistence enabled
            self._collect_and_post_artifacts(exit_code)
            return 0
        except SystemExit:
            raise
        except Exception as exc:  # pragma: no cover - defensive
            post_error(
                self.config.server_url,
                self.config.run_id,
                self.config.capability_token,
                reason=repr(exc),
                summary="bootstrap failure",
            )
            print(f"bootstrap failed: {exc}", file=sys.stderr)
            return 1
        finally:
            self._terminate_active_invocation()
            self._stop_heartbeats.set()
            if self.heartbeat_thread and self.heartbeat_thread.is_alive():
                self.heartbeat_thread.join(timeout=2)
            self._stop_control.set()
            if self.control_thread and self.control_thread.is_alive():
                self.control_thread.join(timeout=2)
            with self._interactive_input_lock:
                thread = self._interactive_input_thread
            if thread and thread.is_alive():
                thread.join(timeout=2)

    # --- individual steps (to be implemented) ---

    def _ensure_prerequisites(self) -> None:
        """Validate required binaries/env vars and fail fast if missing."""
        if not self.config.run_id:
            raise SystemExit("missing run id")
        if not self.config.capability_token:
            raise SystemExit("missing capability token")
        if not self.config.config_path.exists():
            raise SystemExit(f"config file not found: {self.config.config_path}")

    def _load_user_config(self) -> None:
        """Read the user's Codex config.toml for sandbox/workspace settings."""
        self.user_config = load_codex_config(self.config.config_path)
        for warning in self.user_config.warnings:
            print(f"bootstrap warning: {warning}", file=sys.stderr)

    def _resolve_workspace(self) -> None:
        """Decide which working directory to hand to Codex (respect user config if set)."""
        assert self.user_config is not None
        if self.user_config.working_dir:
            # Support the documented "<run_id>" placeholder in config paths.
            raw = str(self.user_config.working_dir)
            if "<run_id>" in raw:
                raw = raw.replace("<run_id>", self.config.run_id)
            workdir = Path(raw).expanduser().resolve()
        else:
            workdir = self.config.run_root / self.config.run_id
        workdir.mkdir(parents=True, exist_ok=True)

        # Auto-create writable_roots directories if they don't exist
        for root in self.user_config.writable_roots:
            root.mkdir(parents=True, exist_ok=True)

        manifest_path = workdir / self.config.artifact_manifest
        if self.user_config.writable_roots:
            ok = False
            for root in self.user_config.writable_roots:
                try:
                    manifest_path.relative_to(root)
                    ok = True
                    break
                except ValueError:
                    continue
            if not ok:
                roots = ", ".join(str(r) for r in self.user_config.writable_roots)
                raise SystemExit(
                    f"manifest path {manifest_path} not in sandbox writable_roots ({roots}); "
                    "please add a writable root or adjust config"
                )
        self.workspace = workdir

    def _ensure_codex_available(self) -> None:
        """Skip install if present; otherwise download tarball and prepend to PATH."""
        if self._mock_codex:
            return
        codex_path: Path | None = None
        if codex_on_path():
            # On Windows, explicitly look for codex.exe to avoid conflicts
            codex_name = "codex.exe" if sys.platform == "win32" else "codex"
            found = shutil.which(codex_name)
            print(
                f"[bootstrap] shutil.which({codex_name!r}) = {found}", file=sys.stderr
            )
            if found:
                codex_path = Path(found)
                self.codex_executable = codex_path
        else:
            download_dir = self.workspace or self.config.run_root
            self.codex_executable = ensure_codex(download_dir=download_dir)
            codex_path = self.codex_executable

        if codex_path is None:
            codex_path = Path("codex")
        print(f"[bootstrap] using codex at: {codex_path}", file=sys.stderr)
        self._ensure_codex_authenticated(codex_path)

    def _fetch_bootstrap_payload(self) -> None:
        """Call backend /runs/{id}/bootstrap to get the task prompt."""
        self.bootstrap_payload = fetch_bootstrap_payload(
            self.config.server_url, self.config.run_id, self.config.capability_token
        )

    def _initialize_git_repo(self) -> None:
        """Initialize git repository if code persistence is enabled.

        If the bootstrap payload contains a repo_context and github_token,
        we clone the repository and set up the experiment branch.
        """
        assert self.bootstrap_payload is not None
        assert self.workspace is not None

        repo_context = self.bootstrap_payload.repo_context
        github_token = self.bootstrap_payload.github_token

        if repo_context is None or github_token is None:
            self._log("Git: Code persistence not configured, skipping repo setup")
            return

        self._log(
            f"Git: Initializing code persistence for {repo_context.repo_owner}/{repo_context.repo_name}"
        )

        # Create git config with telemetry logging
        def log_fn(level: str, message: str) -> None:
            self._log(f"Git: {message}", level=level)

        self.git_config = GitConfig(
            workspace=self.workspace,
            repo_context=repo_context,
            github_token=github_token,
            log_fn=log_fn,
        )

        # Initialize the repository (clone, credentials, branch)
        if not initialize_repo(self.git_config):
            self._log(
                "Git: Failed to initialize repository, continuing without code persistence",
                level="warning",
            )
            self.git_config = None
            return

        self._log(
            f"Git: Repository initialized, working on branch {repo_context.branch_name}"
        )

    def _finalize_git_repo(self, exit_code: int) -> None:
        """Finalize git repository after codex completes.

        Commits any changes and pushes them to the remote.
        Only runs if code persistence was successfully initialized.
        """
        if self.git_config is None:
            return

        commit_message = (
            f"Flywheel experiment run: {self.config.run_id}"
            if exit_code == 0
            else f"[WIP] Flywheel experiment run (failed): {self.config.run_id}"
        )

        if exit_code != 0:
            self._log(
                f"Git: Codex exited with code {exit_code}, committing WIP",
                level="warning",
            )
        else:
            self._log("Git: Finalizing repository, committing and pushing changes")

        artifact_exclusions = self._artifact_paths_for_git_exclusion()
        if artifact_exclusions:
            self._log(
                f"Git: Excluding {len(artifact_exclusions)} artifact path(s) from commit"
            )

        if not commit_changes(
            self.git_config,
            commit_message,
            exclude_paths=artifact_exclusions,
        ):
            self._log("Git: Failed to commit changes", level="error")
            return

        self._refresh_github_token()

        if push_changes(self.git_config):
            self._log("Git: Changes pushed successfully")
        else:
            self._log("Git: Failed to push changes", level="error")

    def _artifact_paths_for_git_exclusion(self) -> tuple[str, ...]:
        """Return workspace-relative artifact paths that should not be committed."""
        assert self.workspace is not None

        paths: set[str] = set()
        manifest_rel = self.config.artifact_manifest
        manifest_path = self.workspace / manifest_rel
        if manifest_path.exists():
            paths.add(manifest_rel)

        manifest_result = read_manifest(manifest_path)
        for artifact in manifest_result.artifacts:
            if not isinstance(artifact, dict):
                continue
            payload = artifact.get("payload", {})
            if not isinstance(payload, dict):
                continue
            raw_path = payload.get("path") or payload.get("file")
            if not isinstance(raw_path, str) or not raw_path.strip():
                continue
            resolved = self._resolve_artifact_path(raw_path)
            if resolved is None or not resolved.exists():
                continue
            try:
                relative = resolved.relative_to(self.workspace).as_posix()
            except ValueError:
                continue
            paths.add(relative)

        return tuple(sorted(paths))

    def _refresh_github_token(self) -> None:
        """Refresh the GitHub token before pushing."""
        if self.git_config is None:
            return

        try:
            token = fetch_github_token(
                self.config.server_url,
                self.config.run_id,
                self.config.capability_token,
            )
        except Exception as exc:
            self._log(
                f"Git: Failed to refresh GitHub token ({exc}); using existing token",
                level="warning",
            )
            return

        if not token:
            self._log(
                "Git: Empty GitHub token response; using existing token",
                level="warning",
            )
            return

        if update_git_auth(self.git_config, token):
            self._log("Git: Refreshed GitHub token")
        else:
            self._log(
                "Git: Failed to update git credentials after token refresh",
                level="warning",
            )

    def _ensure_codex_authenticated(self, codex_path: Path) -> None:
        """Fail fast if codex is present but not logged in."""
        if codex_login_status_ok(codex_path):
            return
        raise SystemExit(
            "Codex isn't authenticated. Run `codex login` (browser/device flow) or "
            "`printenv OPENAI_API_KEY | codex login --with-api-key` then rerun the bootstrap."
        )

    def _launch_codex_and_stream(self) -> int:
        """Run Codex, switching between autonomous and interactive-resume modes."""
        assert self.workspace is not None
        assert self.bootstrap_payload is not None
        assert self.user_config is not None

        prompt_text = build_prompt_text(
            server_prompt=self.bootstrap_payload.prompt,
            workspace_instructions=self.user_config.workspace_instructions,
            artifact_manifest=self.config.artifact_manifest,
        )

        if self._mock_codex:
            # Fast-path: emit one heartbeat, a couple logs, a run id, and exit 0.
            post_heartbeat(
                self.config.server_url,
                self.config.run_id,
                self.config.capability_token,
                summary="alive (mock)",
            )
            for event in self._mock_codex_events():
                self._handle_event(event)
            self._write_mock_manifest()
            return 0

        codex_path = self.codex_executable or Path("codex")
        env = os.environ.copy()
        env.update(
            {
                "FLYWHEEL_RUN_ID": self.config.run_id,
                "FLYWHEEL_RUN_TOKEN": self.config.capability_token,
                "FLYWHEEL_SERVER": self.config.server_url,
                "FLYWHEEL_WORKSPACE": str(self.workspace.resolve()),
            }
        )

        # Debug: show API key status
        api_key = env.get("OPENAI_API_KEY", "")
        if api_key:
            print(
                f"[bootstrap] OPENAI_API_KEY is set (starts with: {api_key[:10]}...)",
                file=sys.stderr,
            )
        else:
            print(
                "[bootstrap] WARNING: OPENAI_API_KEY is NOT set in environment",
                file=sys.stderr,
            )

        # If using LM Studio, set the API base URL for Codex
        if self.user_config.oss_provider == "lmstudio":
            env["OPENAI_API_BASE"] = "http://localhost:1234/v1"
            env["OPENAI_BASE_URL"] = "http://localhost:1234/v1"
            # LM Studio doesn't validate API keys, but some tools require one to be set
            if "OPENAI_API_KEY" not in env:
                env["OPENAI_API_KEY"] = "lm-studio"
            print(
                "[bootstrap] Using LM Studio at http://localhost:1234/v1",
                file=sys.stderr,
            )

        # Ensure Codex actually sees the same config file bootstrap parsed.
        # Codex reads config from CODEX_HOME/config.toml; point it at a per-run copy.
        try:
            codex_home = self.workspace / ".codex_home"
            codex_home.mkdir(parents=True, exist_ok=True)

            # Keep copied codex auth out of git commits in persisted repos.
            self._ensure_workspace_gitignore_entry(".codex_home/auth.json")

            # Copy config but expand ~ paths so codex sees absolute paths
            config_text = self.config.config_path.read_text(encoding="utf-8")
            # Expand common tilde patterns in the config
            # Use forward slashes for cross-platform compatibility (works on Windows too)
            # and avoids TOML interpreting backslashes as escape sequences
            home_dir = str(Path.home()).replace("\\", "/")
            config_text = config_text.replace('"~/', f'"{home_dir}/')
            config_text = config_text.replace("'~/", f"'{home_dir}/")
            (codex_home / "config.toml").write_text(config_text, encoding="utf-8")

            # Also copy auth credentials from user's default codex home so the
            # spawned codex process stays authenticated.
            # TODO: more sophisticated auth
            user_codex_home = Path.home() / ".codex"
            user_auth = user_codex_home / "auth.json"
            if user_auth.exists():
                shutil.copyfile(user_auth, codex_home / "auth.json")
                print(f"[bootstrap] Copied auth from {user_auth}", file=sys.stderr)
            else:
                print(
                    f"[bootstrap] WARNING: No auth.json found at {user_auth}",
                    file=sys.stderr,
                )

            env["CODEX_HOME"] = str(codex_home)
            print(f"[bootstrap] CODEX_HOME set to: {codex_home}", file=sys.stderr)
        except Exception as exc:
            post_log(
                self.config.server_url,
                self.config.run_id,
                self.config.capability_token,
                level="warning",
                message="failed to prepare CODEX_HOME config override; continuing",
                extra={"error": repr(exc)},
            )

        # Pass sandbox settings as proper CLI flags
        # For provisioned instances, we use --yolo to completely bypass sandbox and approvals
        extra_flags: list[str] = []
        if self.user_config.sandbox_mode:
            if self.user_config.sandbox_mode == "danger-full-access":
                # Use --yolo (--dangerously-bypass-approvals-and-sandbox) for unrestricted access
                # This is safe on provisioned instances since they're isolated VMs
                extra_flags.append("--yolo")
            else:
                extra_flags.extend(["--sandbox", self.user_config.sandbox_mode])

        self._codex_command = Path(codex_path)
        self._codex_env = dict(env)
        self._codex_extra_flags = tuple(extra_flags)
        codex_command = self._codex_command
        codex_env = self._codex_env
        assert codex_command is not None
        assert codex_env is not None
        with self._interaction_mode_lock:
            self._interaction_mode = "autonomous"

        # Start heartbeat thread
        self._stop_heartbeats.clear()
        self.heartbeat_thread = threading.Thread(
            target=self._heartbeat_loop, daemon=True
        )
        self.heartbeat_thread.start()
        self._stop_control.clear()
        self.control_thread = threading.Thread(target=self._control_loop, daemon=True)
        self.control_thread.start()

        first_autonomous_run = True

        while True:
            if self._is_interactive_input_running():
                if self._stop_control.wait(CONTROL_POLL_INTERVAL_SECONDS):
                    return 1
                continue

            mode = self._get_interaction_mode()
            if mode == "interactive":
                # In interactive mode, control commands drive resume steps.
                if self._stop_control.wait(CONTROL_POLL_INTERVAL_SECONDS):
                    return 1
                continue

            if first_autonomous_run or self.codex_thread_id is None:
                invocation = build_invocation(
                    codex_executable=codex_command,
                    prompt=prompt_text,
                    workdir=self.workspace,
                    env=codex_env,
                    extra_flags=self._codex_extra_flags,
                )
                first_autonomous_run = False
            else:
                invocation = build_resume_invocation(
                    codex_executable=codex_command,
                    session_id=self.codex_thread_id,
                    prompt=_AUTONOMOUS_RESUME_PROMPT,
                    workdir=self.workspace,
                    env=codex_env,
                    extra_flags=self._codex_extra_flags,
                )

            with self._active_invocation_lock:
                self._active_invocation = invocation
            try:
                for event in run_and_stream(invocation):
                    self._handle_event(event)
            finally:
                with self._active_invocation_lock:
                    self._active_invocation = None

            exit_code = invocation.exit_code if invocation.exit_code is not None else 1
            if invocation.stderr_output:
                self.last_stderr = invocation.stderr_output

            # If we switched to interactive mode, we intentionally interrupted
            # autonomous execution and should not terminalize the run.
            if self._get_interaction_mode() == "interactive":
                continue
            return exit_code

    def _collect_and_post_artifacts(self, exit_code: int) -> None:
        """Read manifest (and optional resume attempts) then POST /artifacts/complete/error."""
        assert self.workspace is not None
        manifest_path = self.workspace / self.config.artifact_manifest
        manifest_result, artifacts = self._load_artifacts_with_content(manifest_path)

        # Auto-resume up to MAX_ARTIFACT_RETRIES times if artifacts are
        # missing or the manifest was malformed.
        retries = 0
        while not artifacts and self.codex_run_id and retries < MAX_ARTIFACT_RETRIES:
            retries += 1
            self._attempt_artifact_retry(manifest_path, manifest_result)
            manifest_result, artifacts = self._load_artifacts_with_content(
                manifest_path
            )

        if artifacts:
            post_artifacts(
                self.config.server_url,
                self.config.run_id,
                self.config.capability_token,
                artifacts,
            )

        if exit_code == 0:
            summary = "codex run completed"
            post_completion(
                self.config.server_url,
                self.config.run_id,
                self.config.capability_token,
                summary,
            )
        else:
            # Include stderr in error reason for debugging
            reason = f"codex exit code {exit_code}"
            if self.last_stderr:
                # Truncate stderr to avoid overly long error messages
                stderr_preview = self.last_stderr[:2000]
                if len(self.last_stderr) > 2000:
                    stderr_preview += "... (truncated)"
                reason = f"{reason}\nstderr: {stderr_preview}"
            post_error(
                self.config.server_url,
                self.config.run_id,
                self.config.capability_token,
                reason=reason,
                summary="codex failed",
            )

    def _load_artifacts_with_content(
        self, manifest_path: Path
    ) -> tuple[ManifestResult, list[dict[str, object]]]:
        """Load artifacts and inline file-backed payloads under threshold.

        Any artifact payload that references a local file via ``path``/``file``
        is enriched when file size is <= ``self._artifact_blob_threshold_bytes``.
        The enriched payload is later uploaded to blob storage via the existing
        ``POST /runs/{id}/artifacts`` path.
        """
        import base64
        import mimetypes

        assert self.workspace is not None
        manifest_result = read_manifest(manifest_path)
        artifacts = manifest_result.artifacts
        enriched: list[dict[str, object]] = []
        max_blob_size = self._artifact_blob_threshold_bytes

        # Checkpoint file extensions (model weights, etc.)
        checkpoint_extensions = {
            ".pt",
            ".pth",
            ".ckpt",
            ".safetensors",
            ".bin",
            ".h5",
            ".hdf5",
            ".pkl",
            ".pickle",
            ".joblib",
            ".npy",
            ".npz",
            ".onnx",
            ".pb",
        }

        for artifact in artifacts:
            try:
                if not isinstance(artifact, dict):
                    continue
                artifact = dict(artifact)
                artifact_type = str(artifact.get("artifact_type", "")).lower()
                payload_raw = artifact.get("payload", {})
                if not isinstance(payload_raw, dict):
                    enriched.append(artifact)
                    continue
                payload = dict(payload_raw)
                artifact["payload"] = payload

                path_str = payload.get("path") or payload.get("file")
                resolved: Path | None = None
                file_size: int | None = None
                if isinstance(path_str, str) and path_str:
                    path_lower = path_str.lower()
                    if any(path_lower.endswith(ext) for ext in checkpoint_extensions):
                        artifact["artifact_type"] = "checkpoint"
                        artifact_type = "checkpoint"
                    resolved = self._resolve_artifact_path(path_str)
                    if resolved and resolved.is_file():
                        file_size = resolved.stat().st_size
                        payload["size_bytes"] = file_size

                if resolved and file_size is not None:
                    if file_size > max_blob_size:
                        payload["rendering_error"] = (
                            f"File too large ({file_size} bytes, max {max_blob_size})"
                        )
                    elif artifact_type in ("text", "html", "table"):
                        try:
                            payload["content"] = resolved.read_text(encoding="utf-8")
                        except UnicodeDecodeError:
                            self._log(
                                f"failed to read {artifact_type} artifact at {resolved} (encoding)",
                                level="warning",
                            )
                    elif artifact_type == "json":
                        try:
                            text_content = resolved.read_text(encoding="utf-8")
                        except UnicodeDecodeError:
                            self._log(
                                f"failed to read json artifact at {resolved} (encoding)",
                                level="warning",
                            )
                        else:
                            try:
                                payload["content"] = json.loads(text_content)
                            except json.JSONDecodeError:
                                payload["content"] = text_content
                                self._log(
                                    f"json artifact at {resolved} is not valid JSON; sending raw text",
                                    level="warning",
                                )
                    else:
                        # For image/checkpoint/unknown file artifacts, inline as data URL.
                        try:
                            file_data = resolved.read_bytes()
                            mime_type, _ = mimetypes.guess_type(str(resolved))
                            if not mime_type:
                                mime_type = (
                                    "image/png"
                                    if artifact_type == "image"
                                    else "application/octet-stream"
                                )
                            b64 = base64.b64encode(file_data).decode("ascii")
                            payload["data_url"] = f"data:{mime_type};base64,{b64}"
                        except Exception as exc:
                            self._log(
                                f"failed to read {artifact_type} artifact at {resolved}: {exc}",
                                level="warning",
                            )

            except Exception as exc:  # pragma: no cover - defensive
                self._log(f"artifact enrichment error: {exc}", level="warning")
            enriched.append(dict(artifact))
        return manifest_result, enriched

    def _resolve_artifact_path(self, path_str: str) -> Path | None:
        """Resolve artifact path within workspace, returning None if invalid."""
        assert self.workspace is not None
        path = Path(path_str)
        resolved = (self.workspace / path).resolve()
        workspace_root = self.workspace.resolve()
        if resolved == workspace_root or workspace_root in resolved.parents:
            return resolved
        self._log(f"skipping artifact outside workspace: {resolved}", level="warning")
        return None

    def _heartbeat_loop(self) -> None:
        while not self._stop_heartbeats.is_set():
            try:
                post_heartbeat(
                    self.config.server_url,
                    self.config.run_id,
                    self.config.capability_token,
                    summary="alive",
                )
            except Exception as exc:  # pragma: no cover - best effort
                print(f"heartbeat failed: {exc}", file=sys.stderr)
            self._stop_heartbeats.wait(HEARTBEAT_INTERVAL_SECONDS)

    def _get_interaction_mode(self) -> Literal["autonomous", "interactive"]:
        with self._interaction_mode_lock:
            return self._interaction_mode

    def _set_interaction_mode(self, mode: Literal["autonomous", "interactive"]) -> None:
        with self._interaction_mode_lock:
            self._interaction_mode = mode

    def _is_interactive_input_running(self) -> bool:
        with self._interactive_input_lock:
            thread = self._interactive_input_thread
        return bool(thread and thread.is_alive())

    def _terminate_active_invocation(self) -> bool:
        with self._active_invocation_lock:
            invocation = self._active_invocation
        if invocation is None or invocation.terminate is None:
            return False
        try:
            invocation.terminate()
            return True
        except Exception as exc:  # pragma: no cover - defensive
            self._log(
                "failed to terminate active codex invocation",
                level="warning",
                extra={"error": repr(exc)},
            )
            return False

    def _run_resume_prompt(self, prompt: str) -> tuple[bool, str]:
        if self.codex_thread_id is None:
            return False, "thread id unavailable; cannot resume session"
        codex_command = self._codex_command
        codex_env = self._codex_env
        if codex_command is None or codex_env is None or self.workspace is None:
            return False, "codex runtime not initialized"

        invocation = build_resume_invocation(
            codex_executable=codex_command,
            session_id=self.codex_thread_id,
            prompt=prompt,
            workdir=self.workspace,
            env=codex_env,
            extra_flags=self._codex_extra_flags,
        )
        with self._active_invocation_lock:
            self._active_invocation = invocation
        try:
            for event in run_and_stream(invocation):
                self._handle_event(event)
        finally:
            with self._active_invocation_lock:
                self._active_invocation = None

        if invocation.stderr_output:
            self.last_stderr = invocation.stderr_output
        exit_code = invocation.exit_code if invocation.exit_code is not None else 1
        if exit_code == 0:
            return True, "resume prompt applied"
        return False, f"resume prompt failed with exit code {exit_code}"

    def _start_send_input_resume(
        self, command_id: str, text: str
    ) -> tuple[Literal["started", "duplicate", "busy"], str]:
        def _worker() -> None:
            ack_status = "failed"
            detail = "send_input resume failed"
            try:
                success, resume_detail = self._run_resume_prompt(text)
                ack_status = "applied" if success else "failed"
                detail = f"send_input resume: {resume_detail}"
                level = "info" if success else "warning"
                self._log(
                    "processed send_input via codex exec resume",
                    level=level,
                    extra={"command_id": command_id, "command": "send_input"},
                )
            except Exception as exc:  # pragma: no cover - defensive
                detail = f"send_input resume failed unexpectedly: {repr(exc)}"
                self._log(
                    "send_input worker failed unexpectedly",
                    level="error",
                    extra={
                        "command_id": command_id,
                        "command": "send_input",
                        "error": repr(exc),
                    },
                )
            finally:
                try:
                    ack_run_control(
                        self.config.server_url,
                        self.config.run_id,
                        self.config.capability_token,
                        command_id=command_id,
                        status=ack_status,
                        detail=detail,
                    )
                except Exception as exc:  # pragma: no cover - best effort
                    self._log(
                        "failed to acknowledge send_input command",
                        level="warning",
                        extra={
                            "command_id": command_id,
                            "command": "send_input",
                            "error": repr(exc),
                        },
                    )
                finally:
                    with self._interactive_input_lock:
                        self._interactive_input_thread = None
                        self._interactive_input_command_id = None

        with self._interactive_input_lock:
            existing = self._interactive_input_thread
            if existing and existing.is_alive():
                if self._interactive_input_command_id == command_id:
                    return "duplicate", "send_input already in progress"
                return "busy", "send_input ignored: previous input is still running"
            worker = threading.Thread(target=_worker, daemon=True)
            self._interactive_input_thread = worker
            self._interactive_input_command_id = command_id
            worker.start()
        return "started", "send_input accepted"

    def _control_loop(self) -> None:
        while not self._stop_control.is_set():
            try:
                command_payload = poll_run_control(
                    self.config.server_url,
                    self.config.run_id,
                    self.config.capability_token,
                )
                self._handle_control_payload(command_payload)
            except Exception as exc:  # pragma: no cover - best effort
                print(f"control poll failed: {exc}", file=sys.stderr)
            self._stop_control.wait(CONTROL_POLL_INTERVAL_SECONDS)

    def _handle_control_payload(self, payload: dict[str, object]) -> None:
        command_id = payload.get("command_id")
        command = payload.get("command")
        if not isinstance(command_id, str) or not isinstance(command, str):
            return

        ack_status = "applied"
        detail = "acknowledged by bootstrap"
        if command == "take_over":
            already_interactive = self._get_interaction_mode() == "interactive"
            if already_interactive:
                detail = "take_over acknowledged; already interactive"
            else:
                self._set_interaction_mode("interactive")
                terminated = self._terminate_active_invocation()
                if terminated:
                    detail = "take_over acknowledged; autonomous execution interrupted, interactive resume mode active"
                else:
                    detail = "take_over acknowledged; interactive resume mode active"
            self._log(
                "received take_over command",
                extra={"command_id": command_id, "command": command},
            )
        elif command == "resume_autonomous":
            self._set_interaction_mode("autonomous")
            terminated = self._terminate_active_invocation()
            detail = "resume_autonomous acknowledged; autonomous execution will resume"
            if terminated:
                detail += " after interrupting active interactive command"
            self._log(
                "received resume_autonomous command",
                extra={"command_id": command_id, "command": command},
            )
        elif command == "send_input":
            if self._get_interaction_mode() != "interactive":
                ack_status = "ignored"
                detail = "send_input ignored: not in interactive mode"
                self._log(
                    "received send_input command while not interactive",
                    level="warning",
                    extra={"command_id": command_id, "command": command},
                )
            else:
                command_payload = payload.get("payload")
                text: str | None = None
                if isinstance(command_payload, dict):
                    raw_text = command_payload.get("text")
                    if isinstance(raw_text, str):
                        stripped = raw_text.strip()
                        if stripped:
                            text = stripped

                if text is None:
                    ack_status = "ignored"
                    detail = "send_input ignored: missing text payload"
                    self._log(
                        "received send_input command without text payload",
                        level="warning",
                        extra={"command_id": command_id, "command": command},
                    )
                else:
                    start_status, start_detail = self._start_send_input_resume(
                        command_id, text
                    )
                    if start_status == "started":
                        self._log(
                            "accepted send_input command for async resume execution",
                            extra={"command_id": command_id, "command": command},
                        )
                        return
                    if start_status == "duplicate":
                        return
                    ack_status = "failed"
                    detail = start_detail
                    self._log(
                        "rejected send_input command while another input is running",
                        level="warning",
                        extra={"command_id": command_id, "command": command},
                    )
        else:
            ack_status = "ignored"
            detail = f"unsupported command '{command}'"
            self._log(
                "received unsupported control command",
                level="warning",
                extra={"command_id": command_id, "command": command},
            )

        ack_run_control(
            self.config.server_url,
            self.config.run_id,
            self.config.capability_token,
            command_id=command_id,
            status=ack_status,
            detail=detail,
        )

    def _extract_command_output_message(self, raw: dict[str, object]) -> str | None:
        raw_type = raw.get("type")
        if raw_type in {"item.started", "item.completed"}:
            item = raw.get("item")
            if isinstance(item, dict):
                item_type = item.get("type")
                if isinstance(item_type, str) and item_type == "command_execution":
                    command = item.get("command")
                    if raw_type == "item.started" and isinstance(command, str):
                        command_line = self._sanitize_terminal_text(f"$ {command}")
                        if command_line:
                            return command_line
                    aggregated_output = item.get("aggregated_output")
                    if isinstance(aggregated_output, str) and aggregated_output.strip():
                        sanitized = self._sanitize_terminal_text(
                            aggregated_output.rstrip("\n")
                        )
                        if sanitized:
                            return sanitized
                    if (
                        raw_type == "item.completed"
                        and isinstance(command, str)
                        and isinstance(item.get("exit_code"), int)
                    ):
                        exit_code = item.get("exit_code")
                        return self._sanitize_terminal_text(
                            f"$ {command} (exit {exit_code})"
                        )
                text = item.get("text")
                if isinstance(item_type, str) and item_type == "reasoning":
                    return None
                if isinstance(text, str):
                    sanitized = self._sanitize_terminal_text(text)
                    if sanitized:
                        return sanitized

        for key in ("terminal_output", "message", "text", "delta", "content", "output"):
            value = raw.get(key)
            if isinstance(value, str):
                sanitized = self._sanitize_terminal_text(value)
                if sanitized:
                    return sanitized
        return None

    def _describe_codex_event(self, raw: dict[str, object]) -> str:
        event_type = raw.get("type")
        if isinstance(event_type, str):
            if event_type == "thread.started":
                thread_id = raw.get("thread_id")
                if isinstance(thread_id, str):
                    return f"codex thread started ({thread_id})"
                return "codex thread started"
            if event_type == "turn.started":
                return "codex turn started"
            if event_type == "turn.completed":
                return "codex turn completed"
            if event_type == "item.completed":
                item = raw.get("item")
                if isinstance(item, dict):
                    item_type = item.get("type")
                    if isinstance(item_type, str):
                        return f"codex item completed: {item_type}"
                return "codex item completed"
            return f"codex event: {event_type}"
        return "codex event"

    def _sanitize_terminal_text(self, text: str) -> str:
        sanitized = _ANSI_ESCAPE_PATTERN.sub("", text).replace("\r", "")
        if len(sanitized) <= MAX_TERMINAL_OUTPUT_CHARS:
            return sanitized
        suffix = " [truncated]"
        max_prefix = max(0, MAX_TERMINAL_OUTPUT_CHARS - len(suffix))
        return sanitized[:max_prefix] + suffix

    def _handle_event(self, event: CodexEvent) -> None:
        level = "info"
        message = "codex event"
        extra: dict[str, object] = {}

        raw = event.raw
        if isinstance(raw, dict):
            # Preserve structured event data so the server can parse usage.
            extra = {"event": raw}
            bootstrap_debug = raw.get("bootstrap_debug")
            if bootstrap_debug == "codex_launch":
                message = "codex exec launched"
                extra["run_event_kind"] = "command_started"
                extra["run_event_source"] = "codex"
            elif bootstrap_debug == "codex_exit":
                exit_code = raw.get("exit_code")
                if isinstance(exit_code, int):
                    message = f"codex exec exited with code {exit_code}"
                else:
                    message = "codex exec exited"
                extra["run_event_kind"] = "command_finished"
                extra["run_event_source"] = "codex"
            else:
                bootstrap_warning = raw.get("bootstrap_warning")
                if isinstance(bootstrap_warning, str):
                    level = "warning"
                    message = bootstrap_warning
                    extra["run_event_kind"] = "warning"
                    extra["run_event_source"] = "bootstrap"
                else:
                    bootstrap_error = raw.get("bootstrap_error")
                    if isinstance(bootstrap_error, str):
                        level = "error"
                        message = bootstrap_error
                        extra["run_event_kind"] = "error"
                        extra["run_event_source"] = "bootstrap"
                    else:
                        output_message = self._extract_command_output_message(raw)
                        if output_message is not None:
                            message = output_message
                            extra["run_event_kind"] = "command_output"
                            extra["run_event_source"] = "codex"
                        else:
                            message = self._describe_codex_event(raw)
                            extra["run_event_kind"] = "codex_event"
                            extra["run_event_source"] = "codex"
        else:
            message = self._sanitize_terminal_text(str(raw))
            extra["run_event_kind"] = "command_output"
            extra["run_event_source"] = "codex"
        post_log(
            self.config.server_url,
            self.config.run_id,
            self.config.capability_token,
            level=level,
            message=message,
            extra=extra,
        )
        if isinstance(raw, dict):
            run_id = raw.get("run_id")
            if isinstance(run_id, str):
                self.codex_run_id = run_id
            thread_id = raw.get("thread_id")
            if isinstance(thread_id, str):
                self.codex_thread_id = thread_id
            thread_raw = raw.get("thread")
            if isinstance(thread_raw, dict):
                nested_thread_id = thread_raw.get("id")
                if isinstance(nested_thread_id, str):
                    self.codex_thread_id = nested_thread_id

    def _attempt_artifact_retry(
        self, manifest_path: Path, manifest_result: ManifestResult
    ) -> None:
        """Retry artifact collection via ``codex exec`` with a feedback prompt.

        Both MISSING and MALFORMED manifests are handled by launching a new
        Codex exec with a targeted prompt describing the problem and telling
        Codex exactly what to do.  This is preferable to ``codex resume``
        which cannot accept additional instructions.
        """
        if not self.codex_run_id:
            return

        manifest_name = self.config.artifact_manifest

        if manifest_result.status == ManifestStatus.MALFORMED:
            error_detail = manifest_result.error or "unknown error"
            raw_content = ""
            if manifest_path.exists():
                try:
                    raw_content = manifest_path.read_text(encoding="utf-8")[:2000]
                except Exception:
                    raw_content = "<could not read file>"

            fix_prompt = (
                "The artifact manifest file at "
                f"$FLYWHEEL_WORKSPACE/{manifest_name} is malformed.\n\n"
                f"Error: {error_detail}\n\n"
                f"Current file contents:\n{raw_content}\n\n"
                "Please rewrite this file so it is a valid JSON list of "
                "artifact entries. Each entry must be an object with "
                '"artifact_type" and "payload" keys. The file must be a '
                "top-level JSON array, for example:\n"
                "[\n"
                '  {"artifact_type": "text", "payload": {"content": "..."}},\n'
                '  {"artifact_type": "image", "payload": {"path": "plot.png",'
                ' "format": "png"}}\n'
                "]\n\n"
                "Do NOT wrap the list in an object. The file must start with "
                "[ and end with ].\n"
                "Only fix the manifest format — do not change the actual "
                "artifact content or paths."
            )
            log_msg = "attempting codex exec to fix malformed artifact manifest"
        else:
            # MISSING — the file was never written.
            fix_prompt = (
                "The artifact manifest file was not found at "
                f"$FLYWHEEL_WORKSPACE/{manifest_name}.\n\n"
                "Your task already completed successfully, but the manifest "
                "file is missing. Please write the manifest now.\n\n"
                "The file must be a valid JSON list of artifact entries. "
                'Each entry must be an object with "artifact_type" and '
                '"payload" keys. The file must be a top-level JSON array, '
                "for example:\n"
                "[\n"
                '  {"artifact_type": "text", "payload": {"content": "..."}},\n'
                '  {"artifact_type": "image", "payload": {"path": "plot.png",'
                ' "format": "png"}}\n'
                "]\n\n"
                "Do NOT wrap the list in an object. The file must start with "
                "[ and end with ].\n"
                "Look at the files you produced in the workspace and create "
                "the manifest based on what you find."
            )
            log_msg = "attempting codex exec to write missing artifact manifest"

        self._log(
            log_msg,
            extra={
                "status": manifest_result.status.value,
                "error": manifest_result.error,
            },
        )

        codex_path = self.codex_executable or Path("codex")
        try:
            invocation = build_invocation(
                codex_executable=codex_path,
                prompt=fix_prompt,
                workdir=self.workspace or Path("."),
                env=os.environ.copy(),
            )
            for event in run_and_stream(invocation):
                self._handle_event(event)
        except Exception as exc:  # pragma: no cover
            self._log(
                "codex artifact retry failed",
                level="error",
                extra={"error": repr(exc)},
            )

    def _log(
        self, message: str, level: str = "info", extra: dict[str, object] | None = None
    ) -> None:
        """Lightweight logger that routes to telemetry."""
        post_log(
            self.config.server_url,
            self.config.run_id,
            self.config.capability_token,
            level=level,
            message=message,
            extra=extra or {},
        )

    # --- mock codex helpers (used in tests via BOOTSTRAP_MOCK_CODEX=1) ---

    def _mock_codex_events(self):
        yield CodexEvent(raw={"run_id": "mock-codex-run"})
        yield CodexEvent(raw={"message": "mock: starting work"})
        yield CodexEvent(raw={"message": "mock: finished"})
        self.codex_run_id = "mock-codex-run"

    def _write_mock_manifest(self) -> None:
        assert self.workspace is not None
        manifest_path = self.workspace / self.config.artifact_manifest
        manifest = [{"artifact_type": "text", "payload": {"content": "mock artifact"}}]
        manifest_path.write_text(json.dumps(manifest), encoding="utf-8")


def build_config(args: Any) -> BootstrapConfig:
    """Construct BootstrapConfig from CLI args and environment."""

    server_url = args.server or os.environ.get(ENV_SERVER_URL, DEFAULT_SERVER_URL)
    config_path = Path(args.config).expanduser().resolve()
    return BootstrapConfig(
        run_id=args.run_id or _env_or_throw(ENV_RUN_ID, "run id"),
        capability_token=args.token or _env_or_throw(ENV_RUN_TOKEN, "capability token"),
        config_path=config_path,
        server_url=server_url,
    )


def _env_or_throw(var: str, label: str) -> str:
    value = os.environ.get(var)
    if not value:
        raise SystemExit(f"missing {label} (pass flag or set {var})")
    return value
