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

import com.easyagents.agent.runtime.AgentRuntimeException;

import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;

/**
 * 白名单命令的命令级选项与路径参数校验器。
 *
 * <p>入口命令白名单不足以阻止工具通过合法命令的扩展选项启动子进程或访问第二路径。
 * 该校验器集中关闭这些二级执行入口，并对已知文件参数执行工作区路径保护。
 */
final class ShellCommandOptionValidator {

    private static final Pattern AWK_CODE_EXECUTION = Pattern.compile(
            "(?is).*(\\bsystem\\s*\\(|\\bgetline\\b|\\bENVIRON\\b|@load\\b|\\bextension\\s*\\().*");
    private static final Pattern SED_SIDE_EFFECT_COMMAND = Pattern.compile(
            "(?is).*(^|[;{}\\n])\\s*(?:(?:\\d+|\\$|/([^/\\n\\\\]|\\\\.)*/)(?:\\s*,\\s*"
                    + "(?:\\d+|\\$|/([^/\\n\\\\]|\\\\.)*/))?\\s*)?[eErRwW](?:\\s|$).*");
    private static final Pattern JQ_EXTERNAL_INPUT = Pattern.compile(
            "(?is).*(\\b(import|include|module|input|inputs|env)\\b|\\$ENV\\b).*");

    private final WorkspacePathGuard pathGuard;

    /**
     * 创建命令选项校验器。
     *
     * @param pathGuard 工作区路径保护器
     */
    ShellCommandOptionValidator(WorkspacePathGuard pathGuard) {
        this.pathGuard = pathGuard;
    }

    /**
     * 校验命令专属的子执行入口、文件选项和路径操作数。
     *
     * @param arguments 已完成安全分词的命令参数
     */
    void validate(List<String> arguments) {
        String command = arguments.get(0);
        switch (command) {
            case "ls" -> validateList(arguments);
            case "awk" -> validateAwk(arguments);
            case "sed" -> validateSed(arguments);
            case "rg" -> validateRipgrep(arguments);
            case "grep" -> validateGrep(arguments);
            case "jq" -> validateJq(arguments);
            case "sort" -> validateSort(arguments);
            case "uniq" -> validateUniq(arguments);
            case "diff", "cmp" -> validateExistingOperands(arguments);
            case "du" -> validateDiskUsage(arguments);
            case "tree" -> validateTree(arguments);
            case "cp" -> validateCopy(arguments);
            case "mkdir", "touch", "mv", "rm" -> validateAllOperands(arguments);
            case "wc" -> validateWordCount(arguments);
            case "file" -> validateFile(arguments);
            case "sha256sum", "shasum" -> validateChecksum(arguments);
            case "tail" -> validateTail(arguments);
            case "pandoc" -> validatePandoc(arguments);
            case "soffice" -> validateSoffice(arguments);
            case "pdftoppm" -> validatePdfToPpm(arguments);
            case "pdfinfo" -> validatePdfInfo(arguments);
            case "pdftotext" -> validatePdfToText(arguments);
            case "pdfimages" -> validatePdfImages(arguments);
            case "qpdf" -> validateQpdf(arguments);
            case "cat", "head", "cut", "stat" ->
                    validateExistingOperands(arguments);
            default -> {
                // pwd/date/tr/basename/dirname/python/python3/node 没有额外的子执行选项；脚本入口由外层单独校验。
            }
        }
    }

    private void validateDiskUsage(List<String> arguments) {
        rejectOptions(arguments, Set.of(
                "--files0-from", "--exclude-from", "-L", "--dereference", "-H", "-D",
                "--dereference-args"));
        validateExistingOperands(arguments);
    }

    private void validateTree(List<String> arguments) {
        rejectOptions(arguments, Set.of(
                "-l", "--follow-links", "-o", "--fromfile", "--gitfile", "--info"));
        validateExistingOperands(arguments);
    }

