#!/usr/bin/env -S java --source 25

interface Boundary {

    String NAME = MethodHandles.lookup().lookupClass().getEnclosingClass().getName();
    String VERSION = "2026-10-09.3";

    static void files(List<String> args) {
        Trace.invocation(NAME, VERSION, args);
        Log.USER.out("%s %s root=%s".formatted(NAME, VERSION, Control.root()));
        try {
            var query = parse(args);
            var scope = new Entity.Scope(Control.root(), query.all());
            var starts = Control.starts(scope, query.paths());
            var walk = Control.walk(scope, starts, query.selection());
            var result = Control.list(scope, starts, walk, query, Log.USER::out);
            Log.NOTE.out(result.summary(query));
            if (!walk.skipped().isEmpty())
                Log.NOTE.out("skipped: " + walk.report());
            Log.exit(result.found() > 0 ? Exit.FOUND : Exit.NONE);
        } catch (SecurityException e) {
            Log.stop(e.getMessage(), Exit.DENIED);
        } catch (IllegalArgumentException | IOException e) {
            Log.stop(e.getMessage(), Exit.USAGE);
        }
    }

    static Entity.Query parse(List<String> args) {
        var paths = new ArrayList<String>();
        var globs = new ArrayList<String>();
        var excludes = new ArrayList<String>();
        var type = Entity.Type.FILE;
        var detailed = false;
        var count = false;
        var max = 1000;
        var all = false;
        var tokens = Arg.expand(args);
        for (int i = 0; i < tokens.size(); i++) {
            var token = tokens.get(i);
            if (token.equals("--")) {
                tokens.subList(i + 1, tokens.size()).forEach(rest -> classify(rest, paths, globs));
                break;
            }
            switch (Arg.from(token)) {
                case HELP -> usage();
                case SHOW_VERSION -> Log.exit(Exit.FOUND);
                case TYPE -> type = Entity.Type.of(Arg.value(tokens, ++i, token));
                case LONG -> detailed = true;
                case COUNT -> count = true;
                case EXCLUDE -> excludes.add(Arg.value(tokens, ++i, token));
                case MAX -> max = Arg.number(tokens, ++i, token);
                case ALL -> all = true;
                case null -> {
                    if (token.startsWith("-") && token.length() > 1)
                        throw new IllegalArgumentException("unknown option: " + token + " (put -- before a name starting with -)");
                    classify(token, paths, globs);
                }
            }
        }
        return new Entity.Query(List.copyOf(paths), Entity.Selection.of(globs, excludes), type, detailed, count, max, all);
    }

    static void classify(String token, List<String> paths, List<String> globs) {
        var glob = token.chars().anyMatch(c -> "*?[{".indexOf(c) >= 0);
        if (!glob && Files.isDirectory(Path.of(token)))
            paths.add(token);
        else
            globs.add(token);
    }

    static void usage() {
        Log.USER.out("""
                Usage: %s [dir...] [glob...] [options]
                Read-only file listing of the current directory or the given paths, confined to the root
                (nearest directory containing .git, at or above the current directory, otherwise the current directory).
                Existing directories are start points, everything else is a glob; a glob without / matches the file name
                (*.js, *.{ts,tsx}, package.json), a glob with / matches the root-relative path (src/**/test/*.js).
                Output: sorted root-relative paths, directories end with /. Summary and skips go to stderr.

                Options:
                %s
                  --                 end of options, use before a name starting with -

                Never listed: .git, secrets (.env, *.pem, *.key, .npmrc, id_rsa, ...), paths outside the root.
                Skipped unless -all: node_modules, dist, target, coverage, .next, .cache, ...,
                build and out at the root.
                Exit: 0 found, 1 nothing found, 2 usage error, 3 denied path.
                """.formatted(NAME, Arg.usage()));
        Log.exit(Exit.FOUND);
    }

    enum Arg {
        HELP("-help", "", "show this help"),
        SHOW_VERSION("-version", "", "show version"),
        TYPE("-type", "f|d", "list files (default) or directories"),
        LONG("-long", "", "prefix size in bytes and line count (- for binary or over 1 MiB)"),
        COUNT("-count", "", "print the number of entries only"),
        EXCLUDE("-exclude", "<glob>", "skip entries matching, repeatable (src/test/**)"),
        MAX("-max", "<n>", "stop after n entries (default 1000)"),
        ALL("-all", "", "include ignored directories; -long counts lines up to 32 MiB");

        final String option;
        final String parameter;
        final String description;

