/*
 *  Licensed to the Apache Software Foundation (ASF) under one
 *  or more contributor license agreements.  See the NOTICE file
 *  distributed with this work for additional information
 *  regarding copyright ownership.  The ASF licenses this file
 *  to you under the Apache License, Version 2.0 (the
 *  "License"); you may not use this file except in compliance
 *  with the License.  You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing,
 *  software distributed under the License is distributed on an
 *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 *  KIND, either express or implied.  See the License for the
 *  specific language governing permissions and limitations
 *  under the License.
 *
 * This file has some code copied from its parent's class: PosixCommands from JLine3.
 * This is in some cases due to bug fixes not yet merged or released in JLine3,
 * and in other cases where existing extensibility points were insufficient.
 * Additional PRs related to extensibility might still be needed.
 * The intent would be to delete all copied code (and maybe most of this file)
 * if/when the relevant PRs have been merged into JLine3.
 * The copied code is available under the BSD License.
 */
package org.apache.groovy.groovysh.jline;

import org.codehaus.groovy.runtime.ArrayGroovyMethods;
import org.jline.builtins.ConfigurationPath;
import org.jline.builtins.Less;
import org.jline.builtins.Options;
import org.jline.builtins.Source;
import org.jline.terminal.Terminal;
import org.jline.utils.AttributedString;
import org.jline.utils.AttributedStringBuilder;
import org.jline.utils.InputStreamReader;
import org.jline.utils.OSUtils;
import org.jline.utils.RegexTimeoutException;
import org.jline.utils.SafeRegex;

import static org.jline.builtins.PosixCommands.*;

import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintStream;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.MalformedURLException;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.nio.file.Paths;
import java.nio.file.attribute.FileTime;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Deque;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeMap;
import java.util.function.Consumer;
import java.util.function.IntBinaryOperator;
import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * Groovy-aware implementations of groovysh POSIX-style commands.
 */
public class GroovyPosixCommands {

    private static Options parseOptions(Context context, String[] usage, Object[] argv) {
        String[] tokens = Arrays.stream(argv).map(String::valueOf).toArray(String[]::new);
        String[] args = tokens.length > 0 ? Arrays.copyOfRange(tokens, 1, tokens.length) : tokens;
        return Options.compile(usage).parse(args);
    }

    /**
     * Writes the supplied files, variables, or standard input to the current output stream.
     *
     * @param context command execution context
     * @param argv command name and arguments
     * @throws Exception if an input cannot be resolved or read
     */
    public static void cat(Context context, Object[] argv) throws Exception {
        final String[] usage = {
            "/cat - concatenate and print FILES or VARIABLES",
            "Usage: /cat [OPTIONS] [FILES] [VARIABLES]",
            "  -? --help                show help",
            "  -n                       number the output lines, starting at 1"
        };
        Options opt = parseOptions(context, usage, argv);

        List<String> args = opt.args();
        if (args.isEmpty()) {
            args = Collections.singletonList("-");
        }
        List<NamedInputStream> sources = getSources(context, argv, args);
        for (NamedInputStream nis : sources) {
            InputStream is = nis.getInputStream();
            doCat(context, new BufferedReader(new InputStreamReader(is)), opt.isSet("n"));
        }
    }

    private static void doCat(Context context, BufferedReader reader, boolean numbered) throws IOException {
        String line;
        int lineno = 1;
        try (reader) {
            while ((line = reader.readLine()) != null) {
                if (numbered) {
                    context.out().printf("%6d\t%s%n", lineno++, line);
                } else {
                    context.out().println(line);
                }
            }
        }
    }

    /**
     * Counts lines, words, characters, or bytes for the selected inputs.
     *
     * @param context command execution context
     * @param argv command name and arguments
     * @throws Exception if an input cannot be processed
     */
    public static void wc(Context context, Object[] argv) throws Exception {
        final String[] usage = {
            "/wc - word, line, character, and byte count",
            "Usage: /wc [OPTIONS] [FILES]",
            "  -? --help                    Show help",
            "  -l --lines                   Print line counts",
            "  -c --bytes                   Print byte counts",
            "  -m --chars                   Print character counts",
            "  -w --words                   Print word counts",
            "     --total=WHEN              Print total counts, WHEN=auto|always|never|only",
        };
        Options opt = parseOptions(context, usage, argv);

        List<String> args = opt.args();
        if (args.isEmpty()) {
            args = Collections.singletonList("-");
        }
        List<NamedInputStream> sources = getSources(context, argv, args);

        boolean showLines = opt.isSet("lines");
        boolean showWords = opt.isSet("words");
        boolean showChars = opt.isSet("chars");
        boolean showBytes = opt.isSet("bytes");
        boolean only = false;
        boolean total = sources.size() > 1;
        String totalOpt = opt.isSet("total") ? opt.get("total") : "auto";
        switch (totalOpt) {
            case "always":
            case "yes":
            case "force":
                total = true;
                break;
            case "never":
            case "no":
            case "none":
                total = false;
                break;
            case "only":
                only = true;
                break;
            case "auto":
            case "tty":
            case "if-tty":
                break;
            default:
                throw new IllegalArgumentException("invalid argument '" + totalOpt + "' for '--total'");
        }

        // If no options specified, show all
        if (!showLines && !showWords && !showChars && !showBytes) {
            showLines = showWords = showBytes = true;
        }

        // a lone count needs no column to line up with, and padding it to eight just
        // indents the number away from the name, which is not what wc does
        boolean severalCounts =
            (showLines ? 1 : 0) + (showWords ? 1 : 0) + (showChars ? 1 : 0) + (showBytes ? 1 : 0) > 1;
        String countFormat = severalCounts ? "%8d" : "%d";

        long totalLines = 0, totalWords = 0, totalChars = 0, totalBytes = 0;

        for (NamedInputStream source : sources) {
            long lines = 0, words = 0, chars = 0, bytes = 0;

            try (BufferedReader reader = new BufferedReader(new InputStreamReader(source.getInputStream()))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    lines++;
                    chars += line.length() + 1; // +1 for newline
                    bytes += line.getBytes().length + 1; // +1 for newline

                    // Count words
                    String[] wordArray = line.trim().split("\\s+");
                    if (wordArray.length == 1 && wordArray[0].isEmpty()) {
                        // Empty line
                    } else {
                        words += wordArray.length;
                    }
                }
            }

            totalLines += lines;
            totalWords += words;
            totalChars += chars;
            totalBytes += bytes;

            if (only) continue;
            // Print results for this file
            StringBuilder result = new StringBuilder();
            if (showLines) result.append(String.format(countFormat, lines));
            if (showWords) result.append(String.format(countFormat, words));
            if (showChars) result.append(String.format(countFormat, chars));
            if (showBytes) result.append(String.format(countFormat, bytes));
            result.append(" ").append(stripControlChars(source.getName()));

            context.out().println(result);
            context.out().flush();
        }