    private void validatePandoc(List<String> arguments) {
        rejectOptions(arguments, Set.of(
                "-F", "--filter", "-L", "--lua-filter", "-d", "--defaults", "--data-dir",
                "--resource-path", "--extract-media", "--pdf-engine", "--pdf-engine-opt"));
        for (String argument : arguments.subList(1, arguments.size())) {
            if (isAttachedShortOption(argument, "-o")) {
                throw new AgentRuntimeException(
                        "pandoc attached output paths are not allowed; use -o followed by a workspace path.");
            }
        }
        validateFollowingFileOptions(arguments, Set.of(
                "--template", "--metadata-file", "--reference-doc", "--syntax-definition",
                "--include-in-header", "--include-before-body", "--include-after-body",
                "--bibliography", "--csl", "--citation-abbreviations"), false);
        validateFollowingFileOptions(arguments, Set.of("-o", "--output", "--log"), true);
        validateExistingOperands(arguments);
    }

    private void validateSoffice(List<String> arguments) {
        String format = null;
        String outputDirectory = null;
        List<String> inputs = new ArrayList<>();
        for (int index = 1; index < arguments.size(); index++) {
            String argument = arguments.get(index);
            String option = optionName(argument);
            if (Set.of("--headless", "--nologo", "--nodefault", "--nolockcheck", "--norestore")
                    .contains(option)) {
                continue;
            }
            if ("--convert-to".equals(option)) {
                format = optionValue(arguments, index);
                if (!argument.contains("=")) {
                    index++;
                }
                continue;
            }
            if ("--outdir".equals(option)) {
                outputDirectory = optionValue(arguments, index);
                if (!argument.contains("=")) {
                    index++;
                }
                continue;
            }
            if (argument.startsWith("-")) {
                throw new AgentRuntimeException("soffice option is not allowed: " + option);
            }
            inputs.add(argument);
        }
        if (format == null || outputDirectory == null || inputs.isEmpty()) {
            throw new AgentRuntimeException(
                    "soffice requires --convert-to, --outdir, and at least one workspace input file.");
        }
        String normalizedFormat = format.split(":", 2)[0].toLowerCase(java.util.Locale.ROOT);
        if (!Set.of("pdf", "docx", "xlsx", "pptx", "odt", "ods", "odp", "html", "txt", "csv")
                .contains(normalizedFormat)) {
            throw new AgentRuntimeException("soffice output format is not allowed: " + normalizedFormat);
        }
        pathGuard.resolveExistingDirectory(outputDirectory);
        inputs.forEach(pathGuard::resolveExistingFile);
    }

    private void validatePdfToPpm(List<String> arguments) {
        List<String> operands = pdfOperands(arguments, Set.of(
                "-f", "-l", "-r", "-rx", "-ry", "-scale-to", "-scale-to-x", "-scale-to-y",
                "-x", "-y", "-W", "-H", "-sz"));
        if (operands.size() != 2) {
            throw new AgentRuntimeException("pdftoppm requires one PDF input and one output prefix.");
        }
        pathGuard.resolveExistingFile(operands.get(0));
        pathGuard.resolveCommandPath(operands.get(1));
    }

    private void validatePdfInfo(List<String> arguments) {
        rejectOptions(arguments, Set.of("-opw", "-upw"));
        List<String> operands = pdfOperands(arguments, Set.of("-f", "-l"));
        if (operands.size() != 1) {
            throw new AgentRuntimeException("pdfinfo requires exactly one workspace PDF input.");
        }
        pathGuard.resolveExistingFile(operands.get(0));
    }

