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

interface Boundary {

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

    static void search(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.search(scope, walk, query, Log.USER::out);
            Log.NOTE.out(result.summary(query));
            if (!walk.skipped().isEmpty())
                Log.NOTE.out("skipped: " + walk.report());
            Log.exit(result.matches() > 0 ? Exit.MATCH : Exit.NO_MATCH);
        } 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 positional = new ArrayList<String>();
        var globs = new ArrayList<String>();
        var excludes = new ArrayList<String>();
        var flags = 0;
        var mode = Entity.Mode.LINES;
        var context = 0;
        var max = 200;
        var all = false;
        var tokens = Arg.expand(args);
        for (int i = 0; i < tokens.size(); i++) {
            var token = tokens.get(i);
            if (token.equals("--")) {
                positional.addAll(tokens.subList(i + 1, tokens.size()));
                break;
            }
            switch (Arg.from(token)) {
                case HELP -> usage();
                case SHOW_VERSION -> Log.exit(Exit.MATCH);
                case GLOB -> globs.add(Arg.value(tokens, ++i, token));
                case EXCLUDE -> excludes.add(Arg.value(tokens, ++i, token));
                case IGNORE_CASE -> flags |= Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE;
                case FIXED -> flags |= Pattern.LITERAL;
                case COUNT -> mode = Entity.Mode.COUNT;
                case FILES -> mode = Entity.Mode.FILES;
                case CONTEXT -> context = Arg.number(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 pattern starting with -)");
                    positional.add(token);
                }
            }
        }
        if (positional.isEmpty())
            usage();
        var pattern = Pattern.compile(positional.getFirst(), flags);
        return new Entity.Query(pattern, List.copyOf(positional.subList(1, positional.size())),
                Entity.Selection.of(globs, excludes), mode, context, max, all);
    }

    static void usage() {
        Log.USER.out("""
                Usage: %s <regex> [path...] [options]
                Read-only regex search 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).
                Output: path:line:text, context lines as path-line-text. Summary and skips go to stderr.

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

                Never searched: .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, files over 1 MiB.
                Exit: 0 match, 1 no match, 2 usage error, 3 denied path.
                """.formatted(NAME, Arg.usage()));
        Log.exit(Exit.MATCH);
    }

    enum Arg {
        HELP("-help", "", "show this help"),
        SHOW_VERSION("-version", "", "show version"),
        GLOB("-glob", "<glob>", "only files matching, repeatable; no / matches the file name (*.js, *.{ts,tsx})"),
        EXCLUDE("-exclude", "<glob>", "skip files matching, repeatable (src/test/**)"),
        IGNORE_CASE("-i", "", "case-insensitive"),
        FIXED("-fixed", "", "treat the pattern as a literal string"),
        COUNT("-count", "", "print path:count per file"),
        FILES("-files", "", "print matching paths only"),
        CONTEXT("-context", "<n>", "lines of context around each match"),
        MAX("-max", "<n>", "stop after n output entries (default 200)"),
        ALL("-all", "", "include ignored directories and files 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 {
        MATCH(0), NO_MATCH(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 {

    int MAX_LINE = 20_000;
    int EXCERPT = 300;

    static Entity.Result search(Entity.Scope scope, Entity.Walk walk, Entity.Query query, Consumer<String> out) {
        var matches = 0;
        var files = 0;
        var entries = 0;
        var candidates = walk.files();
        for (int index = 0; index < candidates.size(); index++) {
            var file = candidates.get(index);
            var lines = lines(scope, file, walk);
            if (lines.isEmpty())
                continue;
            var hits = hits(lines.get(), query.pattern(), walk);
            if (hits.isEmpty())
                continue;
            files++;
            matches += hits.size();
            var path = scope.relative(file);
            var hidden = false;
            switch (query.mode()) {
                case FILES -> out.accept(path);
                case COUNT -> out.accept(path + ":" + hits.size());
                case LINES -> {
                    var shown = hits.subList(0, Math.min(hits.size(), query.max() - entries));
                    if (query.context() > 0 && entries > 0)
                        out.accept("--");
                    print(path, lines.get(), shown, Set.copyOf(hits), query, out);
                    entries += shown.size() - 1;
                    hidden = shown.size() < hits.size();
                }
            }
            entries++;
            if (entries >= query.max() && (hidden || index < candidates.size() - 1))
                return new Entity.Result(matches, files, true);
        }
        return new Entity.Result(matches, files, false);
    }

    static void print(String path, List<String> lines, List<Integer> shown, Set<Integer> hits, Entity.Query query, Consumer<String> out) {
        var last = -1;
        for (var hit : shown) {
            var from = Math.max(Math.max(0, hit - query.context()), last + 1);
            var to = Math.min(lines.size() - 1, hit + query.context());
            if (last >= 0 && from > last + 1)
                out.accept("--");
            for (int i = from; i <= to; i++) {
                var separator = hits.contains(i) ? ":" : "-";
                out.accept(path + separator + (i + 1) + separator + excerpt(lines.get(i), query.pattern()));
            }
            last = Math.max(last, to);
        }
    }

    static String excerpt(String line, Pattern pattern) {
        if (line.length() <= EXCERPT)
            return line;
        var matcher = pattern.matcher(line);
        var start = matcher.find() ? Math.max(0, matcher.start() - EXCERPT / 3) : 0;
        var end = Math.min(line.length(), start + EXCERPT);
        return (start > 0 ? "…" : "") + line.substring(start, end) + (end < line.length() ? "…" : "");
    }

    static List<Integer> hits(List<String> lines, Pattern pattern, Entity.Walk walk) {
        var hits = new ArrayList<Integer>();
        for (int i = 0; i < lines.size(); i++) {
            var line = lines.get(i);
            if (line.length() > MAX_LINE) {
                walk.skip(Entity.Skip.LONG_LINE);
                continue;
            }
            if (pattern.matcher(line).find())
                hits.add(i);
        }
        return hits;
    }

    static Optional<List<String>> lines(Entity.Scope scope, Path file, Entity.Walk walk) {
        try {
            if (Files.size(file) > scope.sizeLimit()) {
                walk.skip(Entity.Skip.LARGE);
                return Optional.empty();
            }
            var bytes = Files.readAllBytes(file);
            if (Entity.Scope.binary(bytes)) {
                walk.skip(Entity.Skip.BINARY);
                return Optional.empty();
            }
            return Optional.of(new String(bytes, StandardCharsets.UTF_8).lines().toList());
        } catch (IOException e) {
            walk.skip(Entity.Skip.UNREADABLE);
            return Optional.empty();
        }
    }

    // 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 Mode { LINES, COUNT, FILES }

    record Query(Pattern pattern, List<String> paths, Selection selection, Mode mode, int context, int max, boolean all) {}

    record Result(int matches, int files, boolean truncated) {

        String summary(Query query) {
            var summary = "%d matching lines in %d files".formatted(matches, files);
            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.search(List.of(args));
}
