"""Per-agent git worktree creation, shared state, and permissions."""

from __future__ import annotations

import json
import logging
import os
import shutil
import subprocess
from pathlib import Path

from coral.workspace.repo import (
    _clean_env,
    run_setup_commands,
)

logger = logging.getLogger(__name__)


def create_agent_worktree(repo_path: Path, agent_id: str, agents_dir: Path) -> Path:
    """Create a git worktree for an agent.

    Returns the worktree path.
    """
    worktree_path = agents_dir / agent_id

    if worktree_path.exists():
        logger.info(f"Worktree already exists at {worktree_path}, reusing")
        return worktree_path

    # Determine the git dir
    git_dir = repo_path / ".git" if (repo_path / ".git").exists() else repo_path
    logger.debug(f"git_dir={git_dir}")

    branch_name = f"coral/{agent_id}"

    # Get current HEAD
    result = subprocess.run(
        ["git", "--git-dir", str(git_dir), "rev-parse", "HEAD"],
        capture_output=True,
        text=True,
    )

    if result.returncode == 0:
        head = result.stdout.strip()
        logger.debug(f"HEAD={head[:12]}, creating branch {branch_name}")
        result = subprocess.run(
            ["git", "--git-dir", str(git_dir), "branch", branch_name, head],
            capture_output=True,
            text=True,
        )
        if result.returncode != 0 and "already exists" not in result.stderr:
            logger.warning(f"Branch creation: {result.stderr.strip()}")
    else:
        # No commits yet — create an initial commit
        logger.info("No commits found, creating initial empty commit")
        subprocess.run(
            [
                "git",
                "--git-dir",
                str(git_dir),
                "--work-tree",
                str(repo_path),
                "commit",
                "--allow-empty",
                "-m",
                "Initial commit",
            ],
            capture_output=True,
            text=True,
        )
        subprocess.run(
            ["git", "--git-dir", str(git_dir), "branch", branch_name],
            capture_output=True,
            text=True,
        )

    # Create worktree
    logger.info(f"Creating worktree at {worktree_path} on branch {branch_name}")
    result = subprocess.run(
        ["git", "--git-dir", str(git_dir), "worktree", "add", str(worktree_path), branch_name],
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        raise RuntimeError(
            f"git worktree add failed:\n"
            f"  git_dir: {git_dir}\n"
            f"  worktree: {worktree_path}\n"
            f"  branch: {branch_name}\n"
            f"  stderr: {result.stderr}"
        )
    logger.debug(f"Worktree created: {result.stdout.strip()}")

    return worktree_path


def setup_git_exclude(worktree_path: Path) -> None:
    """Register CORAL-managed files in the repo's git exclude file.

    Entries go to ``$GIT_COMMON_DIR/info/exclude`` rather than the tracked
    ``.gitignore``: the exclude file is shared by every worktree of the run's
    repo, is never part of any commit (so it can't pollute an attempt's diff),
    and survives ``git reset --hard`` — a working-tree ``.gitignore`` edit is
    wiped by ``coral revert`` / ``coral checkout`` or any raw reset the agent
    runs, after which ``git add -A`` stages the breadcrumb files (#171).
    """
    result = subprocess.run(
        ["git", "rev-parse", "--git-common-dir"],
        capture_output=True,
        text=True,
        cwd=worktree_path,
    )
    if result.returncode != 0:
        raise RuntimeError(
            f"git rev-parse --git-common-dir failed in {worktree_path}: {result.stderr}"
        )
    # The path may be relative to the worktree (typically just ".git").
    common_dir = (worktree_path / result.stdout.strip()).resolve()
    exclude_path = common_dir / "info" / "exclude"

    entries = {
        ".coral_agent_id",
        ".coral_dir",
        ".coral_island",
        "CLAUDE.md",
        "AGENTS.md",
        ".claude/",
        ".codex/",
        ".cursor/",
        ".opencode/",
        ".pi/",
        ".venv/",
    }

    # Preserve existing entries
    existing = set()
    if exclude_path.exists():
        existing = set(exclude_path.read_text(encoding="utf-8").splitlines())

    missing = entries - existing
    if missing:
        exclude_path.parent.mkdir(parents=True, exist_ok=True)
        with exclude_path.open("a", encoding="utf-8") as f:
            for entry in sorted(missing):
                f.write(f"{entry}\n")


def write_agent_id(worktree_path: Path, agent_id: str) -> None:
    """Write .coral_agent_id file in the worktree."""
    (worktree_path / ".coral_agent_id").write_text(agent_id, encoding="utf-8")


def write_coral_dir(worktree_path: Path, coral_dir: Path) -> None:
    """Write .coral_dir breadcrumb storing the absolute path to the shared .coral directory.

    Hooks and graders read this file to locate shared state (attempts, config,
    private grader data) without needing a symlink in the worktree.
    """
    (worktree_path / ".coral_dir").write_text(str(coral_dir.resolve()), encoding="utf-8")


def get_coral_dir(worktree_path: Path) -> Path | None:
    """Read the shared .coral directory path from the .coral_dir breadcrumb file."""
    ref_file = worktree_path / ".coral_dir"
    if ref_file.exists():
        return Path(ref_file.read_text(encoding="utf-8").strip())
    return None


def grader_source_dir(coral_dir: Path) -> Path | None:
    """Resolve the task's grader package dir (``{config_dir}/grader``), or None.

    Reads the ``config_dir`` breadcrumb written by ``create_project`` (the
    effective task dir — ``config.task_dir`` in the Docker session, which maps to
    ``/coral-setup/task``) and returns ``<that>/grader`` when it exists on disk.
    Single source of truth so both the ``<shared_dir>/grader`` symlink and the
    per-runtime read grant point at the same place without threading the task
    dir through every worktree-setup call site (spawn *and* re-permission).
    """
    cfg_file = coral_dir / "config_dir"
    if not cfg_file.exists():
        return None
    grader = Path(cfg_file.read_text(encoding="utf-8").strip()) / "grader"
    return grader if grader.is_dir() else None


def setup_shared_state(
    worktree_path: Path,
    coral_dir: Path,
    shared_dir_name: str = ".claude",
    island_id: str | int | None = None,
) -> None:
    """Create a shared state directory in the worktree with symlinks into the island root.

    Symlinks notes, skills, attempts, etc. from the per-island state root into
    the shared directory so agents can read/write shared state. In single-island
    mode (``island_id is None``) the target is ``coral_dir/public/*``; in
    multi-island mode it is ``coral_dir/islands/<id>/*``.

    When ``island_id`` is provided, also writes a ``.coral_island`` breadcrumb
    in the worktree so ``coral eval`` and other CLI commands can determine which
    island this agent belongs to without rescanning configuration.

    Args:
        worktree_path: Path to the agent's git worktree
        coral_dir: Path to the shared .coral directory
        shared_dir_name: Name of the shared dir in the worktree (e.g. ".claude")
        island_id: The agent's island id (str/int), or None for single-island mode.
    """
    from coral.hub._island import island_root

    state_root = island_root(coral_dir, island_id)
    shared_dir = worktree_path / shared_dir_name

    # Self-heal old-style absolute symlink to .coral/public/.
    if shared_dir.is_symlink():
        shared_dir.unlink()

    shared_dir.mkdir(exist_ok=True)

    for item in _SHARED_STATE_ITEMS:
        src = state_root / item
        dst = shared_dir / item
        # If a previous (buggy) run wrote into a real local dir at this path
        # instead of a symlink, migrate any files into the shared dir then
        # replace the local dir with a symlink.
        if dst.exists() and not dst.is_symlink() and dst.is_dir():
            src.mkdir(parents=True, exist_ok=True)
            for entry in dst.iterdir():
                target = src / entry.name
                if not target.exists():
                    shutil.move(str(entry), str(target))
            try:
                dst.rmdir()
            except OSError:
                continue
        if not dst.exists() and not dst.is_symlink():
            try:
                rel = os.path.relpath(src.resolve(), shared_dir.resolve())
                dst.symlink_to(rel)
            except (ValueError, OSError):
                dst.symlink_to(src.resolve())

    # Surface the grader source at <shared_dir>/grader/ so the agent can read
    # the exact code that scores it (CORAL.md points here and tells the agent
    # not to modify it). This is a symlink to the real grader package — not a
    # copy — so there is no duplication. The grader that actually runs is the
    # editable install from this same source, so writes here would perturb
    # grading; in the Docker session the target is a read-only (:ro) bind mount,
    # which makes it physically unwritable. On the host there is no isolation,
    # so read-only is best-effort (see the CORAL.md reminder). The symlink
    # target is outside the worktree and shared state, so the per-runtime read
    # grant in setup_claude_settings / setup_opencode_settings must also cover
    # it — otherwise the agent could see the link but not read through it.
    # Guarded on existence so a task without a grader dir gets no dangling link.
    grader_source = grader_source_dir(coral_dir)
    if grader_source is not None:
        grader_dst = shared_dir / "grader"
        if not grader_dst.exists() and not grader_dst.is_symlink():
            grader_dst.symlink_to(grader_source.resolve())

    # Write the .coral_island breadcrumb when on an island. Single-island
    # callers (no island_id) deliberately do NOT get this file — its absence
    # is how downstream code (submit_eval, monitor_loop) distinguishes modes.
    if island_id is not None:
        (worktree_path / ".coral_island").write_text(str(island_id), encoding="utf-8")


# Items inside the shared dir that are agent-facing symlinks into the
# island state root. Kept in module scope so :func:`setup_shared_state`
# and :func:`repoint_shared_state` stay in sync.
_SHARED_STATE_ITEMS: tuple[str, ...] = (
    "notes",
    "skills",
    "agents",
    "attempts",
    "logs",
    "heartbeat",
    "roles",
    "eval_logs",
)


def repoint_shared_state(
    worktree_path: Path,
    coral_dir: Path,
    shared_dir_name: str,
    new_island_id: str | int,
) -> None:
    """Repoint an agent's shared-state symlinks at a different island.

    Used by migration: the agent's worktree was originally wired to
    ``coral_dir/islands/<src>/*``; after migration the same shared dir
    (`.claude/`, `.codex/`, ...) needs to surface the destination
    island's notes / attempts / etc. instead. We unlink each item
    symlink unconditionally and recreate it against the new island root,
    then rewrite the ``.coral_island`` breadcrumb so the next
    ``coral eval`` from this worktree submits to the right place.

    ``setup_shared_state`` on its own won't do this: it short-circuits
    when ``dst.exists()`` is True, and a symlink to the still-existing
    old island satisfies ``exists()``.

    Raises:
        ValueError: if ``new_island_id`` is None or fails island_root
            validation (path separators, etc.).
    """
    from coral.hub._island import island_root

    if new_island_id is None:
        raise ValueError("repoint_shared_state requires a non-None new_island_id")

    state_root = island_root(coral_dir, new_island_id)
    shared_dir = worktree_path / shared_dir_name
    shared_dir.mkdir(exist_ok=True)

    for item in _SHARED_STATE_ITEMS:
        src = state_root / item
        dst = shared_dir / item
        # Ensure the new island has the directory the symlink will point at
        # (creating it lazily here keeps repoint idempotent even when the
        # destination island hasn't seen any agent activity yet).
        src.mkdir(parents=True, exist_ok=True)

        if dst.is_symlink():
            dst.unlink()
        elif dst.exists() and dst.is_dir():
            # Local dir at this path (shouldn't happen in normal runs, but
            # the original setup_shared_state self-heals this case so we
            # match): move any contents into the new state root, then drop
            # the local dir so we can replace it with a symlink.
            for entry in dst.iterdir():
                target = src / entry.name
                if not target.exists():
                    shutil.move(str(entry), str(target))
            try:
                dst.rmdir()
            except OSError:
                logger.warning(f"repoint_shared_state: could not remove non-empty local dir {dst}")
                continue

        try:
            rel = os.path.relpath(src.resolve(), shared_dir.resolve())
            dst.symlink_to(rel)
        except (ValueError, OSError):
            dst.symlink_to(src.resolve())

    (worktree_path / ".coral_island").write_text(str(new_island_id), encoding="utf-8")


def apply_runtime_mounts(
    worktree_path: Path,
    mounts: dict[str, str],
    base_dir: Path,
) -> None:
    """Copy host files into the agent worktree per ``runtime_options.mounts``.

    ``mounts`` is a ``{source: dest}`` dict (matching ``docker -v`` source-first
    convention):

    - **source** is a host path with ``~`` expansion. Resolved relative to
      ``base_dir`` (typically the task directory) when not absolute.
    - **dest** is worktree-relative (e.g. ``.claude/settings.json``). Must
      stay inside the worktree — ``..`` and absolute paths are rejected.

    Files are copied (not symlinked) on every agent setup, so edits to the
    source propagate at the next agent restart but the worktree owns its own
    snapshot in between. Parent dirs are created. Existing dest files are
    overwritten — the call is the last hook before the agent starts, so
    user-supplied files win over CORAL's defaults (notably, mounting to
    ``.claude/settings.local.json`` will replace what
    ``setup_claude_settings`` just wrote).

    For Claude Code settings the recommended dest is ``.claude/settings.json``
    (no ``.local`` suffix). Claude Code natively merges that with CORAL's
    ``settings.local.json``, so the user's MCP servers / hooks / env layer
    on top of CORAL's required worktree-scoped permissions without anyone
    having to hand-merge JSON.

    Raises:
        FileNotFoundError: if ``source`` does not resolve to an existing path.
        ValueError: if ``dest`` escapes ``worktree_path``.
    """
    if not mounts:
        return
    worktree_resolved = worktree_path.resolve()
    for source_raw, dest_raw in mounts.items():
        source = Path(source_raw).expanduser()
        if not source.is_absolute():
            source = (base_dir / source).resolve()
        if not source.exists():
            raise FileNotFoundError(
                f"mount source {source_raw!r} (resolved to {source}) does not exist"
            )

        dest_path = Path(dest_raw)
        if dest_path.is_absolute():
            raise ValueError(f"mount dest {dest_raw!r} must be worktree-relative, not absolute")
        dest = (worktree_resolved / dest_path).resolve()
        try:
            dest.relative_to(worktree_resolved)
        except ValueError as e:
            raise ValueError(f"mount dest {dest_raw!r} escapes worktree {worktree_path}") from e

        dest.parent.mkdir(parents=True, exist_ok=True)
        if source.is_dir():
            if dest.exists() or dest.is_symlink():
                if dest.is_dir() and not dest.is_symlink():
                    shutil.rmtree(dest)
                else:
                    dest.unlink()
            shutil.copytree(source, dest)
        else:
            if dest.is_dir() and not dest.is_symlink():
                shutil.rmtree(dest)
            shutil.copy2(source, dest)


def setup_claude_settings(
    worktree_path: Path,
    coral_dir: Path,
    *,
    research: bool = True,
    gateway_url: str | None = None,
    gateway_api_key: str | None = None,
    island_id: str | int | None = None,
) -> None:
    """Write Claude Code settings.json with permissions and gateway env.

    Scopes the agent's tools via allow/deny rules (replacing
    --dangerously-skip-permissions).  The permission *mode* is deliberately NOT
    set here: a project-level ``defaultMode: "auto"`` is silently downgraded to
    ``default`` in headless ``-p`` mode (only ~/.claude/settings.json or the
    ``--permission-mode`` CLI flag may escalate to auto), so the runtime sets it
    via ``--permission-mode auto`` on the command line instead. The allow/deny
    rules below do take effect and are the real scoping mechanism.  When a
    gateway is configured, sets ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY in the
    settings ``env`` so they override the user's global ``~/.claude/settings.json``.

    In multi-island runs (``island_id`` set), Read scopes only to the
    agent's own island root (``.coral/islands/<id>/``) — sibling islands
    are off-limits. In single-island mode (``island_id is None``), scopes
    to ``.coral/public/``.
    """
    from coral.hub._island import island_root

    claude_dir = worktree_path / ".claude"
    claude_dir.mkdir(exist_ok=True)

    private_dir = str(coral_dir.resolve() / "private")
    state_root_resolved = island_root(coral_dir, island_id).resolve()
    agents_dir = str(state_root_resolved / "agents")
    worktree_str = str(worktree_path.resolve())
    private_pattern = f"{private_dir}/**"
    agents_pattern = f"{agents_dir}/**"
    worktree_pattern = f"{worktree_str}/**"
    state_root_pattern = f"{state_root_resolved}/**"

    # Allow rules grant agent autonomy without --dangerously-skip-permissions
    # Bash/Edit/Write are scoped to the agent's own worktree via allow + deny rules
    allow_rules: list[str] = [
        "Bash",
        f"Read(/{worktree_pattern})",
        f"Read(/{state_root_pattern})",
        f"Read(/{agents_pattern})",
        f"Edit(/{worktree_pattern})",
        f"Write(/{worktree_pattern})",
    ]
    if research:
        allow_rules.extend(["WebSearch", "WebFetch"])

    # Grant read on the grader source so the <shared_dir>/grader symlink is
    # actually readable: its target is outside the worktree/state root, so
    # neither the worktree nor state-root Read rule covers it. Read-only — the
    # grader is not in the Edit/Write allow set, so tool-based edits via the
    # resolved path are denied (Docker's :ro mount is the hard guarantee).
    grader_source = grader_source_dir(coral_dir)
    if grader_source is not None:
        grader_pattern = f"{grader_source.resolve()}/**"
        allow_rules.append(f"Read(/{grader_pattern})")

    # Deny rules block git and private dir access.
    # Edit/Write/Bash don't need agents_pattern denies — the scoped allows
    # already restrict them to the agent's own worktree.
    deny_rules: list[str] = [
        "Bash(git *)",
        f"Read(/{private_pattern})",
        # Tools that block on human approval — there is no human in the
        # loop in CORAL. Leaving them enabled causes the agent to stall
        # indefinitely waiting for a reply that never comes. Planning
        # belongs in TodoWrite / focus notes; uncertainty belongs in an
        # eval message, not a question.
        "AskUserQuestion",
        "EnterPlanMode",
        "ExitPlanMode",
    ]
    if not research:
        deny_rules.extend(["WebSearch", "WebFetch"])

    # No ``defaultMode`` here: a project-level ``auto`` is silently downgraded
    # to ``default`` in headless ``-p`` mode, so it would be a misleading no-op.
    # The mode is set authoritatively via ``--permission-mode auto`` on the
    # ``claude`` CLI (see coral/agent/builtin/claude_code.py).
    permissions: dict = {
        "allow": allow_rules,
        "deny": deny_rules,
    }

    settings: dict = {
        "permissions": permissions,
    }

    # Route agent traffic through gateway by overriding env in settings.
    # Claude Code reads env vars from settings, not the OS environment,
    # so process-level env vars have no effect.
    if gateway_url or gateway_api_key:
        env: dict[str, str] = {}
        if gateway_url:
            env["ANTHROPIC_BASE_URL"] = gateway_url
        if gateway_api_key:
            env["ANTHROPIC_API_KEY"] = gateway_api_key
        # Clear custom headers so the agent doesn't send them to the
        # local gateway — LiteLLM handles upstream headers via its own
        # config.  Without this, headers from the user's global settings
        env["ANTHROPIC_CUSTOM_HEADERS"] = ""
        settings["env"] = env

    settings_path = claude_dir / "settings.local.json"
    # Always overwrite — each agent needs its own copy
    settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")


def setup_opencode_settings(
    worktree_path: Path,
    coral_dir: Path,
    *,
    research: bool = True,
    gateway_url: str | None = None,
    gateway_api_key: str | None = None,
    island_id: str | int | None = None,
) -> None:
    """Write OpenCode opencode.json with scoped permissions.

    Allows access to the agent's worktree and shared island state,
    but denies access to .coral/private/ (grader data, answer keys).
    When a gateway is configured, patches the provider's baseURL so
    agent traffic routes through the LiteLLM proxy.

    In multi-island runs the ``external_directory`` allow scopes to the
    agent's island root only; in single-island mode it scopes to
    ``.coral/public/``.
    """
    from coral.hub._island import island_root

    opencode_dir = worktree_path / ".opencode"
    opencode_dir.mkdir(exist_ok=True)

    private_pattern = str(coral_dir.resolve() / "private") + "/**"
    state_root_pattern = str(island_root(coral_dir, island_id).resolve()) + "/**"

    # Grant the grader source as an allowed external dir so the
    # <shared_dir>/grader symlink is readable (its target is outside the project
    # root; OpenCode gates out-of-project access via external_directory, so
    # "*": "allow" alone does not reach it).
    external_allow = {state_root_pattern: "allow"}
    grader_source = grader_source_dir(coral_dir)
    if grader_source is not None:
        external_allow[str(grader_source.resolve()) + "/**"] = "allow"

    settings: dict = {
        "$schema": "https://opencode.ai/config.json",
        "permission": {
            "*": "allow",
            "external_directory": external_allow,
            "read": {
                private_pattern: "deny",
            },
            "bash": {
                private_pattern: "deny",
            },
            "edit": {
                private_pattern: "deny",
            },
            "write": {
                private_pattern: "deny",
            },
            "question": "deny",
            "doom_loop": "allow",
            "webfetch": "deny" if not research else "allow",
            "websearch": "deny" if not research else "allow",
        },
    }

    if gateway_url:
        provider_options: dict[str, str] = {"baseURL": gateway_url}
        if gateway_api_key:
            provider_options["apiKey"] = gateway_api_key
        settings["provider"] = {
            "openai": {
                "npm": "@ai-sdk/openai",
                "name": "openai",
                "options": provider_options,
                "models": {
                    "gpt-5.4": {"name": "gpt-5.4"},
                    "claude-opus-4-6": {"name": "claude-opus-4-6"},
                },
            },
        }

    settings_path = opencode_dir / "opencode.json"
    settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")


def setup_codex_settings(
    worktree_path: Path,
    coral_dir: Path,
    *,
    research: bool = True,
    gateway_url: str | None = None,
    gateway_api_key: str | None = None,
    island_id: str | int | None = None,  # noqa: ARG001
) -> None:
    """Write Codex CLI config.toml with sandbox, approval, and web search settings.

    Sets the agent to full-auto mode (no approval prompts, workspace-write
    sandbox) and toggles web_search based on the *research* flag.  When a
    gateway is configured, sets ``base_url`` so the agent routes
    traffic through the LiteLLM proxy.
    """
    codex_dir = worktree_path / ".codex"
    codex_dir.mkdir(exist_ok=True)

    web_search = "live" if research else "disabled"

    lines = [
        'model = "gpt-5.4"',
        'approval_policy = "never"',
        'sandbox_mode = "danger-full-access"',
        'personality = "pragmatic"',
        f'web_search = "{web_search}"',
    ]

    if gateway_url:
        lines += [
            'model_provider = "litellm"\n',
            "[model_providers.litellm]",
            'name = "LiteLLM Proxy"',
            f'base_url = "{gateway_url}/v1"',
            'wire_api = "responses"',
            'env_key = "OPENAI_API_KEY"',
        ]

    config_toml = "\n".join(lines) + "\n"

    settings_path = codex_dir / "config.toml"
    settings_path.write_text(config_toml, encoding="utf-8")


def setup_cursor_settings(
    worktree_path: Path,
    coral_dir: Path,
    *,
    research: bool = True,
    # Cursor Agent uses its own auth (`cursor-agent login`) and does not
    # honour the OpenAI/Anthropic base-url env vars LiteLLM relies on.
    # The kwargs are accepted so the manager dispatch can stay uniform.
    gateway_url: str | None = None,  # noqa: ARG001
    gateway_api_key: str | None = None,  # noqa: ARG001
    island_id: str | int | None = None,  # noqa: ARG001
) -> None:
    """Write `.cursor/rules/coral.mdc` with always-apply CORAL guardrails.

    Cursor Agent reads `.cursor/rules/*.mdc` files via its native rules
    system in addition to AGENTS.md. The full task brief lives in AGENTS.md;
    this file holds short, high-priority constraints that should survive
    context pressure (eval workflow, private-dir guard, sharing channels).

    Permission bypass is handled at the CLI via `--force`, not in a settings
    file — so unlike claude/opencode/codex there is no permissions block.
    """
    rules_dir = worktree_path / ".cursor" / "rules"
    rules_dir.mkdir(parents=True, exist_ok=True)

    private_dir = str(coral_dir.resolve() / "private")

    body_lines = [
        "Always:",
        "",
        '- Use `coral eval -m "<short description>"` to stage, commit, and grade your work — never bare `git commit`.',
        "- Read the full task brief in `AGENTS.md` at the workspace root.",
        f"- Do not read or modify anything under `{private_dir}/` (grader internals, answer keys).",
        "- Share findings through `.cursor/notes/` and reusable tools through `.cursor/skills/` so other agents benefit.",
    ]
    if not research:
        body_lines.append("- Web search and web fetch are disabled for this run.")

    rules_md = (
        "---\n"
        "description: CORAL agent guardrails\n"
        "globs:\n"
        "alwaysApply: true\n"
        "---\n"
        "\n"
        "# CORAL Agent Guardrails\n"
        "\n" + "\n".join(body_lines) + "\n"
    )

    (rules_dir / "coral.mdc").write_text(rules_md, encoding="utf-8")


def setup_worktree_env(worktree_path: Path, setup_commands: list[str]) -> None:
    """Run setup commands and install coral in a worktree's venv.

    After creating a worktree, we need to:
    1. Run workspace setup commands (e.g. ``uv sync``) so the worktree
       gets its own ``.venv`` with task dependencies.
    2. Install ``coral`` into that venv so ``coral eval`` is available
       when the agent uses ``uv run``.

    Each worktree gets its own isolated ``.venv`` via UV_PROJECT_ENVIRONMENT
    to prevent concurrent agents from corrupting a shared venv.

    Idempotent: if the worktree's ``.venv`` is already populated (the python
    binary exists), skip both the setup commands and the coral reinstall.
    Deps don't change mid-run, so re-running ``uv sync`` on every
    interrupt-and-resume cycle is wasted work. To force a re-sync, delete the
    ``.venv`` directory before resuming.
    """
    if not setup_commands:
        return

    # Force uv to create/use a venv inside this worktree, even if
    # pyproject.toml is resolved from a parent directory.
    worktree_venv = worktree_path / ".venv"
    venv_python = worktree_venv / "bin" / "python"
    if venv_python.exists():
        logger.debug(f"Worktree venv already populated at {worktree_venv}, skipping setup commands")
        return

    env_override = {"UV_PROJECT_ENVIRONMENT": str(worktree_venv)}
    run_setup_commands(setup_commands, worktree_path, extra_env=env_override)

    # Install coral into the worktree's venv so agents can use
    # ``uv run coral eval`` and graders can ``from coral.grader import ...``.
    venv_python = worktree_venv / "bin" / "python"
    if venv_python.exists() and shutil.which("uv"):
        coral_root = Path(__file__).resolve().parent.parent.parent
        if (coral_root / "pyproject.toml").exists():
            logger.info(f"Installing coral into worktree venv from {coral_root}")
            env = _clean_env()
            env.update(env_override)
            result = subprocess.run(
                ["uv", "pip", "install", "--python", str(venv_python), "-e", str(coral_root)],
                cwd=str(worktree_path),
                capture_output=True,
                text=True,
                env=env,
            )
            if result.returncode != 0:
                logger.warning(f"Failed to install coral in worktree: {result.stderr.strip()}")