    private void validatePdfToText(List<String> arguments) {
        rejectOptions(arguments, Set.of("-opw", "-upw"));
        List<String> operands = pdfOperands(arguments, Set.of(
                "-f", "-l", "-r", "-x", "-y", "-W", "-H", "-enc", "-eol"));
        if (operands.size() < 1 || operands.size() > 2) {
            throw new AgentRuntimeException("pdftotext requires one PDF input and an optional output file.");
        }
        pathGuard.resolveExistingFile(operands.get(0));
        if (operands.size() == 2 && !"-".equals(operands.get(1))) {
            pathGuard.resolveCommandPath(operands.get(1));
        }
    }

    private void validatePdfImages(List<String> arguments) {
        rejectOptions(arguments, Set.of("-opw", "-upw"));
        List<String> operands = pdfOperands(arguments, Set.of("-f", "-l", "-jpegopt"));
        if (operands.size() != 2) {
            throw new AgentRuntimeException("pdfimages requires one PDF input and one output prefix.");
        }
        pathGuard.resolveExistingFile(operands.get(0));
        pathGuard.resolveCommandPath(operands.get(1));
    }

    private void validateQpdf(List<String> arguments) {
        rejectOptions(arguments, Set.of(
                "--replace-input", "--password-file", "--encryption-file-password",
                "--copy-attachments-from", "--overlay", "--underlay", "--json-input",
                "--job-json-file"));
        for (String argument : arguments.subList(1, arguments.size())) {
            if (argument.startsWith("@")) {
                throw new AgentRuntimeException("qpdf response files are not allowed.");
            }
        }
        validateExistingOperands(arguments);
    }

    private List<String> pdfOperands(List<String> arguments, Set<String> optionsWithValues) {
        List<String> result = new ArrayList<>();
        boolean endOfOptions = false;
        for (int index = 1; index < arguments.size(); index++) {
            String argument = arguments.get(index);
            if (!endOfOptions && "--".equals(argument)) {
                endOfOptions = true;
                continue;
            }
            if (!endOfOptions && argument.startsWith("-")) {
                String option = optionName(argument);
                if (optionsWithValues.contains(option) && !argument.contains("=")) {
                    if (++index >= arguments.size()) {
                        throw new AgentRuntimeException("PDF command option requires a value: " + option);
                    }
                }
                continue;
            }
            result.add(argument);
        }
        return result;
    }

    private void validateFollowingFileOptions(List<String> arguments,
                                              Set<String> fileOptions,
                                              boolean writable) {
        for (int index = 1; index < arguments.size(); index++) {
            String argument = arguments.get(index);
            String option = optionName(argument);
            if (!fileOptions.contains(option)) {
                continue;
            }
            String path = optionValue(arguments, index);
            if (writable) {
                pathGuard.resolveCommandPath(path);
            } else {
                pathGuard.resolveExistingFile(path);
            }
            if (!argument.contains("=")) {
                index++;
            }
        }
    }

    private void validateAwk(List<String> arguments) {
        rejectOptions(arguments, Set.of(
                "-f", "--file", "-e", "--exec", "-i", "--include", "-l", "--load", "-W",
                "-d", "--dump-variables", "-o", "--pretty-print", "-p", "--profile"));
        for (String argument : operands(arguments)) {
            if (AWK_CODE_EXECUTION.matcher(argument).matches()) {
                throw new AgentRuntimeException("awk sub-process and external input features are not allowed.");
            }
        }
        validateExistingOperandsSkippingFirst(arguments);
    }

