package com.easyagents.agent.runtime.tool.operate;

import com.easyagents.agent.runtime.AgentRuntimeException;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalEvaluation;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.tool.AgentTool;
import io.agentscope.core.tool.ToolCallParam;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

/**
 * 不经过系统 Shell 解释器的受控命令执行工具。
 *
 * <p>命令先按受限引号规则拆分为参数，再直接交给 {@link ProcessBuilder}。因此管道、重定向、
 * 命令替换和环境变量展开既会被显式拒绝，也不会被二次解释。
 */
public final class ControlledShellTool implements AgentTool {

    /** L22 首版固定命令白名单。 */
    public static final Set<String> DEFAULT_ALLOWED_COMMANDS = Set.of(
            "pwd", "ls", "cat", "head", "tail", "wc", "grep", "rg", "sed", "awk", "sort", "uniq",
            "cut", "tr", "basename", "dirname", "stat", "file", "date", "sha256sum", "shasum", "jq",
            "diff", "cmp", "du", "tree",
            "mkdir", "touch", "cp", "mv", "rm", "python", "python3", "node",
            "gzip", "gunzip", "zip", "unzip", "tar",
            "pandoc", "soffice", "pdftoppm", "pdfinfo", "pdftotext", "pdfimages", "qpdf");

    private static final Set<String> APPROVAL_REQUIRED_COMMANDS = Set.of(
            "mkdir", "touch", "cp", "mv", "gzip", "gunzip", "zip", "unzip", "tar",
            "pandoc", "soffice", "pdftoppm", "pdfimages", "qpdf");

    private static final Map<Integer, Semaphore> INSTANCE_LIMITERS = new ConcurrentHashMap<>();
    private static final Map<Integer, ExecutorService> OUTPUT_EXECUTORS = new ConcurrentHashMap<>();
    private static final Map<Process, ActiveProcess> ACTIVE_PROCESS_TREES = new ConcurrentHashMap<>();
    private static final AtomicInteger OUTPUT_THREAD_SEQUENCE = new AtomicInteger();
    private static final String FORBIDDEN_METACHARACTERS = ";|&><`$";
    private static final String TRUSTED_EXECUTABLE_PATH = "/usr/local/bin:/usr/bin:/bin";

