#!/usr/bin/env python3
"""Review a local JSONL export. No network access, DNS, account calls or writes.

This is an offline evidence reviewer, not middleware or a production trust policy.
Supply the actual transport peer before any address rewriting, not a header value.
"""
import argparse
from collections import Counter
import ipaddress
import json
from pathlib import Path
import sys

MAX_BYTES = 10 * 1024 * 1024
MAX_HEADER = 4096
MAX_HOPS = 32


def address(value):
    if not isinstance(value, str) or '%' in value:
        raise ValueError('An IP literal without a zone or port is required')
    result = ipaddress.ip_address(value.strip())
    if isinstance(result, ipaddress.IPv6Address) and result.ipv4_mapped:
        result = result.ipv4_mapped
    return result


def trusted(ip, networks):
    return any(ip.version == network.version and ip in network for network in networks)


def review(row, line, networks, header):
    result = {'line': line, 'peer_ip': None, 'client_ip': None,
              'status': 'unresolved', 'reason': ''}
    try:
        if not isinstance(row, dict):
            raise ValueError('Expected an object')
        peer = address(row.get('peer_ip'))
    except ValueError:
        result['reason'] = 'invalid_peer'
        return result
    result['peer_ip'] = str(peer)
    if not trusted(peer, networks):
        result.update(client_ip=str(peer), status='transport_peer',
                      reason='forwarded_header_not_trusted')
        return result
    headers = row.get('headers', {})
    if not isinstance(headers, dict):
        result['reason'] = 'invalid_headers_object'
        return result
    values = [value for key, value in headers.items() if key.casefold() == header]
    if len(values) != 1:
        result['reason'] = 'missing_or_duplicate_header'
        return result
    raw = values[0]
    if not isinstance(raw, str) or not raw or len(raw) > MAX_HEADER:
        result['reason'] = 'invalid_header'
        return result
    parts = raw.split(',')
    if len(parts) > MAX_HOPS or (header == 'cf-connecting-ip' and len(parts) != 1):
        result['reason'] = 'invalid_header'
        return result
    try:
        chain = [address(part) for part in parts]
    except ValueError:
        result['reason'] = 'invalid_header'
        return result
    if header == 'cf-connecting-ip':
        client = chain[0]
    else:
        client = next((hop for hop in reversed(chain) if not trusted(hop, networks)), None)
        if client is None:
            result['reason'] = 'no_untrusted_hop'
            return result
    result.update(client_ip=str(client), status='resolved_header', reason='configured_trust_boundary')
    return result


def unique_object(pairs):
    result = {}
    for key, value in pairs:
        if key in result:
            raise ValueError('Duplicate JSON key: ' + key)
        result[key] = value
    return result


def run(args):
    networks = []
    for value in args.trusted_proxy:
        network = ipaddress.ip_network(value, strict=True)
        if network.prefixlen == 0:
            raise ValueError('A trust-all network is not supported')
        if isinstance(network, ipaddress.IPv6Network) and network.subnet_of(ipaddress.ip_network('::ffff:0:0/96')):
            raise ValueError('Use IPv4 CIDRs for IPv4-mapped peers')
        networks.append(network)
    path = Path(args.input)
    if not path.is_file():
        raise ValueError('Input must be a local regular file')
    with path.open('rb') as source:
        raw = source.read(MAX_BYTES + 1)
    if len(raw) > MAX_BYTES:
        raise ValueError('Select an export window of at most 10 MiB')
    records = []
    normalized = []
    for line, text in enumerate(raw.decode('utf-8-sig').splitlines(), 1):
        if not text.strip():
            continue
        try:
            row = json.loads(text, object_pairs_hook=unique_object)
        except ValueError as error:
            raise ValueError(f'Invalid JSON at line {line}: {error}') from error
        result = review(row, line, networks, args.header)
        records.append(result)
        target = row.get('request_target') if isinstance(row, dict) else None
        # Only resolved headers and GET origin-form targets reach builder output.
        # Never substitute a trusted proxy peer for a missing client observation.
        if result['status'] == 'resolved_header' and row.get('method') == 'GET' and isinstance(target, str):
            if target.startswith('/') and not target.startswith('//') and not any(ch.isspace() for ch in target):
                normalized.append(f"{result['client_ip']} GET {target}")
    counts = Counter(record['status'] for record in records)
    return {'header': args.header, 'trusted_proxy_cidrs': [str(n) for n in networks],
            'summary': {'records': len(records), 'resolved_header': counts['resolved_header'],
                        'transport_peer': counts['transport_peer'], 'unresolved': counts['unresolved'],
                        'builder_lines': len(normalized)},
            'records': records, 'builder_lines': normalized}


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('input', help='local JSONL export; not a URL')
    parser.add_argument('--header', choices=['x-forwarded-for', 'cf-connecting-ip'], required=True)
    parser.add_argument('--trusted-proxy', action='append', default=[], help='repeat for each verified transport-proxy CIDR')
    parser.add_argument('--format', choices=['json', 'builder'], default='json')
    args = parser.parse_args(argv)
    try:
        report = run(args)
    except (ValueError, OSError, UnicodeError) as error:
        print(str(error), file=sys.stderr)
        return 2
    if args.format == 'builder':
        print('\n'.join(report['builder_lines']))
        print(json.dumps(report['summary']), file=sys.stderr)
    else:
        print(json.dumps(report, indent=2))
    return 0


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