        Arg(String option, String parameter, String description) {
            this.option = option;
            this.parameter = parameter;
            this.description = description;
        }

        boolean matches(String value) {
            return option.equals(value) || value.startsWith(option + ":");
        }

        static Arg from(String value) {
            return Stream.of(values())
                    .filter(arg -> arg.matches(value))
                    .findFirst()
                    .orElse(null);
        }

        static List<String> expand(List<String> args) {
            var tokens = new ArrayList<String>();
            for (int i = 0; i < args.size(); i++) {
                var arg = args.get(i);
                if (arg.equals("--")) {
                    tokens.addAll(args.subList(i, args.size()));
                    break;
                }
                var known = from(arg);
                if (known != null && arg.length() > known.option.length()) {
                    tokens.add(known.option);
                    tokens.add(arg.substring(known.option.length() + 1));
                } else {
                    tokens.add(arg);
                }
            }
            return tokens;
        }

        static String value(List<String> tokens, int index, String option) {
            if (index >= tokens.size())
                throw new IllegalArgumentException("missing value for " + option);
            return tokens.get(index);
        }

        static int number(List<String> tokens, int index, String option) {
            var value = value(tokens, index, option);
            if (!value.matches("\\d+"))
                throw new IllegalArgumentException(option + " expects a non-negative number, got: " + value);
            return Integer.parseInt(value);
        }

        static String usage() {
            return Stream.of(values())
                    .map(arg -> "  %-18s %s".formatted(arg.option + " " + arg.parameter, arg.description))
                    .collect(Collectors.joining("\n"));
        }
    }

    enum Exit {
        FOUND(0), NONE(1), USAGE(2), DENIED(3);

        final int code;

        Exit(int code) {
            this.code = code;
        }
    }

    enum Log {
        USER, NOTE, ERROR;

        void out(String message) {
            switch (this) {
                case USER -> IO.println(message);
                case NOTE -> System.err.println(message);
                case ERROR -> System.err.println(NAME + ": " + message);
            }
        }

        static void stop(String message, Exit exit) {
            ERROR.out(message);
            exit(exit);
        }

        static void exit(Exit exit) {
            Trace.exit(exit.code);
        }
    }
}

interface Control {

    static Entity.Result list(Entity.Scope scope, List<Path> starts, Entity.Walk walk, Entity.Query query, Consumer<String> out) {
        var entries = switch (query.type()) {
            case FILE -> walk.files();
            case DIRECTORY -> walk.directories().stream().filter(directory -> !starts.contains(directory)).toList();
        };
        if (query.count()) {
            out.accept(String.valueOf(entries.size()));
            return new Entity.Result(entries.size(), false);
        }
        entries.stream()
                .limit(query.max())
                .map(entry -> line(scope, entry, query))
                .forEach(out);
        return new Entity.Result(entries.size(), entries.size() > query.max());
    }

    static String line(Entity.Scope scope, Path entry, Entity.Query query) {
        var path = scope.relative(entry);
        if (query.type() == Entity.Type.DIRECTORY)
            path += "/";
        if (!query.detailed())
            return path;
        if (query.type() == Entity.Type.DIRECTORY)
            return "%10s %7s  %s".formatted("-", "-", path);
        try {
            var size = Files.size(entry);
            var lines = size > scope.sizeLimit() ? "-" : lines(entry);
            return "%10d %7s  %s".formatted(size, lines, path);
        } catch (IOException e) {
            return "%10s %7s  %s".formatted("-", "-", path);
        }
    }

    static String lines(Path file) throws IOException {
        var bytes = Files.readAllBytes(file);
        if (Entity.Scope.binary(bytes))
            return "-";
        var lines = 0;
        for (var b : bytes)
            if (b == '\n')
                lines++;
        if (bytes.length > 0 && bytes[bytes.length - 1] != '\n')
            lines++;
        return String.valueOf(lines);
    }

    // root, starts, walk and visit are shared verbatim by files and search, root and starts also by show — change them together

