#!/usr/bin/env python3
"""Filter Nginx JSONL records without rewriting matched input bytes. MIT license."""
import argparse
from decimal import Decimal, InvalidOperation
import json
import os
import re
import sys

__version__ = "0.1.0"
DECIMAL_TEXT = re.compile(r"[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?")


class RecordError(ValueError):
    """A record cannot be interpreted unambiguously."""


def decimal_seconds(value):
    if isinstance(value, str):
        if not DECIMAL_TEXT.fullmatch(value):
            raise RecordError("epoch must be finite decimal Unix seconds")
        try:
            value = Decimal(value)
        except InvalidOperation:
            raise RecordError("epoch is outside the supported decimal range") from None
    if not isinstance(value, Decimal) or not value.is_finite():
        raise RecordError("epoch must be finite decimal Unix seconds")
    return value


def argument_seconds(value):
    try:
        return decimal_seconds(value)
    except RecordError as error:
        raise argparse.ArgumentTypeError(str(error)) from None


def nonempty(value):
    if not value:
        raise argparse.ArgumentTypeError("value must not be empty")
    return value


def unique_object(pairs):
    result = {}
    for key, value in pairs:
        if key in result:
            raise RecordError("duplicate JSON object key")
        result[key] = value
    return result


def reject_constant(_value):
    raise RecordError("non-standard JSON numeric constant")


def load_record(raw):
    try:
        text = raw.decode("utf-8")
    except UnicodeDecodeError:
        raise RecordError("invalid UTF-8") from None
    try:
        record = json.loads(text, parse_float=Decimal, parse_int=Decimal,
                            parse_constant=reject_constant, object_pairs_hook=unique_object)
    except json.JSONDecodeError as error:
        raise RecordError(f"invalid JSON at column {error.colno}") from None
    except (RecursionError, InvalidOperation):
        raise RecordError("JSON nesting or number exceeds parser limits") from None
    if not isinstance(record, dict):
        raise RecordError("record must be a JSON object")
    for field in ("host", "uri"):
        if not isinstance(record.get(field), str) or not record[field]:
            raise RecordError(f"{field} must be a nonempty string")
    if "epoch" not in record:
        raise RecordError("missing epoch field")
    return record, decimal_seconds(record["epoch"])


def filter_stream(source, destination, label, args):
    hosts, uris = set(args.host), set(args.uri)
    for number, raw in enumerate(source, 1):
        try:
            record, timestamp = load_record(raw)
        except RecordError as error:
            print(f"request-sieve: {label}:{number}: {error}", file=sys.stderr)
            destination.flush()
            return 1
        if hosts and record["host"] not in hosts:
            continue
        if uris and record["uri"] not in uris:
            continue
        if args.since is not None and timestamp < args.since:
            continue
        if args.before is not None and timestamp >= args.before:
            continue
        destination.write(raw)
    destination.flush()
    return 0


def main(argv=None):
    parser = argparse.ArgumentParser(
        prog="request-sieve",
        description="Select Nginx JSONL records; emit matching input bytes unchanged.",
        epilog="Records require host, uri and epoch. Errors may leave partial stdout.")
    parser.add_argument("input", nargs="?", default="-", help="UTF-8 JSONL file; default: stdin")
    parser.add_argument("--host", action="append", default=[], type=nonempty,
                        help="exact host; repeat for alternatives")
    parser.add_argument("--uri", action="append", default=[], type=nonempty,
                        help="exact raw path and query; repeat for alternatives")
    parser.add_argument("--since", type=argument_seconds,
                        help="inclusive lower bound in decimal Unix seconds")
    parser.add_argument("--before", type=argument_seconds,
                        help="exclusive upper bound in decimal Unix seconds")
    parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
    args = parser.parse_args(argv)
    if args.since is not None and args.before is not None and args.since >= args.before:
        parser.error("--before must be greater than --since")
    label = "<stdin>" if args.input == "-" else args.input
    try:
        if args.input == "-":
            return filter_stream(sys.stdin.buffer, sys.stdout.buffer, label, args)
        with open(args.input, "rb") as source:
            return filter_stream(source, sys.stdout.buffer, label, args)
    except BrokenPipeError:
        # Prevent a second error when Python flushes stdout during shutdown.
        with open(os.devnull, "wb") as sink:
            os.dup2(sink.fileno(), sys.stdout.fileno())
        return 0
    except OSError as error:
        print(f"request-sieve: {label}: I/O error: {error.strerror or 'operation failed'}",
              file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())
