#!/usr/bin/env python3
"""NETSCOPE local route collector. Python 3.9+, standard library only.
Runs your OS traceroute/tracert and ping; writes an importable JSON report.
No background service, no uploads, no shell interpolation.
"""
import argparse
import datetime
import ipaddress
import json
import locale
import platform
import re
import shutil
import socket
import statistics
import subprocess
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path


def normalize_target(value):
    value = value.strip()
    try:
        return str(ipaddress.ip_address(value.strip('[]')))
    except ValueError:
        pass
    parsed = urllib.parse.urlsplit(value if '://' in value else 'https://' + value)
    if parsed.scheme not in ('https', 'http') or parsed.username or parsed.password:
        raise ValueError('Use an IP address or HTTP(S) hostname without credentials.')
    host = (parsed.hostname or '').encode('idna').decode('ascii')
    try:
        return str(ipaddress.ip_address(host))
    except ValueError:
        pass
    if len(host) > 253 or not re.fullmatch(r'(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?', host):
        raise ValueError('Invalid hostname or IP address.')
    if any(not label or len(label) > 63 or label.startswith('-') or label.endswith('-') for label in host.split('.')):
        raise ValueError('Invalid hostname label.')
    return host


def run_command(args, timeout):
    try:
        p = subprocess.run(args, capture_output=True, text=True,
                           encoding=locale.getpreferredencoding(False), errors='replace',
                           timeout=timeout, shell=False)
        return {'command': args[0], 'returnCode': p.returncode,
                'raw': (p.stdout + '\n' + p.stderr).strip(), 'timedOut': False}
    except subprocess.TimeoutExpired as e:
        out = e.stdout or ''
        if isinstance(out, bytes):
            out = out.decode(locale.getpreferredencoding(False), errors='replace')
        return {'command': args[0], 'returnCode': None, 'raw': out, 'timedOut': True}
    except OSError as e:
        return {'command': args[0], 'returnCode': None, 'raw': str(e), 'timedOut': False}


def parse_hops(raw):
    hops = []
    for line in raw.splitlines():
        m = re.match(r'^\s*(\d{1,2})\s+(.+)$', line)
        if not m:
            continue
        hop = int(m.group(1))
        if not 1 <= hop <= 64:
            continue
        rest = m.group(2)
        addresses = []
        for token in re.split(r'\s+', rest):
            token = token.strip('[](),')
            try:
                address = str(ipaddress.ip_address(token))
                if address not in addresses:
                    addresses.append(address)
            except ValueError:
                pass
        samples = [float(v.replace(',', '.')) for v in re.findall(r'<?\s*(\d+(?:[.,]\d+)?)\s*(?:ms|мс)\b', rest, re.I)]
        # <1 ms from Windows is an upper bound, not an exact zero.
        hops.append({'hop': hop, 'addresses': addresses, 'rttMs': samples,
                     'medianMs': statistics.median(samples) if samples else None,
                     'noReplyProbes': rest.count('*'),
                     'status': 'reply' if addresses or samples else 'no-reply', 'raw': line.strip()})
    return hops


def parse_ping(raw):
    m = re.search(r'(\d+(?:[.,]\d+)?)%\s*(?:packet loss|loss|потер)', raw, re.I)
    times = [float(v.replace(',', '.')) for v in re.findall(r'(?:time|время)\s*[=<]\s*(\d+(?:[.,]\d+)?)\s*(?:ms|мс)', raw, re.I)]
    return {'lossPercent': float(m.group(1).replace(',', '.')) if m else None,
            'rttMs': times, 'medianMs': statistics.median(times) if times else None}


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('target', help='IP address or hostname, for example example.com')
    parser.add_argument('--output', default='netscope-route-report.json')
    parser.add_argument('--max-hops', type=int, default=24, choices=range(1, 33))
    args = parser.parse_args()
    try:
        host = normalize_target(args.target)
    except ValueError as e:
        parser.error(str(e))
    report = {'schema': 'netscope-route-v1', 'date': datetime.datetime.now(datetime.timezone.utc).isoformat(),
              'target': host, 'source': 'local-computer', 'system': platform.system(),
              'dns': {'addresses': []}, 'hops': [], 'ping': {}, 'http': {}, 'warnings': []}
    print('Resolving:', host, flush=True)
    try:
        addresses = list(dict.fromkeys(a[4][0] for a in socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)))
        report['dns']['addresses'] = addresses
    except socket.gaierror as e:
        report['dns']['error'] = str(e)
        addresses = []
    if addresses:
        address = next((a for a in addresses if ':' not in a), addresses[0])
        v6 = ':' in address
        system = platform.system()
        trace = shutil.which('tracert') if system == 'Windows' else shutil.which('traceroute6' if v6 and system == 'Darwin' else 'traceroute')
        if trace:
            cmd = [trace, '-d', '-h', str(args.max_hops), '-w', '750', address] if system == 'Windows' else [trace, '-n', '-m', str(args.max_hops), '-q', '3', '-w', '1', address]
            if v6 and system not in ('Darwin', 'Windows'):
                cmd.insert(1, '-6')
            print('Tracing route to', address, '(up to 85 seconds)…', flush=True)
            result = run_command(cmd, 85)
            report['trace'] = result
            report['hops'] = parse_hops(result['raw'])
            if result['timedOut']:
                report['warnings'].append('Traceroute exceeded its time budget; route may be incomplete.')
        else:
            report['warnings'].append('traceroute/tracert not installed. Linux: install the traceroute package with your package manager. macOS and Windows normally include the native command.')
        ping = shutil.which('ping')
        if ping:
            cmd = [ping, '-n', '4', '-w', '2000', address] if system == 'Windows' else [ping, '-c', '4', '-W', '2000' if system == 'Darwin' else '2', address]
            if v6 and system == 'Darwin':
                cmd[0] = shutil.which('ping6') or ping
            elif v6 and system != 'Windows':
                cmd.insert(1, '-6')
            print('Collecting end-to-end ICMP replies…', flush=True)
            result = run_command(cmd, 15)
            report['ping'] = {**parse_ping(result['raw']), 'raw': result['raw']}
        print('Checking HTTPS HEAD…', flush=True)
        start = time.perf_counter()
        try:
            hostname = '[' + host + ']' if ':' in host else host
            req = urllib.request.Request('https://' + hostname + '/', method='HEAD', headers={'User-Agent': 'NETSCOPE-Local/1.2'})
            with urllib.request.urlopen(req, timeout=6) as response:
                report['http'] = {'status': response.status, 'rttMs': (time.perf_counter()-start)*1000}
        except urllib.error.HTTPError as e:
            report['http'] = {'status': e.code, 'rttMs': (time.perf_counter()-start)*1000}
        except Exception as e:
            report['http'] = {'error': str(e)}
    report['warnings'].append('An intermediate hop with no reply or a high RTT does not prove packet loss or a bottleneck. Routers may rate-limit ICMP. Link speeds are not measured by traceroute.')
    output = Path(args.output).expanduser()
    output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
    print('Saved:', output.resolve())
    print('Import this JSON in NETSCOPE → Site / IP → Import route.')


if __name__ == '__main__':
    main()
