#!/usr/bin/env python3
"""Node.js stream-based upload validation helper.

This script provides a practical, vendor-neutral reference implementation for
validating uploads by inspecting bytes as they arrive. It is intended as a
standalone utility for local testing, policy prototyping, or as a blueprint for
building similar logic into a Node.js upload pipeline.

What it does:
- Enforces a maximum byte limit while reading a stream
- Detects common file types from magic bytes / signatures
- Checks the detected type against an allow-list
- Optionally verifies a small amount of structure for selected formats
- Produces a clear pass/fail result without storing the full file

What it does not do:
- It does not attempt malware scanning
- It does not trust filename extensions or Content-Type headers
- It does not write uploads to disk by default

Usage examples:
  # Validate a local file as if it were an upload stream
  python3 upload_validate.py --input ./sample.pdf --allow pdf --max-bytes 10485760

  # Read from standard input
  cat ./sample.png | python3 upload_validate.py --stdin --allow png

  # Inspect a file and print JSON output
  python3 upload_validate.py --input ./sample.csv --allow csv --json
"""

from __future__ import annotations

import argparse
import json
import os
import sys
from dataclasses import dataclass
from typing import BinaryIO, Dict, Iterable, List, Optional, Tuple


@dataclass
class ValidationResult:
    allowed: bool
    reason: str
    detected_type: Optional[str]
    bytes_read: int


SIGNATURES: Dict[str, List[bytes]] = {
    "png": [b"\x89PNG\r\n\x1a\n"],
    "pdf": [b"%PDF-"],
    "jpg": [b"\xff\xd8\xff"],
    "jpeg": [b"\xff\xd8\xff"],
    "gif": [b"GIF87a", b"GIF89a"],
    "zip": [b"PK\x03\x04"],
    "csv": [],  # CSV is heuristically validated below
}

MAX_SIGNATURE_BYTES = max(len(sig) for sigs in SIGNATURES.values() for sig in sigs) if any(SIGNATURES.values()) else 0


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Validate upload-like byte streams using early, stream-based checks."
    )
    parser.add_argument(
        "--input",
        help="Path to a local file to validate. Use --stdin to read from standard input.",
    )
    parser.add_argument(
        "--stdin",
        action="store_true",
        help="Read bytes from standard input instead of a file.",
    )
    parser.add_argument(
        "--allow",
        action="append",
        dest="allowed_types",
        default=[],
        help="Allowed file type label. Can be provided multiple times. Example: --allow pdf --allow png",
    )
    parser.add_argument(
        "--max-bytes",
        type=int,
        default=10 * 1024 * 1024,
        help="Maximum number of bytes to accept. Default: 10 MiB.",
    )
    parser.add_argument(
        "--json",
        action="store_true",
        help="Print the result as JSON.",
    )
    return parser.parse_args()


def open_source(args: argparse.Namespace) -> BinaryIO:
    if args.stdin:
        if args.input:
            raise ValueError("Use either --input or --stdin, not both.")
        return sys.stdin.buffer

    if not args.input:
        raise ValueError("Provide --input <path> or --stdin.")

    if not os.path.isfile(args.input):
        raise FileNotFoundError(f"Input path does not exist or is not a file: {args.input}")

    return open(args.input, "rb")


def normalize_allowed_types(types: Iterable[str]) -> List[str]:
    normalized = []
    for item in types:
        value = item.strip().lower()
        if not value:
            continue
        normalized.append(value)
    return sorted(set(normalized))


def detect_type(prefix: bytes) -> Optional[str]:
    for file_type, signatures in SIGNATURES.items():
        for signature in signatures:
            if prefix.startswith(signature):
                return file_type

    # Lightweight CSV heuristic: text-like content with commas in early lines.
    if prefix:
        try:
            sample = prefix.decode("utf-8", errors="strict")
        except UnicodeDecodeError:
            return None
        if "," in sample and "\x00" not in sample:
            return "csv"

    return None


def validate_csv_structure(sample: bytes) -> Tuple[bool, str]:
    try:
        text = sample.decode("utf-8", errors="strict")
    except UnicodeDecodeError:
        return False, "CSV validation failed: content is not valid UTF-8 text."

    lines = [line for line in text.splitlines() if line.strip()]
    if len(lines) < 1:
        return False, "CSV validation failed: no non-empty lines found."

    comma_counts = {line.count(",") for line in lines[:10]}
    if len(comma_counts) > 2:
        return False, "CSV validation failed: inconsistent comma structure in initial lines."

    return True, "CSV structure looks acceptable for a lightweight check."


def stream_validate(source: BinaryIO, allowed_types: List[str], max_bytes: int) -> ValidationResult:
    if max_bytes <= 0:
        return ValidationResult(False, "Max bytes must be greater than zero.", None, 0)

    prefix = bytearray()
    total = 0
    chunk_size = 8192

    while True:
        chunk = source.read(chunk_size)
        if not chunk:
            break

        total += len(chunk)
        if total > max_bytes:
            return ValidationResult(False, f"Upload rejected: size limit exceeded ({max_bytes} bytes).", None, total)

        if len(prefix) < max(64, MAX_SIGNATURE_BYTES):
            needed = max(64, MAX_SIGNATURE_BYTES) - len(prefix)
            prefix.extend(chunk[:needed])

    detected = detect_type(bytes(prefix))

    if not detected:
        return ValidationResult(False, "Upload rejected: unable to confirm a supported file signature.", None, total)

    if allowed_types and detected not in allowed_types:
        return ValidationResult(
            False,
            f"Upload rejected: detected type '{detected}' is not in the allow-list: {', '.join(allowed_types)}.",
            detected,
            total,
        )

    if detected == "csv":
        ok, reason = validate_csv_structure(bytes(prefix))
        if not ok:
            return ValidationResult(False, reason, detected, total)

    return ValidationResult(True, "Upload accepted: validation checks passed.", detected, total)


def emit_result(result: ValidationResult, as_json: bool) -> None:
    payload = {
        "allowed": result.allowed,
        "reason": result.reason,
        "detected_type": result.detected_type,
        "bytes_read": result.bytes_read,
    }

    if as_json:
        print(json.dumps(payload, indent=2, sort_keys=True))
    else:
        status = "ACCEPTED" if result.allowed else "REJECTED"
        print(f"{status}: {result.reason}")
        print(f"Detected type: {result.detected_type or 'unknown'}")
        print(f"Bytes read: {result.bytes_read}")


def main() -> int:
    args = parse_args()

    try:
        allowed_types = normalize_allowed_types(args.allowed_types)
        if allowed_types:
            invalid = [item for item in allowed_types if item not in SIGNATURES]
            if invalid:
                raise ValueError(
                    f"Unsupported allow-list type(s): {', '.join(invalid)}. Supported values: {', '.join(sorted(SIGNATURES))}."
                )

        with open_source(args) as source:
            result = stream_validate(source, allowed_types, args.max_bytes)

        emit_result(result, args.json)
        return 0 if result.allowed else 2

    except (ValueError, FileNotFoundError) as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 1
    except KeyboardInterrupt:
        print("ERROR: interrupted by user", file=sys.stderr)
        return 130


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