        // Print totals if multiple files
        if (total) {
            StringBuilder result = new StringBuilder();
            if (showLines) result.append(String.format(countFormat, totalLines));
            if (showWords) result.append(String.format(countFormat, totalWords));
            if (showChars) result.append(String.format(countFormat, totalChars));
            if (showBytes) result.append(String.format(countFormat, totalBytes));
            result.append(" total");

            context.out().println(result);
        }
    }

    /**
     * Prints the beginning of each requested input.
     *
     * @param context command execution context
     * @param argv command name and arguments
     * @throws Exception if an input cannot be read
     */
    public static void head(Context context, Object[] argv) throws Exception {
        final String[] usage = {
            "/head - display first lines of files or variables",
            "Usage: /head [-n lines | -c bytes] [-q | -v] [file|variable ...]",
            "  -? --help                    Show help",
            "  -n --lines=LINES             Print line counts",
            "  -c --bytes=BYTES             Print byte counts",
            "  -q --quiet                   Never output filename headers",
            "  -v --verbose                 Always output filename headers",
        };
        Options opt = parseOptions(context, usage, argv);

        if (opt.isSet("lines") && opt.isSet("bytes")) {
            throw new IllegalArgumentException("usage: /head [-n # | -c #] [-q | -v] [file|variable ...]");
        }

        if (opt.isSet("quiet") && opt.isSet("verbose")) {
            throw new IllegalArgumentException("usage: /head [-n # | -c #] [-q | -v] [file|variable ...]");
        }

        int nbLines = Integer.MAX_VALUE;
        int nbBytes = Integer.MAX_VALUE;
        if (opt.isSet("lines")) {
            nbLines = opt.getNumber("lines");
        } else if (opt.isSet("bytes")) {
            nbBytes = opt.getNumber("bytes");
        } else {
            nbLines = 10; // default
        }

        List<String> args = opt.args();
        if (args.isEmpty()) {
            args = Collections.singletonList("-");
        }

        boolean first = true;
        List<NamedInputStream> sources = getSources(context, argv, args);
        for (NamedInputStream nis : sources) {
            boolean filenameHeader = sources.size() > 1;
            if (opt.isSet("verbose")) {
                filenameHeader = true;
            } else if (opt.isSet("quiet")) {
                filenameHeader = false;
            }
            if (filenameHeader) {
                if (!first) {
                    context.out().println();
                }
                context.out().println("==> " + stripControlChars(nis.getName()) + " <==");
            }
            doHead(context, nis.getInputStream(), nbLines, nbBytes);
            first = false;
        }
    }

    private static void doHead(Context context, InputStream is, final int nbLines, final int nbBytes) throws IOException {
        if (nbLines != Integer.MAX_VALUE) {
            try (BufferedReader reader = new BufferedReader(new InputStreamReader(is))) {
                String line;
                int count = 0;
                while ((line = reader.readLine()) != null && count < nbLines) {
                    context.out().println(line);
                    count++;
                }
            }
        } else {
            byte[] buffer = new byte[nbBytes];
            int bytesRead = is.read(buffer);
            if (bytesRead > 0) {
                context.out().write(buffer, 0, bytesRead);
            }
            is.close();
        }
    }

    /**
     * Prints the end of each requested input.
     *
     * @param context command execution context
     * @param argv command name and arguments
     * @throws Exception if an input cannot be read
     */
    public static void tail(Context context, Object[] argv) throws Exception {
        final String[] usage = {
            "/tail - display last lines of files or variables",
            "Usage: /tail [-n lines | -c bytes] [-q | -v] [file|variable ...]",
            "  -? --help                    Show help",
            "  -n --lines=LINES             Number of lines to print",
            "  -c --bytes=BYTES             Number of bytes to print",
            "  -q --quiet                   Never output filename headers",
            "  -v --verbose                 Always output filename headers",
        };
        Options opt = parseOptions(context, usage, argv);

        if (opt.isSet("lines") && opt.isSet("bytes")) {
            throw new IllegalArgumentException("usage: /tail [-c # | -n #] [-q | -v] [file|variable ...]");
        }

        if (opt.isSet("quiet") && opt.isSet("verbose")) {
            throw new IllegalArgumentException("usage: /tail [-c # | -n #] [-q | -v] [file|variable ...]");
        }

        int lines = opt.isSet("lines") ? opt.getNumber("lines") : 10;
        int bytes = opt.isSet("bytes") ? opt.getNumber("bytes") : -1;

        List<String> args = opt.args();
        if (args.isEmpty()) {
            args = Collections.singletonList("-");
        }

        List<NamedInputStream> sources = getSources(context, argv, args);
        boolean filenameHeader = sources.size() > 1;
        if (opt.isSet("verbose")) {
            filenameHeader = true;
        } else if (opt.isSet("quiet")) {
            filenameHeader = false;
        }
        for (NamedInputStream nis : sources) {
            if (filenameHeader) {
                context.out().println("==> " + stripControlChars(nis.getName()) + " <==");
            }
            tailInputStream(context, nis.getInputStream(), lines, bytes);
        }
    }

    private static void tailInputStream(Context context, InputStream is, int lines, int bytes) throws IOException {
        // Both branches keep only what is going to be printed. Reading the input into memory
        // first and taking the end of it costs the size of the input, which for the logs this
        // is pointed at is the whole point of asking for the last few lines of them.
        if (bytes > 0) {
            byte[] window = new byte[bytes];
            int held = 0;
            byte[] chunk = new byte[8192];
            int n;
            while ((n = is.read(chunk)) != -1) {
                if (n >= bytes) {
                    System.arraycopy(chunk, n - bytes, window, 0, bytes);
                    held = bytes;
                } else {
                    int kept = Math.min(held, bytes - n);
                    System.arraycopy(window, held - kept, window, 0, kept);
                    System.arraycopy(chunk, 0, window, kept, n);
                    held = kept + n;
                }
            }
            context.out().write(window, 0, held);
        } else {
            Deque<String> tail = new ArrayDeque<>();
            try (BufferedReader reader = new BufferedReader(new java.io.InputStreamReader(is))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    tail.addLast(line);
                    if (tail.size() > lines) {
                        tail.removeFirst();
                    }
                }
            }
            for (String line : tail) {
                context.out().println(line);
            }
        }
    }

    // from jline master (v4), remove once it is released, and we move to that version, and jline PR#1436 is merged
    /**
     * Lists directory entries using the current groovysh working directory.
     *
     * @param context command execution context
     * @param argv command name and arguments
     * @throws Exception if the listing cannot be produced
     */
    public static void ls(Context context, Object[] argv) throws Exception {
        final String[] usage = {
            "/ls - list files",
            "Usage: /ls [OPTIONS] [PATTERNS...]",
            "  -? --help                show help",
            "  -1                       list one entry per line",
            "  -C                       multi-column output",
            "     --color=WHEN          colorize the output, may be `always', `never' or `auto'",
            "  -a                       list entries starting with .",
            "  -F                       append file type indicators",
            "  -m                       comma separated",
            "  -l                       long listing",
            "  -S                       sort by size",
            "  -f                       output is not sorted",
            "  -r                       reverse sort order",
            "  -t                       sort by modification time",
            "  -x                       sort horizontally",
            "  -L                       list referenced file for links",
            "  -h                       print sizes in human readable form"
        };
        Options opt = parseOptions(context, usage, argv);

        Map<String, String> colorMap = getLsColorMap(context);

        String color = opt.isSet("color") ? opt.get("color") : "auto";
        boolean colored = switch (color) {
            case "always", "yes", "force" -> true;
            case "never", "no", "none" -> false;
            case "auto", "tty", "if-tty" -> context.isTty();
            default -> throw new IllegalArgumentException("invalid argument '" + color + "' for '--color'");
        };
        Map<String, String> colors =
            colored ? (colorMap != null ? colorMap : getLsColorMap(DEFAULT_LS_COLORS)) : Collections.emptyMap();

        /**
         * Represents one path selected for listing output.
         */
        class PathEntry implements Comparable<PathEntry> {
            /** Absolute path resolved for the entry. */
            final Path abs;
            /** Path shown to the user, relative to the requested root when possible. */
            final Path path;
            /** File attributes used for sorting and formatting. */
            final Map<String, Object> attributes;

            /**
             * Creates a display entry for a resolved path.
             *
             * @param abs absolute path to inspect
             * @param root root path used to compute relative display names
             */
            public PathEntry(Path abs, Path root) {
                this.abs = abs;
                try {
                    this.path = Files.isSameFile(abs, root)
                        ? Paths.get(".")
                        : abs.startsWith(root) ? root.relativize(abs) : abs;
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
                this.attributes = readAttributes(abs);
            }

            /**
             * Orders entries using the active {@code ls} sort options.
             *
             * @param o entry to compare against
             * @return sort order for this entry
             */
            @Override
            public int compareTo(PathEntry o) {
                int c = doCompare(o);
                return opt.isSet("r") ? -c : c;
            }

            private int doCompare(PathEntry o) {
                if (opt.isSet("f")) {
                    return -1;
                }
                if (opt.isSet("S")) {
                    long s0 = attributes.get("size") != null ? ((Number) attributes.get("size")).longValue() : 0L;
                    long s1 = o.attributes.get("size") != null ? ((Number) o.attributes.get("size")).longValue() : 0L;
                    return s0 > s1 ? -1 : s0 < s1 ? 1 : path.toString().compareTo(o.path.toString());
                }
                if (opt.isSet("t")) {
                    long t0 = attributes.get("lastModifiedTime") != null
                        ? ((FileTime) attributes.get("lastModifiedTime")).toMillis()
                        : 0L;
                    long t1 = o.attributes.get("lastModifiedTime") != null
                        ? ((FileTime) o.attributes.get("lastModifiedTime")).toMillis()
                        : 0L;
                    return t0 > t1 ? -1 : t0 < t1 ? 1 : path.toString().compareTo(o.path.toString());
                }
                return path.toString().compareTo(o.path.toString());
            }

            /**
             * Returns whether this entry should be rendered as a non-directory item.
             *
             * @return {@code true} when the entry is not a directory
             */
            boolean isNotDirectory() {
                return is("isRegularFile") || is("isSymbolicLink") || is("isOther");
            }

            /**
             * Returns whether this entry is a directory.
             *
             * @return {@code true} when the entry is a directory
             */
            boolean isDirectory() {
                return is("isDirectory");
            }

            private boolean is(String attr) {
                Object d = attributes.get(attr);
                return d instanceof Boolean && (Boolean) d;
            }

            /**
             * Formats the entry name for standard listing output.
             *
             * @return formatted entry name
             */
            String display() {
                String type;
                String suffix;
                String link = "";
                if (is("isSymbolicLink")) {
                    type = "sl";
                    suffix = "@";
                    try {
                        Path l = Files.readSymbolicLink(abs);
                        link = " -> " + stripControlChars(l.toString());
                    } catch (IOException e) {
                        // ignore
                    }
                } else if (is("isDirectory")) {
                    type = "dr";
                    suffix = "/";
                } else if (is("isExecutable")) {
                    type = "ex";
                    suffix = "*";
                } else if (is("isOther")) {
                    type = "ot";
                    suffix = "";
                } else {
                    type = "";
                    suffix = "";
                }
                boolean addSuffix = opt.isSet("F");
                return applyStyle(stripControlChars(path.toString()), colors, type) + (addSuffix ? suffix : "") + link;
            }

            /**
             * Formats the entry for long-listing output.
             *
             * @return formatted long-listing entry
             */
            String longDisplay() {
                String username = Objects.toString(attributes.get("owner"), "owner");
                String group = Objects.toString(attributes.get("group"), "group");
                String size = formatSize();

                @SuppressWarnings("unchecked")
                Set<PosixFilePermission> perms = (Set<PosixFilePermission>) attributes.get("permissions");
                String permissions = PosixFilePermissions.toString(
                    perms != null ? perms : EnumSet.noneOf(PosixFilePermission.class));

                String fileType = is("isDirectory") ? "d" : (is("isSymbolicLink") ? "l" : (is("isOther") ? "o" : "-"));
                String linkCount = Objects.toString(attributes.get("nlink"), "1");
                String modTime = toString((FileTime) attributes.get("lastModifiedTime"));
                String fileName = display();

                return String.format(
                    "%s%s %3s %-8.8s %-8.8s %8s %s %s",
                    fileType, permissions, linkCount, username, group, size, modTime, fileName);
            }

            private String formatSize() {
                Number length = (Number) attributes.get("size");
                if (length == null) {
                    length = 0L;
                }
                if (opt.isSet("h")) {
                    long bytes = length.longValue();
                    if (bytes < SIZE_THRESHOLD) {
                        return bytes + "B";
                    }
                    double size = bytes;
                    int unitIndex = 0;
                    while (size >= SIZE_THRESHOLD && unitIndex < SIZE_UNITS.length - 1) {
                        size /= 1024;
                        unitIndex++;
                    }
                    String format = (size < 10 && bytes > SIZE_THRESHOLD) ? "%.1f" : "%.0f";
                    return String.format(format, size) + SIZE_UNITS[unitIndex];
                } else {
                    return length.toString();
                }
            }

            /**
             * Formats a file timestamp for long listings.
             *
             * @param time file timestamp
             * @return formatted timestamp text
             */
            protected String toString(FileTime time) {
                long millis = (time != null) ? time.toMillis() : -1L;
                if (millis < 0L) {
                    return "------------";
                }
                ZonedDateTime dt = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault());
                // Less than six months
                if (System.currentTimeMillis() - millis < 183L * 24L * 60L * 60L * 1000L) {
                    return DateTimeFormatter.ofPattern("MMM ppd HH:mm").format(dt);
                }
                // Older than six months
                else {
                    return DateTimeFormatter.ofPattern("MMM ppd  yyyy").format(dt);
                }
            }

            /**
             * Reads the available file attributes for the supplied path.
             *
             * @param path the path whose attributes should be collected
             * @return a case-insensitive map containing the discovered attributes
             */
            protected Map<String, Object> readAttributes(Path path) {
                Map<String, Object> attrs = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
                for (String view : path.getFileSystem().supportedFileAttributeViews()) {
                    try {
                        Map<String, Object> ta =
                            Files.readAttributes(path, view + ":*", getLinkOptions(opt.isSet("L")));
                        ta.forEach(attrs::putIfAbsent);
                    } catch (IOException e) {
                        // Ignore
                    }
                }
                attrs.computeIfAbsent("isExecutable", s -> Files.isExecutable(path));
                attrs.computeIfAbsent("permissions", s -> getPermissionsFromFile(path));
                return attrs;
            }
        }

        Path currentDir = context.currentDir();
        // Listing
        List<Path> expanded = new ArrayList<>();
        if (opt.args().isEmpty()) {
            expanded.add(currentDir);
        } else {
            opt.args().stream().flatMap(s -> maybeExpandGlob(context, s)).forEach(expanded::add);
        }
        boolean listAll = opt.isSet("a");
        Predicate<Path> filter = p -> listAll
            || p.getFileName() == null // root
            || p.getFileName().toString().equals(".")
            || p.getFileName().toString().equals("..")
            || !p.getFileName().toString().startsWith(".");
        List<PathEntry> all = expanded.stream()
            .filter(filter)
            .map(p -> new PathEntry(p, currentDir))
            .sorted()
            .collect(Collectors.toList());
        // Print files first
        List<PathEntry> files = all.stream().filter(PathEntry::isNotDirectory).collect(Collectors.toList());
        PrintStream out = context.out();
        Consumer<Stream<PathEntry>> display = s -> {
            boolean optLine = opt.isSet("1");
            boolean optComma = opt.isSet("m");
            boolean optLong = opt.isSet("l");
            boolean optCol = opt.isSet("C");
            if (!optLine && !optComma && !optLong && !optCol) {
                if (context.isTty()) {
                    optCol = true;
                } else {
                    optLine = true;
                }
            }
            // One entry per line
            if (optLine) {
                s.map(PathEntry::display).forEach(out::println);
            }
            // Comma separated list
            else if (optComma) {
                out.println(s.map(PathEntry::display).collect(Collectors.joining(", ")));
            }
            // Long listing
            else if (optLong) {
                s.map(PathEntry::longDisplay).forEach(out::println);
            }
            // Column listing
            else {
                toColumn(context, out, s.map(PathEntry::display), opt.isSet("x"));
            }
        };
        boolean space = false;
        if (!files.isEmpty()) {
            display.accept(files.stream());
            space = true;
        }
        // Print directories
        List<PathEntry> directories =
            all.stream().filter(PathEntry::isDirectory).collect(Collectors.toList());
        for (PathEntry entry : directories) {
            if (space) {
                out.println();
            }
            space = true;
            Path path = currentDir.resolve(entry.path);
            if (expanded.size() > 1) {
                out.println(stripControlChars(currentDir.relativize(path).toString()) + ":");
            }
            try (Stream<Path> pathStream = Files.list(path)) {
                display.accept(Stream.concat(Stream.of(".", "..").map(path::resolve), pathStream)
                    .filter(filter)
                    .map(p -> new PathEntry(p, path))
                    .sorted());
            }
        }
    }

    private static void toColumn(Context context, PrintStream out, Stream<String> ansi, boolean horizontal) {
        Terminal terminal = context.terminal();
        int width = context.isTty() ? terminal.getColumns() : 80;
        List<AttributedString> strings = ansi.map(AttributedString::fromAnsi).collect(Collectors.toList());
        if (!strings.isEmpty()) {
            int max = strings.stream()
                .mapToInt(AttributedString::columnLength)
                .max()
                .getAsInt();
            int c = Math.max(1, width / max);
            while (c > 1 && c * max + (c - 1) >= width) {
                c--;
            }
            int columns = c;
            int lines = (strings.size() + columns - 1) / columns;
            IntBinaryOperator index;
            if (horizontal) {
                index = (i, j) -> i * columns + j;
            } else {
                index = (i, j) -> j * lines + i;
            }
            AttributedStringBuilder sb = new AttributedStringBuilder();
            for (int i = 0; i < lines; i++) {
                for (int j = 0; j < columns; j++) {
                    int idx = index.applyAsInt(i, j);
                    if (idx < strings.size()) {
                        AttributedString str = strings.get(idx);
                        boolean hasRightItem = j < columns - 1 && index.applyAsInt(i, j + 1) < strings.size();
                        sb.append(str);
                        if (hasRightItem) {
                            for (int k = 0; k <= max - str.length(); k++) {
                                sb.append(' ');
                            }
                        }
                    }
                }
                sb.append('\n');
            }
            out.print(sb.toAnsi(terminal));
        }
    }

    private static final String[] SIZE_UNITS = {"B", "K", "M", "G", "T", "P"};
    private static final long SIZE_THRESHOLD = 1000L;

    /**
     * Searches the selected inputs for lines matching the supplied pattern.
     *
     * @param context command execution context
     * @param argv command name and arguments
     * @throws Exception if the search cannot be completed
     */
    public static void grep(Context context, Object[] argv) throws Exception {
        final String[] usage = {
            "/grep -  search for PATTERN in each FILE or VARIABLE or standard input.",
            "Usage: /grep [OPTIONS] PATTERN [FILE|VARIABLE ...]",
            "  -? --help                Show help",
            "  -i --ignore-case         Ignore case distinctions",
            "  -n --line-number         Prefix each line with line number within its input file",
            "  -q --quiet, --silent     Suppress all normal output",
            "  -v --invert-match        Select non-matching lines",
            "  -w --word-regexp         Select only whole words",
            "  -x --line-regexp         Select only whole lines",
            "  -c --count               Only print a count of matching lines per file",
            "  -z --zero                When used with -c, don't print a count of zero",
            "  -H --with-filename       Display filename header for each file (defaults to true if multiple files are given)",
            "  -h --no-filename         Do not display filename header",
            "     --color=WHEN          Use markers to distinguish the matching string, may be `always', `never' or `auto'",
            "  -B --before-context=NUM  Print NUM lines of leading context before matching lines",
            "  -A --after-context=NUM   Print NUM lines of trailing context after matching lines",
            "  -C --context=NUM         Print NUM lines of output context",
            "     --pad-lines           Pad line numbers"
        };
        Options opt = parseOptions(context, usage, argv);

        Map<String, String> colorMap = getColorMap(context, "GREP", DEFAULT_GREP_COLORS);

        List<String> args = opt.args();
        if (args.isEmpty()) {
            throw new IllegalArgumentException("no pattern supplied");
        }

        String regex = args.remove(0);
        String regexp = regex;
        if (opt.isSet("word-regexp")) {
            regexp = "\\b" + regexp + "\\b";
        }
        boolean lineRegexp = opt.isSet("line-regexp");
        if (lineRegexp) {
            regexp = "^" + regexp + "$";
        }
        Pattern p;
        Pattern p2;
        if (opt.isSet("ignore-case")) {
            p = Pattern.compile(regexp, Pattern.CASE_INSENSITIVE);
            p2 = Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
        } else {
            p = Pattern.compile(regexp);
            p2 = Pattern.compile(regex);
        }
        int after = opt.isSet("after-context") ? opt.getNumber("after-context") : -1;
        int before = opt.isSet("before-context") ? opt.getNumber("before-context") : -1;
        int contextLines = opt.isSet("context") ? opt.getNumber("context") : 0;
        String lineFmt = opt.isSet("pad-lines") ? "%6d" : "%d";
        if (after < 0) {
            after = contextLines;
        }
        if (before < 0) {
            before = contextLines;
        }
        boolean count = opt.isSet("count");
        boolean zero = opt.isSet("zero");
        boolean quiet = opt.isSet("quiet");
        boolean invert = opt.isSet("invert-match");
        boolean lineNumber = opt.isSet("line-number");
        String color = opt.isSet("color") ? opt.get("color") : "auto";
        boolean colored = switch (color) {
            case "always", "yes", "force" -> true;
            case "never", "no", "none" -> false;
            case "auto", "tty", "if-tty" -> context.isTty();
            default -> throw new IllegalArgumentException("invalid argument '" + color + "' for '--color'");
        };
        Map<String, String> colors =
            colored ? (colorMap != null ? colorMap : getColorMap(DEFAULT_GREP_COLORS)) : Collections.emptyMap();

        if (args.isEmpty()) {
            args.add("-");
        }
        List<NamedInputStream> sources = getSources(context, argv, args);
        boolean filenameHeader = sources.size() > 1;
        if (opt.isSet("with-filename")) {
            filenameHeader = true;
        } else if (opt.isSet("no-filename")) {
            filenameHeader = false;
        }
        boolean match = false;
        for (NamedInputStream src : sources) {
            List<String> lines = new ArrayList<>();
            boolean firstPrint = true;
            int nb = 0;
            try (InputStream is = src.getInputStream()) {
                try (BufferedReader r = new BufferedReader(new InputStreamReader(is))) {
                    String line;
                    int lineno = 1;
                    int lineMatch = 0;
                    while ((line = r.readLine()) != null) {
                        boolean matches = lineRegexp ? SafeRegex.matches(p, line) : SafeRegex.find(p, line);
                        if (invert) {
                            matches = !matches;
                        }
                        AttributedStringBuilder sbl = new AttributedStringBuilder();
                        if (matches) {
                            nb++;
                            if (!count && !quiet) {
                                if (filenameHeader) {
                                    if (colored) {
                                        applyStyle(sbl, colors, "fn");
                                    }
                                    sbl.append(stripControlChars(src.getName()));
                                    if (colored) {
                                        applyStyle(sbl, colors, "se");
                                    }
                                    sbl.append(":");
                                }
                                if (lineNumber) {
                                    if (colored) {
                                        applyStyle(sbl, colors, "ln");
                                    }
                                    sbl.append(String.format(lineFmt, lineno));
                                    if (colored) {
                                        applyStyle(sbl, colors, "se");
                                    }
                                    sbl.append(":");
                                }
                                if (colored) {
                                    Matcher matcher2 = SafeRegex.matcher(p2, line);
                                    int cur = 0;
                                    try {
                                        while (matcher2.find()) {
                                            applyStyle(sbl, colors, "se");
                                            sbl.append(line, cur, matcher2.start());
                                            applyStyle(sbl, colors, "ms");
                                            sbl.append(line, matcher2.start(), matcher2.end());
                                            applyStyle(sbl, colors, "se");
                                            cur = matcher2.end();
                                        }
                                    } catch (RegexTimeoutException e) {
                                        // append the remainder without highlighting
                                    }
                                    sbl.append(line, cur, line.length());
                                } else {
                                    sbl.append(line);
                                }
                                while (lineMatch > after && !lines.isEmpty()) {
                                    context.out().println(lines.remove(0));
                                    lineMatch--;
                                }
                                lineMatch = Math.min(before, lines.size()) + after + 1;
                            }
                        } else if (lineMatch > 0) {
                            context.out().println(lines.remove(0));
                            lineMatch--;
                            if (filenameHeader) {
                                if (colored) {
                                    applyStyle(sbl, colors, "fn");
                                }
                                sbl.append(stripControlChars(src.getName()));
                                if (colored) {
                                    applyStyle(sbl, colors, "se");
                                }
                                sbl.append("-");
                            }
                            if (lineNumber) {
                                if (colored) {
                                    applyStyle(sbl, colors, "ln");
                                }
                                sbl.append(String.format(lineFmt, lineno));
                                if (colored) {
                                    applyStyle(sbl, colors, "se");
                                }
                                sbl.append("-");
                            }
                            if (colored) {
                                applyStyle(sbl, colors, "se");
                            }
                            sbl.append(line);
                        } else {
                            if (filenameHeader) {
                                if (colored) {
                                    applyStyle(sbl, colors, "fn");
                                }
                                sbl.append(stripControlChars(src.getName()));
                                if (colored) {
                                    applyStyle(sbl, colors, "se");
                                }
                                sbl.append("-");
                            }
                            if (lineNumber) {
                                if (colored) {
                                    applyStyle(sbl, colors, "ln");
                                }
                                sbl.append(String.format(lineFmt, lineno));
                                if (colored) {
                                    applyStyle(sbl, colors, "se");
                                }
                                sbl.append("-");
                            }
                            if (colored) {
                                applyStyle(sbl, colors, "se");
                            }
                            sbl.append(line);
                            while (lines.size() > before) {
                                lines.remove(0);
                            }
                            lineMatch = 0;
                        }
                        lines.add(sbl.toAnsi(context.terminal()));
                        while (lineMatch == 0 && lines.size() > before) {
                            lines.remove(0);
                        }
                        lineno++;
                    }
                    if (!count && lineMatch > 0) {
                        if (!firstPrint && before + after > 0) {
                            AttributedStringBuilder sbl2 = new AttributedStringBuilder();
                            if (colored) {
                                applyStyle(sbl2, colors, "se");
                            }
                            sbl2.append("--");
                            context.out().println(sbl2.toAnsi(context.terminal()));
                        } else {
                            firstPrint = false;
                        }
                        for (int i = 0; i < lineMatch && i < lines.size(); i++) {
                            context.out().println(lines.get(i));
                        }
                    }
                    if (count) {
                        if (nb != 0 || !zero) {
                            AttributedStringBuilder sbl = new AttributedStringBuilder();
                            if (filenameHeader) {
                                if (colored) {
                                    applyStyle(sbl, colors, "fn");
                                }
                                sbl.append(stripControlChars(src.getName()));
                                if (colored) {
                                    applyStyle(sbl, colors, "se");
                                }
                                sbl.append(":");
                            }
                            sbl.append(Integer.toString(nb));
                            context.out().println(sbl.toAnsi(context.terminal()));
                        }
                    }
                    match |= nb > 0;
                }
                context.out().flush();
            }
        }
    }

    /**
     * Sorts the requested inputs and writes the ordered lines to the current output.
     *
     * @param context command execution context
     * @param argv command name and arguments
     * @throws Exception if an input cannot be read or sorted
     */
    public static void sort(Context context, Object[] argv) throws Exception {
        final String[] usage = {
            "/sort -  writes sorted standard input to standard output.",
            "Usage: /sort [OPTIONS] [FILES]",
            "  -? --help                    show help",
            "  -f --ignore-case             fold lower case to upper case characters",
            "  -r --reverse                 reverse the result of comparisons",
            "  -u --unique                  output only the first of an equal run",
            "  -t --field-separator=SEP     use SEP instead of non-blank to blank transition",
            "  -b --ignore-leading-blanks   ignore leading blanks",
            "     --numeric-sort            compare according to string numerical value",
            "  -k --key=KEY                 fields to use for sorting separated by whitespaces"
        };

        Options opt = parseOptions(context, usage, argv);

        List<String> args = opt.args();
        if (args.isEmpty()) {
            args = Collections.singletonList("-");
        }
        List<NamedInputStream> sources = getSources(context, argv, args);
        List<String> lines = new ArrayList<>();
        for (NamedInputStream s : sources) {
            try (BufferedReader reader = new BufferedReader(new InputStreamReader(s.getInputStream()))) {
                readLines(reader, lines);
            }
        }

        String separator = opt.get("field-separator");
        boolean caseInsensitive = opt.isSet("ignore-case");
        boolean reverse = opt.isSet("reverse");
        boolean ignoreBlanks = opt.isSet("ignore-leading-blanks");
        boolean numeric = opt.isSet("numeric-sort");
        boolean unique = opt.isSet("unique");
        List<String> sortFields = opt.getList("key");

        char sep = (separator == null || separator.length() == 0) ? '\0' : separator.charAt(0);
        lines.sort(new SortComparator(caseInsensitive, reverse, ignoreBlanks, numeric, sep, sortFields));
        String last = null;
        for (String s : lines) {
            if (!unique || last == null || !s.equals(last)) {
                context.out().println(s);
            }
            last = s;
        }
    }

    /**
     * Displays the requested inputs in the less viewer, or streams them when not interactive.
     *
     * @param context command execution context
     * @param argv command name and arguments
     * @throws Exception if the viewer cannot be initialized or an input cannot be opened
     */
    public static void less(Context context, String[] argv) throws Exception {
        Options opt = parseOptions(context, Less.usage(), argv);

        List<Source> sources = new ArrayList<>();
        if (opt.args().isEmpty()) {
            opt.args().add("-");
        }
        for (String arg : opt.args()) {
            if ("-".equals(arg)) {
                sources.add(new Source.StdInSource(context.in()));
            } else {
                maybeExpandGlob(context, arg)
                    .map(p -> {
                        try {
                            return new Source.URLSource(p.toUri().toURL(), p.toString());
                        } catch (MalformedURLException e) {
                            throw new RuntimeException(e);
                        }
                    })
                    .forEach(sources::add);
            }
        }

        if (!context.isTty()) {
            // Non-interactive mode - just cat the files
            for (Source source : sources) {
                try (BufferedReader reader = new BufferedReader(new InputStreamReader(source.read()))) {
                    String line;
                    while ((line = reader.readLine()) != null) {
                        context.out().println(line);
                    }
                }
            }
            return;
        }

        ConfigurationPath cfg = (context instanceof GroovyPosixContext)
                ? ((GroovyPosixContext) context).getConfigPath()
                : null;
        Less less = new Less(context.terminal(), context.currentDir(), opt, cfg);
        less.run(sources);
    }

    private static class NamedInputStream implements Closeable {
        private InputStream inputStream;
        private final Path path;
        private final String name;
        private final boolean close;

        public NamedInputStream(InputStream inputStream, String name, boolean close) {
            this.inputStream = inputStream;
            this.path = null;
            this.name = name;
            this.close = close;
        }

        public NamedInputStream(InputStream inputStream, String name) {
            this(inputStream, name, true);
        }
        public NamedInputStream(Path path, String name) {
            this.inputStream = null;
            this.path = path;
            this.name = name;
            this.close = false;
        }

        public InputStream getInputStream() throws IOException {
            if (inputStream == null) {
                inputStream = path.toUri().toURL().openStream();
            }
            return inputStream;
        }

        public String getName() {
            return name;
        }

        @Override
        public void close() throws IOException {
            if (inputStream != null && close) {
                inputStream.close();
            }
        }
    }

    private static List<NamedInputStream> getSources(Context context, Object[] argv, List<String> args) {
        List<NamedInputStream> sources = new ArrayList<>();
        for (String arg : args) {
            if ("-".equals(arg)) {
                sources.add(new NamedInputStream(context.in(), "(standard input)", false));
            } else if (arg.startsWith("[Ljava.lang.String;@")) {
                sources.add(new NamedInputStream(variableInputStream(argv, arg), arg));
            } else {
                sources.addAll(maybeExpandGlob(context, arg)
                    .map(p -> new NamedInputStream(p, p.toString()))
                    .collect(Collectors.toList()));
            }
        }
        return sources;
    }

    private static ByteArrayInputStream variableInputStream(Object[] argv, String arg) {
        String[] found = (String[]) Arrays.stream(argv).filter(v -> v.toString().equals(arg)).findFirst().get();
        ByteArrayInputStream inputStream = new ByteArrayInputStream(ArrayGroovyMethods.join(found, "\n").getBytes(StandardCharsets.UTF_8));
        return inputStream;
    }

    private static LinkOption[] getLinkOptions(boolean followLinks) {
        if (followLinks) {
            return EMPTY_LINK_OPTIONS;
        } else { // return a clone that modifications to the array will not affect others
            return NO_FOLLOW_OPTIONS.clone();
        }
    }

    private static final LinkOption[] NO_FOLLOW_OPTIONS = new LinkOption[] {LinkOption.NOFOLLOW_LINKS};
    private static final LinkOption[] EMPTY_LINK_OPTIONS = new LinkOption[0];
    private static final List<String> WINDOWS_EXECUTABLE_EXTENSIONS =
        List.of(".bat", ".exe", ".cmd");

    private static boolean isWindowsExecutable(String fileName) {
        if ((fileName == null) || (fileName.length() <= 0)) {
            return false;
        }
        for (String suffix : WINDOWS_EXECUTABLE_EXTENSIONS) {
            if (fileName.endsWith(suffix)) {
                return true;
            }
        }
        return false;
    }

    /**
     * Removes control characters from text that is about to be written to the terminal.
     * <p>
     * File names, symlink targets and the like are data supplied by whoever created them, not text
     * the user asked to render, so an escape sequence embedded in a name would otherwise be executed
     * by the terminal &mdash; hiding output, spoofing what is displayed, or reaching an emulator bug.
     * <p>
     * Ported verbatim from JLine's {@code PosixCommands} (jline#2200), which this class is derived
     * from, so that the two stay diffable. Upstream strips rather than quoting as coreutils does; the
     * behaviour is matched deliberately rather than improved on, because divergence here is what
     * caused this fix to be missed in the first place.
     */
    private static String stripControlChars(String s) {
        if (s == null) {
            return null;
        }
        StringBuilder sb = new StringBuilder(s.length());
        s.codePoints().forEach(cp -> {
            if (!Character.isISOControl(cp)) {
                sb.appendCodePoint(cp);
            }
        });
        return sb.toString();
    }

    private static Set<PosixFilePermission> getPermissionsFromFile(Path f) {
        Set<PosixFilePermission> perms = new HashSet<>();
        try {
            perms = Files.getPosixFilePermissions(f);
        } catch (IOException | UnsupportedOperationException ignore) {
        }
        if (OSUtils.IS_WINDOWS && isWindowsExecutable(f.getFileName().toString())) {
            perms.add(PosixFilePermission.OWNER_EXECUTE);
            perms.add(PosixFilePermission.GROUP_EXECUTE);
            perms.add(PosixFilePermission.OTHERS_EXECUTE);
        }
        return perms;
    }

    private static void readLines(BufferedReader reader, List<String> lines) throws IOException {
        String line;
        while ((line = reader.readLine()) != null) {
            lines.add(line);
        }
    }

    private static Stream<Path> maybeExpandGlob(Context context, String s) {
        if (s.contains("*") || s.contains("?")) {
            return expandGlob(context, s).stream();
        }
        return Stream.of(context.currentDir().resolve(s));
    }

    private static List<Path> expandGlob(Context context, String pattern) {
        // try Ant for globbing if on classpath
        try {
            Class<?> directoryScannerClass = Class.forName("org.apache.tools.ant.DirectoryScanner");
            Object scanner = directoryScannerClass.getDeclaredConstructor().newInstance();
            List<Path> result = new ArrayList<>();

            // Use reflection to invoke methods on the DirectoryScanner
            Method setBasedirMethod = directoryScannerClass.getMethod("setBasedir", File.class);
            setBasedirMethod.invoke(scanner, context.currentDir().toFile());

            Method setIncludesMethod = directoryScannerClass.getMethod("setIncludes", String[].class);
            setIncludesMethod.invoke(scanner, (Object) new String[]{pattern});

            Method scanMethod = directoryScannerClass.getMethod("scan");
            scanMethod.invoke(scanner);

            Method getIncludedFilesMethod = directoryScannerClass.getMethod("getIncludedFiles");
            String[] includedFiles = (String[]) getIncludedFilesMethod.invoke(scanner);

            for (String file : includedFiles) {
                // the scanner reports matches relative to its base directory, which is the
                // shell's working directory and not necessarily the JVM's
                result.add(context.currentDir().resolve(file));
            }

            Method getIncludedDirsMethod = directoryScannerClass.getMethod("getIncludedDirectories");
            String[] includedDirs = (String[]) getIncludedDirsMethod.invoke(scanner);

            for (String dir : includedDirs) {
                result.add(context.currentDir().resolve(dir));
            }
            return result;
        } catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException | InstantiationException |
                 InvocationTargetException ignore) {
        }

        Path path = Path.of(pattern);

        Path base;
        String globPart;

        if (path.isAbsolute()) {
            base = path.getParent();
            globPart = path.getFileName().toString();
        } else {
            base = context.currentDir();
            globPart = pattern;
        }

        PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + globPart);

        try {
            return Files.list(base)
                .filter(p -> matcher.matches(p.getFileName()))
                .collect(Collectors.toList());
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}