    private void validateSed(List<String> arguments) {
        List<String> expressions = new ArrayList<>();
        List<String> files = new ArrayList<>();
        boolean endOfOptions = false;
        for (int index = 1; index < arguments.size(); index++) {
            String argument = arguments.get(index);
            if (!endOfOptions && "--".equals(argument)) {
                endOfOptions = true;
                continue;
            }
            if (!endOfOptions && (argument.equals("-i") || argument.startsWith("-i")
                    || argument.startsWith("--in-place") || argument.equals("--follow-symlinks")
                    || argument.startsWith("-f") || argument.startsWith("--file"))) {
                throw new AgentRuntimeException("sed in-place, external script, and symlink-following options are not allowed.");
            }
            if (!endOfOptions && ("-e".equals(argument) || "--expression".equals(argument))) {
                if (++index >= arguments.size()) {
                    throw new AgentRuntimeException("sed expression option requires a value.");
                }
                expressions.add(arguments.get(index));
                continue;
            }
            if (!endOfOptions && argument.startsWith("--expression=")) {
                expressions.add(argument.substring("--expression=".length()));
                continue;
            }
            if (!endOfOptions && argument.startsWith("-") && !isSafeSedFlag(argument)) {
                throw new AgentRuntimeException("sed option is not allowed.");
            }
            if (!endOfOptions && argument.startsWith("-")) {
                continue;
            }
            if (expressions.isEmpty()) {
                expressions.add(argument);
            } else {
                files.add(argument);
            }
        }
        if (expressions.isEmpty()) {
            throw new AgentRuntimeException("sed requires an inline expression.");
        }
        for (String expression : expressions) {
            if (SED_SIDE_EFFECT_COMMAND.matcher(expression).matches()
                    || containsUnsafeSubstitutionFlag(expression)) {
                throw new AgentRuntimeException("sed execute/read/write commands are not allowed.");
            }
        }
        for (String file : files) {
            validateExistingPath(file);
        }
    }

    private void validateRipgrep(List<String> arguments) {
        for (int index = 1; index < arguments.size(); index++) {
            String argument = arguments.get(index);
            String option = optionName(argument);
            if (Set.of("--pre", "--pre-glob", "--hostname-bin", "--search-zip").contains(option)
                    || isShortOptionPresent(argument, 'z') || "--follow".equals(option)
                    || isShortOptionPresent(argument, 'L')) {
                throw new AgentRuntimeException(
                        "rg preprocessors, archive search, and symlink-following options are not allowed.");
            }
            if (Set.of("-f", "--file", "--ignore-file").contains(option)
                    || isAttachedShortOption(argument, "-f")) {
                String path = attachedOrFollowingValue(arguments, index, "-f");
                validateExistingPath(path);
                if (!argument.contains("=") && !isAttachedShortOption(argument, "-f")) {
                    index++;
                }
            }
        }
        validateExistingOperands(arguments);
    }

    private void validateGrep(List<String> arguments) {
        for (int index = 1; index < arguments.size(); index++) {
            String argument = arguments.get(index);
            String option = optionName(argument);
            if (isShortOptionPresent(argument, 'R') || "--dereference-recursive".equals(option)) {
                throw new AgentRuntimeException("grep symlink-following recursion is not allowed.");
            }
            if (Set.of("-f", "--file", "--exclude-from").contains(option)
                    || isAttachedShortOption(argument, "-f")) {
                String path = attachedOrFollowingValue(arguments, index, "-f");
                validateExistingPath(path);
                if (!argument.contains("=") && !isAttachedShortOption(argument, "-f")) {
                    index++;
                }
            }
        }
        validateExistingOperands(arguments);
    }

    private void validateJq(List<String> arguments) {
        rejectOptions(arguments, Set.of("-f", "--from-file", "-L", "--library-path", "--run-tests"));
        for (String operand : operands(arguments)) {
            if (JQ_EXTERNAL_INPUT.matcher(operand).matches()) {
                throw new AgentRuntimeException("jq module, environment, and external input functions are not allowed.");
            }
        }
        for (int index = 1; index < arguments.size(); index++) {
            String option = optionName(arguments.get(index));
            if (Set.of("--argfile", "--slurpfile", "--rawfile").contains(option)) {
                if (index + 2 >= arguments.size()) {
                    throw new AgentRuntimeException("jq file option requires a variable name and workspace file.");
                }
                validateExistingPath(arguments.get(index + 2));
                index += 2;
            }
        }
        validateExistingOperandsSkippingFirst(arguments);
    }