    static Path root() {
        try {
            var cwd = Path.of("").toRealPath();
            for (var dir = cwd; dir != null; dir = dir.getParent())
                if (Files.exists(dir.resolve(".git")))
                    return dir;
            return cwd;
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    static List<Path> starts(Entity.Scope scope, List<String> paths) throws IOException {
        if (paths.isEmpty())
            return List.of(Path.of("").toRealPath());
        var starts = new ArrayList<Path>();
        for (var path : paths) {
            var candidate = Path.of(path).toAbsolutePath().normalize();
            if (!Files.exists(candidate))
                throw new IllegalArgumentException("not found: " + path);
            var real = candidate.toRealPath();
            if (!real.startsWith(scope.root()))
                throw new SecurityException("outside the root " + scope.root() + ": " + path);
            if (Files.isRegularFile(real) && scope.denied(real))
                throw new SecurityException("denied (may contain secrets): " + path);
            starts.add(real);
        }
        return starts;
    }

    static Entity.Walk walk(Entity.Scope scope, List<Path> starts, Entity.Selection selection) {
        var walk = new Entity.Walk(new ArrayList<>(), new ArrayList<>(), new EnumMap<>(Entity.Skip.class));
        for (var start : starts) {
            if (Files.isDirectory(start))
                visit(scope, start, selection, walk);
            else
                walk.files().add(start);
        }
        walk.files().sort(Comparator.comparing(scope::relative));
        walk.directories().sort(Comparator.comparing(scope::relative));
        return walk;
    }

    static void visit(Entity.Scope scope, Path directory, Entity.Selection selection, Entity.Walk walk) {
        if (selection.test(scope.relative(directory)))
            walk.directories().add(directory);
        try (var entries = Files.newDirectoryStream(directory)) {
            for (var entry : entries) {
                if (Files.isSymbolicLink(entry) && !scope.contains(entry)) {
                    walk.skip(Entity.Skip.OUTSIDE);
                } else if (Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS)) {
                    if (scope.ignored(entry))
                        walk.skip(Entity.Skip.IGNORED);
                    else
                        visit(scope, entry, selection, walk);
                } else if (Files.isRegularFile(entry) && selection.test(scope.relative(entry))) {
                    if (scope.denied(entry))
                        walk.skip(Entity.Skip.DENIED);
                    else
                        walk.files().add(entry);
                }
            }
        } catch (IOException e) {
            walk.skip(Entity.Skip.UNREADABLE);
        }
    }
}

interface Entity {

    enum Type {
        FILE, DIRECTORY;

        static Type of(String value) {
            return switch (value) {
                case "f" -> FILE;
                case "d" -> DIRECTORY;
                default -> throw new IllegalArgumentException("-type expects f or d, got: " + value);
            };
        }
    }

    record Query(List<String> paths, Selection selection, Type type, boolean detailed, boolean count, int max, boolean all) {}

    record Result(int found, boolean truncated) {

        String summary(Query query) {
            var summary = "%d %s".formatted(found, query.type() == Type.FILE ? "files" : "directories");
            return truncated ? summary + " (output stopped at -max " + query.max() + ")" : summary;
        }
    }

    // Scope, Skip, Walk, Glob and Selection are shared verbatim by files and search, Scope also by show — change them together

    record Scope(Path root, boolean all) {

        static final Set<String> IGNORED_ANYWHERE = Set.of("node_modules", "bower_components", "target", "dist",
                "coverage", ".next", ".nuxt", ".svelte-kit", ".cache", ".parcel-cache", ".turbo", ".gradle",
                "__pycache__", ".venv", "venv", ".idea");
        static final Set<String> IGNORED_AT_ROOT = Set.of("build", "out");
        static final List<PathMatcher> DENIED = Stream.of(".env", ".env.*", "*.pem", "*.key", "*.p12", "*.pfx",
                        "*.jks", "*.keystore", "*.kdbx", "id_rsa*", "id_dsa*", "id_ecdsa*", "id_ed25519*", ".npmrc",
                        ".yarnrc.yml", ".netrc", ".pgpass", ".git-credentials", "credentials", "credentials.json",
                        "secrets.{yml,yaml,json,properties}")
                .map(glob -> FileSystems.getDefault().getPathMatcher("glob:" + glob))
                .toList();
        static final List<String> HARMLESS_SUFFIXES = List.of(".example", ".sample", ".template");
        static final long SIZE_LIMIT = 1L << 20;
        static final long SIZE_LIMIT_ALL = 32L << 20;

        boolean ignored(Path directory) {
            var name = directory.getFileName().toString();
            if (name.equals(".git"))
                return true;
            if (all)
                return false;
            return IGNORED_ANYWHERE.contains(name) || (IGNORED_AT_ROOT.contains(name) && root.equals(directory.getParent()));
        }

        boolean denied(Path file) {
            if (relative(file).startsWith(".git/") || relative(file).contains("/.git/"))
                return true;
            var name = file.getFileName();
            if (HARMLESS_SUFFIXES.stream().anyMatch(name.toString()::endsWith))
                return false;
            return DENIED.stream().anyMatch(matcher -> matcher.matches(name));
        }

