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

interface Boundary {

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

    static void show(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(), false);
            var files = Control.starts(scope, query.paths());
            var directory = files.stream().filter(Files::isDirectory).findFirst();
            if (directory.isPresent())
                throw new IllegalArgumentException("is a directory, list it with files: " + scope.relative(directory.get()));
            var shown = Control.show(scope, files, query, Log.USER::out, Log.NOTE::out);
            Log.exit(shown > 0 ? Exit.SHOWN : Exit.NOTHING);
        } 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 ranges = Entity.Ranges.EVERYTHING;
        var max = 500;
        var tokens = Arg.expand(args);
        for (int i = 0; i < tokens.size(); i++) {
            var token = tokens.get(i);
            if (token.equals("--")) {
                paths.addAll(tokens.subList(i + 1, tokens.size()));
                break;
            }
            switch (Arg.from(token)) {
                case HELP -> usage();
                case SHOW_VERSION -> Log.exit(Exit.SHOWN);
                case LINES -> ranges = Entity.Ranges.of(Arg.value(tokens, ++i, token));
                case MAX -> max = Arg.number(tokens, ++i, token);
                case null -> {
                    if (token.startsWith("-") && token.length() > 1)
                        throw new IllegalArgumentException("unknown option: " + token + " (put -- before a file starting with -)");
                    paths.add(token);
                }
            }
        }
        if (paths.isEmpty())
            usage();
        return new Entity.Query(List.copyOf(paths), ranges, max);
    }

    static void usage() {
        Log.USER.out("""
                Usage: %s <file...> [options]
                Read-only, line-numbered file display confined to the root
                (nearest directory containing .git, at or above the current directory, otherwise the current directory).
                Output: a ==> path · N lines <== header per file, then number<TAB>text.
                Lines over %d characters are cut with a …[+N chars] note. Truncation notes go to stderr.

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

                Never shown: .git, secrets (.env, *.pem, *.key, .npmrc, id_rsa, ...), paths outside the root,
                binary files. Files inside ignored directories (node_modules, dist, ...) are shown when named.
                Exit: 0 shown, 1 nothing shown (binary or range past the end), 2 usage error, 3 denied path.
                """.formatted(NAME, Control.MAX_WIDTH, Arg.usage()));
        Log.exit(Exit.SHOWN);
    }

    enum Arg {
        HELP("-help", "", "show this help"),
        SHOW_VERSION("-version", "", "show version"),
        LINES("-lines", "<a-b|a-|a>[,...]", "show lines a to b, or from a to the end (a- and a are the same); several ranges separated by commas, e.g. 47-48,60-70"),
        MAX("-max", "<n>", "stop after n lines across all files (default 500)");

        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 {
        SHOWN(0), NOTHING(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_WIDTH = 2000;
    int HEAD = 8192;

    static int show(Entity.Scope scope, List<Path> files, Entity.Query query, Consumer<String> out, Consumer<String> note) throws IOException {
        var budget = query.max();
        var shown = 0;
        for (int index = 0; index < files.size(); index++) {
            var file = files.get(index);
            var path = scope.relative(file);
            if (budget == 0) {
                var rest = files.subList(index, files.size()).stream().map(scope::relative).collect(Collectors.joining(" "));
                note.accept("stopped at -max %d; not shown: %s".formatted(query.max(), rest));
                break;
            }
            if (binary(file)) {
                note.accept(path + ": binary, not shown");
                continue;
            }
            var excerpt = excerpt(file, query.ranges(), budget);
            out.accept("==> %s · %d lines <==".formatted(path, excerpt.total()));
            excerpt.lines().forEach(out);
            budget -= excerpt.lines().size();
            shown += excerpt.lines().size();
            if (excerpt.lines().isEmpty() && excerpt.total() > 0)
                note.accept("%s: -lines %s is past the end (%d lines)".formatted(path, query.ranges(), excerpt.total()));
            if (excerpt.next() > 0)
                note.accept("%s: stopped at -max %d after line %d of %d; continue with: %s %s -lines %d-"
                        .formatted(path, query.max(), excerpt.next() - 1, excerpt.total(), Boundary.NAME, path, excerpt.next()));
        }
        return shown;
    }

    static Entity.Excerpt excerpt(Path file, Entity.Ranges ranges, int budget) throws IOException {
        var lines = new ArrayList<String>();
        var total = 0;
        var next = 0;
        try (var reader = new BufferedReader(new InputStreamReader(Files.newInputStream(file), StandardCharsets.UTF_8))) {
            for (var line = reader.readLine(); line != null; line = reader.readLine()) {
                total++;
                if (!ranges.contains(total))
                    continue;
                if (lines.size() < budget)
                    lines.add("%6d\t%s".formatted(total, cut(line)));
                else if (next == 0)
                    next = total;
            }
        }
        return new Entity.Excerpt(List.copyOf(lines), total, next);
    }

    static String cut(String line) {
        if (line.length() <= MAX_WIDTH)
            return line;
        return line.substring(0, MAX_WIDTH) + "…[+" + (line.length() - MAX_WIDTH) + " chars]";
    }

    static boolean binary(Path file) throws IOException {
        try (var stream = Files.newInputStream(file)) {
            return Entity.Scope.binary(stream.readNBytes(HEAD));
        }
    }

    // root and starts are shared verbatim by files, search and 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;
    }
}

interface Entity {

    record Range(int from, int to) {

        static final Range EVERYTHING = new Range(1, Integer.MAX_VALUE);

        static Range of(String value) {
            var matcher = Pattern.compile("(\\d+)(-(\\d*))?").matcher(value);
            if (!matcher.matches())
                throw new IllegalArgumentException("-lines expects a-b, a- or a, got: " + value);
            var from = Integer.parseInt(matcher.group(1));
            var to = matcher.group(3) == null || matcher.group(3).isEmpty() ? Integer.MAX_VALUE : Integer.parseInt(matcher.group(3));
            if (from < 1 || to < from)
                throw new IllegalArgumentException("-lines expects 1 <= a <= b, got: " + value);
            return new Range(from, to);
        }

        boolean contains(int line) {
            return line >= from && line <= to;
        }

        @Override
        public String toString() {
            return to == Integer.MAX_VALUE ? from + "-" : from + "-" + to;
        }
    }

    record Ranges(List<Range> ranges) {

        static final Ranges EVERYTHING = new Ranges(List.of(Range.EVERYTHING));

        static Ranges of(String value) {
            if (value.isBlank())
                throw new IllegalArgumentException("-lines expects a-b, a- or a, got: " + value);
            var ranges = Stream.of(value.split(",", -1)).map(String::strip).map(Range::of).toList();
            return new Ranges(ranges);
        }

        boolean contains(int line) {
            return ranges.stream().anyMatch(range -> range.contains(line));
        }

        @Override
        public String toString() {
            return ranges.stream().map(Range::toString).collect(Collectors.joining(","));
        }
    }

    record Query(List<String> paths, Ranges ranges, int max) {}

    /// next is the first line held back by -max, 0 when nothing was held back
    record Excerpt(List<String> lines, int total, int next) {}

    // Scope is shared verbatim by files, search and 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;
        }
    }
}

// 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.show(List.of(args));
}
