#!/usr/bin/env python3
"""logfold - collapse runs of repeated log lines.

Reads files (or standard input) and prints each run of identical lines once,
followed by a repeat count. Lines can be compared after masking the parts that
always differ, such as timestamps and numeric ids.

MIT License. Python 3.8 or newer, standard library only.
"""
import argparse
import re
import sys

__version__ = "0.9.4"

# Masks applied by --normalize, in order.
_MASKS = [
    (re.compile(r"\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?(?:Z|[+-]\d{2}:?\d{2})?"), "<ts>"),
    (re.compile(r"\b\d{2}:\d{2}:\d{2}(?:[.,]\d+)?\b"), "<time>"),
    (re.compile(r"\b0x[0-9a-fA-F]+\b"), "<hex>"),
    (re.compile(r"\b\d+\b"), "<n>"),
]


def mask(line):
    for pattern, repl in _MASKS:
        line = pattern.sub(repl, line)
    return line


def fold(lines, normalize=False, min_run=2):
    """Yield (line, count) for every run of equal lines.

    The first line of a run is the one that is kept. Runs shorter than
    min_run are yielded one line at a time with a count of 1.
    """
    last_key = None
    first = None
    count = 0
    for line in lines:
        line = line.rstrip("\n")
        key = mask(line) if normalize else line
        if count and key == last_key:
            count += 1
            continue
        yield from _flush(first, count, min_run)
        first, last_key, count = line, key, 1
    yield from _flush(first, count, min_run)


def _flush(line, count, min_run):
    if not count:
        return
    if count < min_run:
        for _ in range(count):
            yield line, 1
    else:
        yield line, count


def main(argv=None):
    ap = argparse.ArgumentParser(prog="logfold", description="Collapse runs of repeated log lines.")
    ap.add_argument("files", nargs="*", help="input files; standard input if none")
    ap.add_argument("-n", "--normalize", action="store_true",
                    help="mask timestamps, hex values and numbers before comparing")
    ap.add_argument("-m", "--min", type=int, default=2, metavar="N",
                    help="fold only runs of at least N lines (default: 2)")
    ap.add_argument("-c", "--count-first", action="store_true",
                    help="print the count before the line instead of after it")
    ap.add_argument("-V", "--version", action="version", version="logfold " + __version__)
    args = ap.parse_args(argv)
    if args.min < 2:
        ap.error("--min must be 2 or more")

    def source():
        if not args.files:
            sys.stdin.reconfigure(errors="replace")
            yield from sys.stdin
            return
        for name in args.files:
            with open(name, encoding="utf-8", errors="replace") as fh:
                yield from fh

    out = sys.stdout
    try:
        for line, count in fold(source(), args.normalize, args.min):
            if count == 1:
                out.write(line + "\n")
            elif args.count_first:
                out.write("%7d  %s\n" % (count, line))
            else:
                out.write("%s  (x%d)\n" % (line, count))
    except BrokenPipeError:
        # Downstream (for example head) closed early; that is not an error.
        sys.stderr.close()
        return 0
    except OSError as exc:
        sys.stderr.write("logfold: %s\n" % exc)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