        boolean contains(Path path) {
            try {
                return path.toRealPath().startsWith(root);
            } catch (IOException e) {
                return false;
            }
        }

        long sizeLimit() {
            return all ? SIZE_LIMIT_ALL : SIZE_LIMIT;
        }

        String relative(Path path) {
            var relative = root.relativize(path).toString().replace(File.separatorChar, '/');
            return relative.isEmpty() ? "." : relative;
        }

        static boolean binary(byte[] head) {
            for (int i = 0; i < Math.min(head.length, 8192); i++)
                if (head[i] == 0)
                    return true;
            return false;
        }
    }

    enum Skip {
        IGNORED("ignored directories (-all includes them, except .git)"),
        DENIED("denied files (may contain secrets)"),
        OUTSIDE("symlinks leaving the root"),
        UNREADABLE("unreadable paths"),
        BINARY("binary files"),
        LARGE("files over the size limit (1 MiB, -all: 32 MiB)"),
        LONG_LINE("lines over 20000 characters");

        final String label;

        Skip(String label) {
            this.label = label;
        }
    }

    record Walk(List<Path> files, List<Path> directories, EnumMap<Skip, Integer> skipped) {

        void skip(Skip reason) {
            skipped.merge(reason, 1, Integer::sum);
        }

        String report() {
            return skipped.entrySet().stream()
                    .map(entry -> entry.getValue() + " " + entry.getKey().label)
                    .collect(Collectors.joining(", "));
        }
    }

    record Glob(String pattern, PathMatcher matcher, PathMatcher tail) {

        static Glob of(String pattern) {
            var tail = pattern.startsWith("**/") ? pattern.substring(3) : pattern;
            return new Glob(pattern, matcher(pattern), matcher(tail));
        }

        static PathMatcher matcher(String pattern) {
            return FileSystems.getDefault().getPathMatcher("glob:" + pattern);
        }

        boolean matches(String relative) {
            var path = Path.of(relative);
            if (!pattern.contains("/"))
                return path.getFileName() != null && matcher.matches(path.getFileName());
            return matcher.matches(path) || tail.matches(path);
        }
    }

    record Selection(List<Glob> includes, List<Glob> excludes) {

        static Selection of(List<String> includes, List<String> excludes) {
            return new Selection(includes.stream().map(Glob::of).toList(), excludes.stream().map(Glob::of).toList());
        }

        boolean test(String relative) {
            if (excludes.stream().anyMatch(glob -> glob.matches(relative)))
                return false;
            return includes.isEmpty() || includes.stream().anyMatch(glob -> glob.matches(relative));
        }
    }
}

// Trace is shared verbatim by every script in this repository — change all copies together

interface Trace {

    Path FILE = Path.of(System.getProperty("user.home"), "zllm-scripts", "zllm-scripts-invocations.log");
    long STARTED = System.currentTimeMillis();
    AtomicInteger EXIT = new AtomicInteger();
    int ARG_LIMIT = 120;
    int LINE_LIMIT = 600;

    static void invocation(String name, String version, List<String> args) {
        Runtime.getRuntime().addShutdownHook(new Thread(() -> append(name, version, args)));
    }

    static void exit(int code) {
        EXIT.set(code);
        System.exit(code);
    }

    static void append(String name, String version, List<String> args) {
        var line = "%s pid=%d %s %s exit=%d ms=%d cwd=%s args=%s%n".formatted(
                Instant.now(), ProcessHandle.current().pid(), name, version, EXIT.get(),
                System.currentTimeMillis() - STARTED, Path.of("").toAbsolutePath(), quote(args));
        try {
            Files.createDirectories(FILE.getParent());
            Files.writeString(FILE, line, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
        } catch (IOException | RuntimeException e) {
            System.err.println(name + ": invocation not logged to " + FILE + ": " + e.getMessage());
        }
    }

    static String quote(List<String> args) {
        var quoted = args.stream()
                .map(arg -> arg.length() > ARG_LIMIT ? arg.substring(0, ARG_LIMIT) + "..." : arg)
                .map(arg -> arg.replace("\\", "\\\\").replace("\"", "\\\"").replaceAll("\\s", " "))
                .map(arg -> '"' + arg + '"')
                .collect(Collectors.joining(" "));
        return quoted.length() > LINE_LIMIT ? quoted.substring(0, LINE_LIMIT) + "..." : quoted;
    }
}

void main(String... args) {
    Boundary.files(List.of(args));
}