    private void validateSort(List<String> arguments) {
        rejectOptions(arguments, Set.of("-o", "--output", "--compress-program", "-T", "--temporary-directory"));
        for (int index = 1; index < arguments.size(); index++) {
            String option = optionName(arguments.get(index));
            if ("--random-source".equals(option)) {
                String path = optionValue(arguments, index);
                validateExistingPath(path);
                if (!arguments.get(index).contains("=")) {
                    index++;
                }
            }
        }
        validateExistingOperands(arguments);
    }

    private void validateUniq(List<String> arguments) {
        List<String> operands = operands(arguments);
        if (operands.size() > 1) {
            throw new AgentRuntimeException("uniq output-file operand is not allowed; use write_text_file instead.");
        }
        if (!operands.isEmpty()) {
            validateExistingPath(operands.get(0));
        }
    }

    private void validateCopy(List<String> arguments) {
        for (String argument : arguments) {
            if (isShortOptionPresent(argument, 'L') || isShortOptionPresent(argument, 'H')
                    || isShortOptionPresent(argument, 'l') || isShortOptionPresent(argument, 's')
                    || Set.of("--dereference", "--link", "--symbolic-link")
                    .contains(optionName(argument))) {
                throw new AgentRuntimeException("cp link creation and symlink-following options are not allowed.");
            }
        }
        validateAllOperands(arguments);
    }

    private void validateTail(List<String> arguments) {
        for (String argument : arguments.subList(1, arguments.size())) {
            String option = optionName(argument);
            if (isShortOptionPresent(argument, 'f') || isShortOptionPresent(argument, 'F')
                    || "--follow".equals(option)) {
                throw new AgentRuntimeException("tail follow mode is not allowed.");
            }
        }
        validateExistingOperands(arguments);
    }

    private void validateList(List<String> arguments) {
        for (String argument : arguments.subList(1, arguments.size())) {
            String option = optionName(argument);
            if (isShortOptionPresent(argument, 'L') || "--dereference".equals(option)
                    || "--dereference-command-line".equals(option)
                    || "--dereference-command-line-symlink-to-dir".equals(option)) {
                throw new AgentRuntimeException("ls symlink-following options are not allowed.");
            }
        }
        validateExistingOperands(arguments);
    }

    private void validateWordCount(List<String> arguments) {
        rejectOptions(arguments, Set.of("--files0-from"));
        validateExistingOperands(arguments);
    }

    private void validateFile(List<String> arguments) {
        rejectOptions(arguments, Set.of("-f", "--files-from", "-C", "--compile"));
        validateExistingOperands(arguments);
    }

    private void validateChecksum(List<String> arguments) {
        rejectOptions(arguments, Set.of("-c", "--check"));
        validateExistingOperands(arguments);
    }

    private void validateAllOperands(List<String> arguments) {
        for (String operand : operands(arguments)) {
            pathGuard.resolveCommandPath(operand);
        }
    }

    private void validateExistingOperands(List<String> arguments) {
        for (String operand : operands(arguments)) {
            validateExistingPathIfPresent(operand);
        }
    }

    private void validateExistingOperandsSkippingFirst(List<String> arguments) {
        List<String> operands = operands(arguments);
        for (int index = 1; index < operands.size(); index++) {
            validateExistingPathIfPresent(operands.get(index));
        }
    }

    private void validateExistingPathIfPresent(String value) {
        java.nio.file.Path candidate = pathGuard.root().resolve(value).normalize();
        if (java.nio.file.Files.exists(candidate, java.nio.file.LinkOption.NOFOLLOW_LINKS)) {
            pathGuard.resolveExistingEntry(value);
        }
    }

    private void validateExistingPath(String value) {
        pathGuard.resolveExistingFile(value);
    }