    static {
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            for (Map.Entry<Process, ActiveProcess> entry : ACTIVE_PROCESS_TREES.entrySet()) {
                ActiveProcess active = entry.getValue();
                active.processGroupSupport().terminate(active.processGroupId());
                terminateProcessTreeNow(entry.getKey(), active.observedDescendants());
            }
            OUTPUT_EXECUTORS.values().forEach(ExecutorService::shutdownNow);
        }, "easyagents-shell-shutdown"));
    }

    private final WorkspacePathGuard pathGuard;
    private final WorkspaceQuotaGuard quotaGuard;
    private final Set<String> allowedCommands;
    private final int defaultTimeoutSeconds;
    private final int maxTimeoutSeconds;
    private final int maxCommandLength;
    private final int maxOutputSize;
    private final Semaphore limiter;
    private final ExecutorService outputExecutor;
    private final ShellCommandOptionValidator optionValidator;
    private final SafeArchiveCommandExecutor archiveCommandExecutor;
    private final ShellProcessGroupSupport processGroupSupport;

    /**
     * 创建受控 Shell 工具。
     *
     * @param pathGuard 路径保护器
     * @param quotaGuard 配额保护器
     * @param spec 操作工具配置
     */
    public ControlledShellTool(WorkspacePathGuard pathGuard,
                               WorkspaceQuotaGuard quotaGuard,
                               AgentOperateToolSpec spec) {
        this.pathGuard = pathGuard;
        this.quotaGuard = quotaGuard;
        this.allowedCommands = validateAllowedCommands(spec.getShellAllowedCommands());
        this.defaultTimeoutSeconds = seconds(spec.getShellDefaultTimeout(), "shellDefaultTimeout");
        this.maxTimeoutSeconds = seconds(spec.getShellMaxTimeout(), "shellMaxTimeout");
        if (defaultTimeoutSeconds > maxTimeoutSeconds) {
            throw new AgentRuntimeException("Shell default timeout must not exceed max timeout.");
        }
        if (spec.getShellMaxCommandLength() <= 0 || spec.getShellMaxOutputSize() <= 0
                || spec.getShellMaxOutputSize() > Integer.MAX_VALUE || spec.getShellMaxConcurrency() <= 0
                || spec.getShellMaxConcurrency() > 64) {
            throw new AgentRuntimeException("Shell limits must be positive and output size must fit in memory.");
        }
        if (spec.getShellCharset() != null && !spec.getShellCharset().isBlank()
                && !"UTF-8".equalsIgnoreCase(spec.getShellCharset().trim())) {
            throw new AgentRuntimeException("Shell charset must be UTF-8.");
        }
        this.maxCommandLength = spec.getShellMaxCommandLength();
        this.maxOutputSize = (int) spec.getShellMaxOutputSize();
        this.limiter = INSTANCE_LIMITERS.computeIfAbsent(spec.getShellMaxConcurrency(), Semaphore::new);
        this.outputExecutor = OUTPUT_EXECUTORS.computeIfAbsent(
                spec.getShellMaxConcurrency(), ControlledShellTool::createOutputExecutor);
        this.optionValidator = new ShellCommandOptionValidator(pathGuard);
        this.archiveCommandExecutor = new SafeArchiveCommandExecutor(pathGuard, quotaGuard, maxOutputSize);
        this.processGroupSupport = ShellProcessGroupSupport.detect();
    }

    /**
     * 获取工具名。
     *
     * @return `execute_shell_command`
     */
    @Override
    public String getName() {
        return AgentOperateToolAdapter.EXECUTE_SHELL_COMMAND_TOOL;
    }

    /**
     * 获取工具描述。
     *
     * @return 工具描述
     */
    @Override
    public String getDescription() {
        return "Execute one allowlisted command in the workspace without shell operators or host path access.";
    }

    /**
     * 获取与 AgentScope 1.x 兼容的参数 Schema。
     *
     * @return JSON Schema
     */
    @Override
    public Map<String, Object> getParameters() {
        return Map.of(
                "type", "object",
                "properties", Map.of(
                        "command", Map.of("type", "string", "description", "The single command to execute"),
                        "timeout", Map.of("type", "integer", "description", "Execution timeout in seconds"),
                        "charset", Map.of("type", "string", "description", "Must be UTF-8 when supplied")),
                "required", List.of("command"));
    }

    /**
     * 校验并异步执行命令。
     *
     * @param param Tool 调用参数
     * @return Tool 结果
     */
    @Override
    public Mono<ToolResultBlock> callAsync(ToolCallParam param) {
        return Mono.fromCallable(() -> execute(param)).subscribeOn(Schedulers.boundedElastic());
    }

    /**
     * 在 HITL 事件生成前校验命令并计算单次调用的审批策略。
     *
     * <p>无效命令不弹出审批，随后由工具调用返回结构化拒绝结果。Python/Node 脚本以
     * 脚本内容和参数的摘要作为本轮复用作用域，脚本变化后必须重新审批。</p>
     *
     * @param toolInput Shell 工具入参
     * @return 动态审批判定
     */
    public AgentToolApprovalEvaluation approvalEvaluation(Map<String, Object> toolInput) {
        try {
            String command = requiredCommand(toolInput);
            List<String> arguments = parse(command);
            validate(arguments);
            String executable = arguments.get(0);
            if ("rm".equals(executable)) {
                return AgentToolApprovalEvaluation.valid(true, true, null);
            }
            if (Set.of("python", "python3", "node").contains(executable)) {
                return AgentToolApprovalEvaluation.valid(true, false, scriptApprovalScope(arguments));
            }
            if ("pdftotext".equals(executable)) {
                boolean stdoutOnly = arguments.size() >= 3 && "-".equals(arguments.get(arguments.size() - 1));
                return AgentToolApprovalEvaluation.valid(!stdoutOnly, false, null);
            }
            return AgentToolApprovalEvaluation.valid(
                    APPROVAL_REQUIRED_COMMANDS.contains(executable), false, null);
        } catch (RuntimeException error) {
            return AgentToolApprovalEvaluation.invalid();
        }
    }

    private ToolResultBlock execute(ToolCallParam param) {
        boolean acquired = false;
        Process process = null;
        long processGroupId = -1;
        Set<ProcessHandle> observedDescendants = ConcurrentHashMap.newKeySet();
        try {
            String command = requiredCommand(param);
            int timeout = requestedTimeout(param);
            validateCharset(param);
            List<String> arguments = parse(command);
            validate(arguments);
            quotaGuard.validateCurrentUsage();
            acquired = limiter.tryAcquire(Math.min(timeout, defaultTimeoutSeconds), TimeUnit.SECONDS);
            if (!acquired) {
                return WorkspaceToolResults.error(
                        "SHELL_CONCURRENCY_LIMIT", "Shell execution queue is full.", true);
            }
            long startedAt = System.nanoTime();
            if (SafeArchiveCommandExecutor.COMMANDS.contains(arguments.get(0))) {
                SafeArchiveCommandExecutor.ArchiveExecutionResult archiveResult =
                        archiveCommandExecutor.execute(arguments,
                                startedAt + TimeUnit.SECONDS.toNanos(timeout));
                quotaGuard.validateCurrentUsage();
                long durationMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt);
                return result(0,
                        new BoundedOutput(archiveResult.output(), archiveResult.truncated()),
                        new BoundedOutput("", false), null, null, false, durationMillis);
            }
            ProcessBuilder processBuilder = new ProcessBuilder(processGroupSupport.wrap(arguments));
            processBuilder.directory(pathGuard.root().toFile());
            sanitizeEnvironment(processBuilder.environment());
            process = processBuilder.start();
            processGroupId = processGroupSupport.enabled() ? process.pid() : -1;
            ACTIVE_PROCESS_TREES.put(process,
                    new ActiveProcess(observedDescendants, processGroupSupport, processGroupId));
            CompletableFuture<BoundedOutput> stdout = readBounded(process.getInputStream());
            CompletableFuture<BoundedOutput> stderr = readBounded(process.getErrorStream());
            boolean completed;
            try {
                completed = waitForProcess(process, timeout, observedDescendants);
            } catch (InterruptedException interrupted) {
                terminateProcessTree(process, observedDescendants, processGroupId);
                Thread.currentThread().interrupt();
                return WorkspaceToolResults.error("SHELL_INTERRUPTED", "Shell command was interrupted.", true);
            }
            if (!completed) {
                terminateProcessTree(process, observedDescendants, processGroupId);
            } else {
                // 白名单脚本不允许在 Tool 正常返回后遗留后台子进程。
                processGroupSupport.terminate(processGroupId);
                terminateObservedDescendants(observedDescendants);
            }
            BoundedOutput stdoutValue = awaitOutput(stdout);
            BoundedOutput stderrValue = awaitOutput(stderr);
            quotaGuard.validateCurrentUsage();
            long durationMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt);
            if (!completed) {
                return result(-1, stdoutValue, stderrValue,
                        "SHELL_TIMEOUT", "Shell command exceeded " + timeout + " seconds.", true, durationMillis);
            }
            return result(process.exitValue(), stdoutValue, stderrValue, null, null, false, durationMillis);
        } catch (WorkspaceToolException error) {
            return WorkspaceToolResults.error(error);
        } catch (AgentRuntimeException error) {
            return WorkspaceToolResults.error("SHELL_COMMAND_DENIED", error.getMessage(), false);
        } catch (IOException error) {
            return WorkspaceToolResults.error(
                    "SHELL_EXECUTION_FAILED", "Command is unavailable or could not be started.", false);
        } catch (InterruptedException error) {
            Thread.currentThread().interrupt();
            return WorkspaceToolResults.error("SHELL_INTERRUPTED", "Shell execution queue wait was interrupted.", true);
        } catch (RuntimeException error) {
            return WorkspaceToolResults.error(
                    new AgentRuntimeException("Unexpected shell execution failure.", error));
        } finally {
            if (process != null) {
                if (process.isAlive()) {
                    terminateProcessTree(process, observedDescendants, processGroupId);
                } else {
                    processGroupSupport.terminate(processGroupId);
                    terminateObservedDescendants(observedDescendants);
                }
                ACTIVE_PROCESS_TREES.remove(process);
            }
            if (acquired) {
                limiter.release();
            }
        }
    }

    private List<String> parse(String command) {
        List<String> tokens = new ArrayList<>();
        StringBuilder current = new StringBuilder();
        char quote = 0;
        boolean escaping = false;
        for (int index = 0; index < command.length(); index++) {
            char character = command.charAt(index);
            if (character == '\n' || character == '\r' || character == '\0'
                    || Character.isISOControl(character)) {
                throw new AgentRuntimeException("Shell control characters are not allowed.");
            }
            if (FORBIDDEN_METACHARACTERS.indexOf(character) >= 0 || character == '~') {
                throw new AgentRuntimeException("Shell operators, substitutions, and expansions are not allowed.");
            }
            if (escaping) {
                current.append(character);
                escaping = false;
            } else if (character == '\\' && quote != '\'') {
                escaping = true;
            } else if ((character == '\'' || character == '"')) {
                if (quote == 0) {
                    quote = character;
                } else if (quote == character) {
                    quote = 0;
                } else {
                    current.append(character);
                }
            } else if (Character.isWhitespace(character) && quote == 0) {
                if (!current.isEmpty()) {
                    tokens.add(current.toString());
                    current.setLength(0);
                }
            } else {
                current.append(character);
            }
        }
        if (escaping || quote != 0) {
            throw new AgentRuntimeException("Shell command contains an unfinished escape or quote.");
        }
        if (!current.isEmpty()) {
            tokens.add(current.toString());
        }
        if (tokens.isEmpty()) {
            throw new AgentRuntimeException("Shell command is required.");
        }
        return tokens;
    }

    private void validate(List<String> arguments) {
        String executable = arguments.get(0);
        if (executable.contains("/") || executable.contains("\\") || !allowedCommands.contains(executable)) {
            throw new AgentRuntimeException("Shell command is not allowlisted: " + executable);
        }
        for (int index = 1; index < arguments.size(); index++) {
            String argument = arguments.get(index);
            rejectHostOrTraversalPath(argument);
            validateExistingPathArgument(argument);
        }
        optionValidator.validate(arguments);
        if ("python".equals(executable) || "python3".equals(executable)) {
            validateScript(arguments, Set.of(".py"), "-c", "-m");
        } else if ("node".equals(executable)) {
            validateScript(arguments, Set.of(".js", ".mjs", ".cjs"), "-e", "--eval");
        } else if ("rm".equals(executable)) {
            validateRemove(arguments);
        }
    }

    private void validateScript(List<String> arguments, Set<String> extensions, String... deniedOptions) {
        if (arguments.size() < 2 || arguments.get(1).startsWith("-")) {
            throw new AgentRuntimeException("Script command requires a workspace script file as its first argument.");
        }
        for (String denied : deniedOptions) {
            if (arguments.contains(denied)) {
                throw new AgentRuntimeException("Inline or module script execution is not allowed.");
            }
        }
        String script = arguments.get(1);
        if (extensions.stream().noneMatch(script::endsWith)) {
            throw new AgentRuntimeException("Script file extension is not allowed.");
        }
        pathGuard.resolveExistingFile(script);
    }

    private void validateRemove(List<String> arguments) {
        boolean hasTarget = false;
        boolean recursive = false;
        boolean force = false;
        for (int index = 1; index < arguments.size(); index++) {
            String argument = arguments.get(index);
            if (argument.startsWith("-")) {
                String flags = argument.replace("-", "");
                recursive |= flags.contains("r") || flags.contains("R") || "recursive".equals(flags);
                force |= flags.contains("f") || "force".equals(flags);
                continue;
            }
            if (".".equals(argument) || "./".equals(argument)) {
                throw new AgentRuntimeException("Workspace root cannot be removed.");
            }
            hasTarget = true;
        }
        if (!hasTarget) {
            throw new AgentRuntimeException("rm requires at least one workspace target.");
        }
        if (recursive && force) {
            throw new AgentRuntimeException("Recursive forced removal is not allowed.");
        }
    }

    private void rejectHostOrTraversalPath(String argument) {
        if (argument.startsWith("-")
                && (argument.contains("/") || argument.contains("\\") || argument.contains("~"))) {
            throw new AgentRuntimeException("Shell option-embedded paths are not allowed.");
        }
        String candidate = optionValue(argument);
        if (candidate.isEmpty() || candidate.startsWith("-")) {
            return;
        }
        if (candidate.startsWith("/") || candidate.startsWith("\\")
                || candidate.matches("^[A-Za-z]:[\\\\/].*") || candidate.startsWith("~")) {
            throw new AgentRuntimeException("Shell absolute paths are not allowed.");
        }
        if (candidate.matches("^[A-Za-z][A-Za-z0-9+.-]*://.*")
                || candidate.regionMatches(true, 0, "file:", 0, "file:".length())
                || candidate.regionMatches(true, 0, "data:", 0, "data:".length())) {
            throw new AgentRuntimeException("Shell URI inputs are not allowed.");
        }
        for (String segment : candidate.replace('\\', '/').split("/")) {
            if ("..".equals(segment)) {
                throw new AgentRuntimeException("Shell path traversal is not allowed.");
            }
        }
    }

    private void validateExistingPathArgument(String argument) {
        String candidate = optionValue(argument);
        if (candidate.isEmpty() || candidate.startsWith("-") || candidate.equals(".")) {
            return;
        }
        Path possible = pathGuard.root().resolve(candidate).normalize();
        if (!possible.startsWith(pathGuard.root()) || !Files.exists(possible, LinkOption.NOFOLLOW_LINKS)) {
            return;
        }
        pathGuard.resolveExistingEntry(candidate);
    }

    private String optionValue(String argument) {
        int equals = argument.indexOf('=');
        return equals >= 0 ? argument.substring(equals + 1) : argument;
    }

    private void sanitizeEnvironment(Map<String, String> environment) {
        environment.clear();
        // 固定搜索路径，避免宿主继承 PATH 中的可写目录劫持白名单命令。
        environment.put("PATH", TRUSTED_EXECUTABLE_PATH);
        environment.put("PYTHONPATH", "/opt/easyflow/python-packages");
        environment.put("NODE_PATH", "/app/node_modules");
        environment.put("HOME", pathGuard.root().toString());
        environment.put("TMPDIR", pathGuard.root().toString());
        environment.put("LANG", "C.UTF-8");
        environment.put("LC_ALL", "C.UTF-8");
    }

    private String requiredCommand(ToolCallParam param) {
        Object value = param == null ? null : param.getInput().get("command");
        return requiredCommand(value);
    }

    /**
     * 从动态审批入参中读取命令。
     *
     * @param input 工具调用入参
     * @return 已完成基础校验的命令
     */
    private String requiredCommand(Map<String, Object> input) {
        Object value = input == null ? null : input.get("command");
        return requiredCommand(value);
    }

    /**
     * 校验命令值与最大长度。
     *
     * @param value 原始命令值
     * @return 已完成基础校验的命令
     */
    private String requiredCommand(Object value) {
        if (!(value instanceof String command) || command.isBlank()) {
            throw new AgentRuntimeException("Shell command is required.");
        }
        if (command.length() > maxCommandLength) {
            throw new AgentRuntimeException("Shell command exceeds max-command-length.");
        }
        return command;
    }

    /**
     * 根据脚本内容和完整参数计算当前 Turn 的复用审批作用域。
     *
     * @param arguments 命令参数
     * @return 带类型前缀的 SHA-256 审批作用域
     */
    private String scriptApprovalScope(List<String> arguments) {
        Path script = pathGuard.resolveExistingFile(arguments.get(1));
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            try (InputStream input = Files.newInputStream(script)) {
                byte[] buffer = new byte[8192];
                int read;
                while ((read = input.read(buffer)) >= 0) {
                    digest.update(buffer, 0, read);
                }
            }
            for (String argument : arguments) {
                digest.update((byte) 0);
                digest.update(argument.getBytes(StandardCharsets.UTF_8));
            }
            return "SHELL_SCRIPT:" + java.util.HexFormat.of().formatHex(digest.digest());
        } catch (IOException error) {
            throw new WorkspaceToolException(
                    "WORKSPACE_IO_FAILED", "Script could not be hashed before approval.", true, error);
        } catch (NoSuchAlgorithmException error) {
            throw new AgentRuntimeException("SHA-256 is unavailable for script approval.", error);
        }
    }

    private int requestedTimeout(ToolCallParam param) {
        Object value = param == null ? null : param.getInput().get("timeout");
        if (value == null) {
            return defaultTimeoutSeconds;
        }
        if (!(value instanceof Number number)) {
            throw new AgentRuntimeException("Shell timeout must be an integer number of seconds.");
        }
        int timeout = number.intValue();
        if (timeout <= 0 || timeout > maxTimeoutSeconds) {
            throw new AgentRuntimeException("Shell timeout is outside the configured range.");
        }
        return timeout;
    }

    private void validateCharset(ToolCallParam param) {
        Object value = param == null ? null : param.getInput().get("charset");
        if (value != null && (!(value instanceof String charset) || !"UTF-8".equalsIgnoreCase(charset.trim()))) {
            throw new AgentRuntimeException("Shell charset override is limited to UTF-8.");
        }
    }

    private CompletableFuture<BoundedOutput> readBounded(InputStream input) {
        try {
            return CompletableFuture.supplyAsync(() -> {
                ByteArrayOutputStream retained = new ByteArrayOutputStream(Math.min(maxOutputSize, 8192));
                boolean truncated = false;
                byte[] buffer = new byte[8192];
                try (input) {
                    int read;
                    while ((read = input.read(buffer)) >= 0) {
                        int remaining = maxOutputSize - retained.size();
                        if (remaining > 0) {
                            retained.write(buffer, 0, Math.min(read, remaining));
                        }
                        if (read > remaining) {
                            truncated = true;
                        }
                    }
                } catch (IOException error) {
                    throw new WorkspaceToolException("SHELL_OUTPUT_FAILED",
                            "Shell output stream could not be read.", true, error);
                }
                return new BoundedOutput(retained.toString(StandardCharsets.UTF_8), truncated);
            }, outputExecutor);
        } catch (RejectedExecutionException error) {
            throw new WorkspaceToolException("SHELL_CONCURRENCY_LIMIT",
                    "Shell output collector is at capacity.", true, error);
        }
    }

    private BoundedOutput awaitOutput(CompletableFuture<BoundedOutput> future) {
        try {
            return future.get(2, TimeUnit.SECONDS);
        } catch (InterruptedException error) {
            Thread.currentThread().interrupt();
            throw new WorkspaceToolException("SHELL_INTERRUPTED",
                    "Shell output collection was interrupted.", true, error);
        } catch (ExecutionException | java.util.concurrent.TimeoutException error) {
            future.cancel(true);
            Throwable cause = error instanceof ExecutionException && error.getCause() != null
                    ? error.getCause() : error;
            if (cause instanceof WorkspaceToolException typed) {
                throw typed;
            }
            throw new WorkspaceToolException("SHELL_OUTPUT_FAILED",
                    "Shell output could not be collected.", true, cause);
        }
    }

    private boolean waitForProcess(Process process,
                                   int timeoutSeconds,
                                   Set<ProcessHandle> observedDescendants) throws InterruptedException {
        long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds);
        while (process.isAlive()) {
            observedDescendants.addAll(process.toHandle().descendants().toList());
            long remainingMillis = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime());
            if (remainingMillis <= 0) {
                return false;
            }
            process.waitFor(Math.max(1, Math.min(remainingMillis, 10)), TimeUnit.MILLISECONDS);
        }
        observedDescendants.addAll(process.toHandle().descendants().toList());
        return true;
    }

    private void terminateProcessTree(Process process,
                                      Set<ProcessHandle> observedDescendants,
                                      long processGroupId) {
        processGroupSupport.terminate(processGroupId);
        List<ProcessHandle> descendants = new ArrayList<>(observedDescendants);
        descendants.addAll(process.toHandle().descendants().toList());
        for (int index = descendants.size() - 1; index >= 0; index--) {
            descendants.get(index).destroy();
        }
        process.destroy();
        try {
            if (!process.waitFor(500, TimeUnit.MILLISECONDS)) {
                for (int index = descendants.size() - 1; index >= 0; index--) {
                    ProcessHandle descendant = descendants.get(index);
                    if (descendant.isAlive()) {
                        descendant.destroyForcibly();
                    }
                }
                process.destroyForcibly();
                process.waitFor(500, TimeUnit.MILLISECONDS);
            }
        } catch (InterruptedException error) {
            for (ProcessHandle descendant : descendants) {
                if (descendant.isAlive()) {
                    descendant.destroyForcibly();
                }
            }
            process.destroyForcibly();
            Thread.currentThread().interrupt();
        }
    }

    private void terminateObservedDescendants(Set<ProcessHandle> observedDescendants) {
        Set<ProcessHandle> expanded = new LinkedHashSet<>(observedDescendants);
        for (ProcessHandle descendant : observedDescendants) {
            if (descendant.isAlive()) {
                expanded.addAll(descendant.descendants().toList());
            }
        }
        for (ProcessHandle descendant : expanded) {
            if (descendant.isAlive()) {
                descendant.destroy();
            }
        }
        long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(300);
        while (expanded.stream().anyMatch(ProcessHandle::isAlive)
                && System.nanoTime() < deadline) {
            try {
                Thread.sleep(10);
            } catch (InterruptedException error) {
                Thread.currentThread().interrupt();
                break;
            }
        }
        for (ProcessHandle descendant : expanded) {
            if (descendant.isAlive()) {
                descendant.destroyForcibly();
            }
        }
    }

    private static void terminateProcessTreeNow(Process process, Set<ProcessHandle> observedDescendants) {
        if (process == null) {
            return;
        }
        List<ProcessHandle> descendants = new ArrayList<>(observedDescendants);
        descendants.addAll(process.toHandle().descendants().toList());
        for (int index = descendants.size() - 1; index >= 0; index--) {
            ProcessHandle descendant = descendants.get(index);
            if (descendant.isAlive()) {
                descendant.destroyForcibly();
            }
        }
        if (process.isAlive()) {
            process.destroyForcibly();
        }
    }

    private ToolResultBlock result(int returnCode,
                                   BoundedOutput stdout,
                                   BoundedOutput stderr,
                                   String errorCode,
                                   String errorMessage,
                                   boolean retryable,
                                   long durationMillis) {
        String error = errorCode == null ? "" : "<error><code>" + errorCode + "</code><message>"
                + xml(errorMessage) + "</message><retryable>" + retryable + "</retryable></error>";
        String warning = errorCode == null && (stdout.truncated() || stderr.truncated())
                ? "<warning><code>OUTPUT_TRUNCATED</code><message>Shell output exceeded the configured limit.</message>"
                + "<retryable>false</retryable></warning>" : "";
        String formatted = "<returncode>" + returnCode + "</returncode>"
                + "<stdout truncated=\"" + stdout.truncated() + "\">" + xml(sanitizeOutput(stdout.text())) + "</stdout>"
                + "<stderr truncated=\"" + stderr.truncated() + "\">" + xml(sanitizeOutput(stderr.text())) + "</stderr>"
                + "<duration_ms>" + durationMillis + "</duration_ms>" + error + warning;
        return ToolResultBlock.text(formatted);
    }

    private String xml(String value) {
        if (value == null) {
            return "";
        }
        return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
    }

    private String sanitizeOutput(String value) {
        if (value == null || value.isEmpty()) {
            return "";
        }
        return value.replace(pathGuard.root().toString(), ".");
    }

    private static int seconds(Duration duration, String name) {
        if (duration == null || duration.isZero() || duration.isNegative() || duration.getSeconds() > Integer.MAX_VALUE) {
            throw new AgentRuntimeException(name + " must be a positive whole-second duration.");
        }
        return Math.toIntExact(duration.getSeconds());
    }

    private static Set<String> validateAllowedCommands(Set<String> configured) {
        if (configured == null || configured.isEmpty()) {
            throw new AgentRuntimeException("Shell command whitelist must not be empty.");
        }
        Set<String> normalized = new LinkedHashSet<>();
        for (String command : configured) {
            if (command == null || command.isBlank() || !DEFAULT_ALLOWED_COMMANDS.contains(command.trim())) {
                throw new AgentRuntimeException("Shell command is outside the fixed whitelist.");
            }
            normalized.add(command.trim());
        }
        return Set.copyOf(normalized);
    }

    private static ExecutorService createOutputExecutor(int maxConcurrency) {
        int threads = Math.multiplyExact(maxConcurrency, 2);
        ThreadFactory threadFactory = runnable -> {
            Thread thread = new Thread(runnable,
                    "easyagents-shell-output-" + OUTPUT_THREAD_SEQUENCE.incrementAndGet());
            thread.setDaemon(true);
            return thread;
        };
        return new ThreadPoolExecutor(
                threads,
                threads,
                0L,
                TimeUnit.MILLISECONDS,
                new ArrayBlockingQueue<>(Math.max(threads * 2, 4)),
                threadFactory,
                new ThreadPoolExecutor.AbortPolicy());
    }

    /**
     * 有界输出。
     *
     * @param text 保留文本
     * @param truncated 是否截断
     */
    private record BoundedOutput(String text, boolean truncated) {
    }

    /**
     * 活跃命令及其进程组清理上下文。
     *
     * @param observedDescendants 执行期观察到的后代
     * @param processGroupSupport Linux 进程组支持
     * @param processGroupId Linux PGID，降级模式为 -1
     */
    private record ActiveProcess(Set<ProcessHandle> observedDescendants,
                                 ShellProcessGroupSupport processGroupSupport,
                                 long processGroupId) {
    }
}
