#!/usr/bin/env python3
"""Optional client-side HTTPS/TCP and HTTP CONNECT/SOCKS5 diagnostics; stdlib only."""
import argparse
import base64
import getpass
import http.client
import ipaddress
import json
import socket
import ssl
import statistics
import time
from datetime import datetime, timezone
from urllib.parse import urlsplit

VERSION="20261107.1"
TARGETS=["ru.desample.space","ru.desample.ru","desample.space","sub.desample.space"]

def recv_exact(sock,size):
    data=b""
    while len(data)<size:
        chunk=sock.recv(size-len(data))
        if not chunk: raise OSError("unexpected_eof")
        data+=chunk
    return data

def socks5(sock,host,port,username,password):
    sock.sendall(b"\x05\x01"+(b"\x02" if username else b"\x00"))
    version,method=recv_exact(sock,2)
    if version!=5: raise OSError("invalid_socks_version")
    if method==2 and username:
        u=username.encode();p=password.encode()
        if len(u)>255 or len(p)>255: raise ValueError("credentials_too_long")
        sock.sendall(b"\x01"+bytes([len(u)])+u+bytes([len(p)])+p)
        if recv_exact(sock,2)!=b"\x01\x00": raise OSError("proxy_authentication_failed")
    elif method!=0: raise OSError("proxy_authentication_failed")
    h=host.encode("idna")
    sock.sendall(b"\x05\x01\x00\x03"+bytes([len(h)])+h+port.to_bytes(2,"big"))
    reply=recv_exact(sock,4)
    if reply[:3]!=b"\x05\x00\x00": raise OSError("proxy_connect_failed")
    if reply[3]==1: recv_exact(sock,4)
    elif reply[3]==4: recv_exact(sock,16)
    elif reply[3]==3: recv_exact(sock,recv_exact(sock,1)[0])
    else: raise OSError("invalid_proxy_reply")
    recv_exact(sock,2)

def http_connect(sock,host,port,username,password):
    request="CONNECT %s:%s HTTP/1.1\r\nHost: %s:%s\r\n"%(host,port,host,port)
    if username:
        value=base64.b64encode((username+":"+password).encode()).decode()
        request+="Proxy-Authorization: Basic "+value+"\r\n"
    sock.sendall((request+"\r\n").encode("ascii"))
    data=b""
    while not data.endswith(b"\r\n\r\n"):
        data+=recv_exact(sock,1)
        if len(data)>16384: raise OSError("proxy_headers_too_large")
    first=data.split(b"\r\n",1)[0].split()
    if len(first)<2 or first[1]!=b"200": raise OSError("proxy_connect_failed")

def measure(host,proxy=None):
    start=time.perf_counter();sock=None
    result={"target":host,"origin":"client","https_verified":False,"ok":False}
    try:
        if proxy:
            kind,ph,pp,user,pw=proxy
            sock=socket.create_connection((ph,pp),timeout=6)
            if kind=="socks5": socks5(sock,host,443,user,pw)
            else: http_connect(sock,host,443,user,pw)
            result["via_user_proxy"]=kind
        else:
            dns_start=time.perf_counter()
            addresses=socket.getaddrinfo(host,443,type=socket.SOCK_STREAM)
            result["dns_ms"]=round((time.perf_counter()-dns_start)*1000,1)
            result["address_families"]=sorted({"IPv4" if x[0]==socket.AF_INET else "IPv6" for x in addresses})
            sock=socket.create_connection((host,443),timeout=6)
        result["connect_ms"]=round((time.perf_counter()-start)*1000,1)
        tls=time.perf_counter();sock=ssl.create_default_context().wrap_socket(sock,server_hostname=host)
        result["tls_ms"]=round((time.perf_counter()-tls)*1000,1);result["https_verified"]=True
        path="/api/ping" if host.startswith("ru.") else "/_diagnostics/ping"
        sock.sendall(("GET "+path+" HTTP/1.1\r\nHost: "+host+"\r\nConnection: close\r\nAccept: application/json\r\n\r\n").encode())
        response=http.client.HTTPResponse(sock);response.begin();body=response.read(65536)
        payload=json.loads(body)
        result["http_status"]=response.status
        result["ok"]=response.status==200 and payload.get("service")=="desample-diagnostics" and payload.get("ok") is True
    except Exception as exc:
        # Never serialize exception messages: URLs and credentials may occur there.
        result["error_class"]=type(exc).__name__
    finally:
        if sock:
            try:sock.close()
            except OSError:pass
    result["total_ms"]=round((time.perf_counter()-start)*1000,1)
    return result

def main():
    parser=argparse.ArgumentParser(description="Диагностика с вашего компьютера. Python 3, без установки библиотек.")
    parser.add_argument("--proxy",choices=["http","socks5"],help="Необязательная реальная HTTPS-проверка через HTTP CONNECT или SOCKS5; адрес/пароль вводятся скрыто.")
    parser.add_argument("--output",help="Сохранить очищенный JSON; иначе вывести в терминал.")
    args=parser.parse_args();proxy=None
    if args.proxy:
        address=getpass.getpass("Адрес вашего прокси host:port (в отчёт не попадёт): ")
        parsed=urlsplit("//"+address)
        if not parsed.hostname or not parsed.port or parsed.username or parsed.password or parsed.path:parser.error("Введите только host:port; учётные данные вводятся отдельно.")
        user=getpass.getpass("Имя пользователя (Enter, если не требуется): ")
        password=getpass.getpass("Пароль: ") if user else ""
        proxy=(args.proxy,parsed.hostname,parsed.port,user,password)
    report={"tool":"DeSample client diagnostics","version":VERSION,"created_at":datetime.now(timezone.utc).isoformat(),
        "measurement_origin":"client","proxy_credentials_included":False,
        "scope":"HTTPS/TLS and optional HTTP CONNECT/SOCKS5; does not test MTProto authentication or control VPN routing",
        "results":[measure(host,proxy) for host in TARGETS]}
    text=json.dumps(report,ensure_ascii=False,indent=2)
    if args.output:
        with open(args.output,"x",encoding="utf-8") as f:f.write(text+"\n")
        print("Отчёт сохранён.")
    else:print(text)

if __name__=="__main__":main()