    private void rejectOptions(List<String> arguments, Set<String> rejected) {
        for (String argument : arguments.subList(1, arguments.size())) {
            String option = optionName(argument);
            if (rejected.contains(option) || rejected.stream()
                    .filter(value -> value.startsWith("-") && !value.startsWith("--") && value.length() == 2)
                    .anyMatch(value -> isAttachedShortOption(argument, value))) {
                throw new AgentRuntimeException("Command option is not allowed: " + option);
            }
        }
    }

    private List<String> operands(List<String> arguments) {
        List<String> operands = new ArrayList<>();
        boolean endOfOptions = false;
        for (int index = 1; index < arguments.size(); index++) {
            String argument = arguments.get(index);
            if (!endOfOptions && "--".equals(argument)) {
                endOfOptions = true;
                continue;
            }
            if (!endOfOptions && argument.startsWith("-")) {
                continue;
            }
            operands.add(argument);
        }
        return operands;
    }

    private String optionName(String argument) {
        int equals = argument.indexOf('=');
        return equals < 0 ? argument : argument.substring(0, equals);
    }

    private String optionValue(List<String> arguments, int optionIndex) {
        String argument = arguments.get(optionIndex);
        int equals = argument.indexOf('=');
        if (equals >= 0) {
            String value = argument.substring(equals + 1);
            if (value.isBlank()) {
                throw new AgentRuntimeException("Command file option requires a value.");
            }
            return value;
        }
        if (optionIndex + 1 >= arguments.size()) {
            throw new AgentRuntimeException("Command file option requires a value.");
        }
        return arguments.get(optionIndex + 1);
    }

    private String attachedOrFollowingValue(List<String> arguments, int optionIndex, String shortOption) {
        String argument = arguments.get(optionIndex);
        if (isAttachedShortOption(argument, shortOption)) {
            return argument.substring(shortOption.length());
        }
        return optionValue(arguments, optionIndex);
    }

    private boolean isAttachedShortOption(String argument, String option) {
        return argument.startsWith(option) && argument.length() > option.length()
                && !argument.startsWith("--");
    }

    private boolean isShortOptionPresent(String argument, char option) {
        return argument.startsWith("-") && !argument.startsWith("--")
                && argument.length() > 1 && argument.substring(1).indexOf(option) >= 0;
    }

    private boolean containsUnsafeSubstitutionFlag(String expression) {
        for (int index = 0; index + 1 < expression.length(); index++) {
            if (expression.charAt(index) != 's' || Character.isLetterOrDigit(expression.charAt(index + 1))) {
                continue;
            }
            char delimiter = expression.charAt(index + 1);
            int patternEnd = findUnescaped(expression, delimiter, index + 2);
            if (patternEnd < 0) {
                continue;
            }
            int replacementEnd = findUnescaped(expression, delimiter, patternEnd + 1);
            if (replacementEnd < 0) {
                continue;
            }
            for (int flagIndex = replacementEnd + 1; flagIndex < expression.length(); flagIndex++) {
                char flag = expression.charAt(flagIndex);
                if (flag == ';' || flag == '\n' || flag == '}') {
                    break;
                }
                if (flag == 'e' || flag == 'w' || flag == 'W') {
                    return true;
                }
                if (!Character.isWhitespace(flag) && !Character.isDigit(flag)
                        && "gIpMm".indexOf(flag) < 0) {
                    break;
                }
            }
        }
        return false;
    }

    private int findUnescaped(String value, char delimiter, int start) {
        boolean escaped = false;
        for (int index = start; index < value.length(); index++) {
            char current = value.charAt(index);
            if (escaped) {
                escaped = false;
            } else if (current == '\\') {
                escaped = true;
            } else if (current == delimiter) {
                return index;
            }
        }
        return -1;
    }

    private boolean isSafeSedFlag(String argument) {
        if (Set.of("-n", "--quiet", "--silent", "-E", "-r", "--regexp-extended", "--sandbox")
                .contains(argument)) {
            return true;
        }
        return argument.matches("-[nEr]+");
    }
}
