#!/usr/bin/env python3
"""NetCheck CLI – Cloudflare HTTPS bandwidth test from Linux.

Speed-test phases, caps and concurrency match NetCheck's browser engine.
Browser Cloudflare latency library is unavailable in Python: this CLI shows
HTTP first-byte timing separately, not Ookla ping or the browser's library ping.
Python >=3.9; standard library only. Ookla is an explicitly selected reference.
"""
import argparse
import http.client
import json
from collections import deque
import os
import re
import shutil
import statistics
import subprocess
import sys
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor

VERSION = "1.8.0"
HOST = "speed.cloudflare.com"
AGENT = "BorgliByte-NetCheck-CLI/" + VERSION

# Canonical NetCheck web engine values, from reference-engine.js
DOWNLOAD_SECONDS = 10
UPLOAD_SECONDS = 8
DOWNLOAD_RAMP = 2.0
UPLOAD_RAMP = 1.5
DOWNLOAD_CAP_BYTES = 600_000_000
UPLOAD_CAP_BYTES = 150_000_000
DOWNLOAD_REQUEST_BYTES = 100_000_000
UPLOAD_REQUEST_BYTES = 10_000_000
STREAMS = 6


def describe(values):
    if not values:
        return None
    return {
        "median_ms": round(statistics.median(values), 1),
        "min_ms": round(min(values), 1),
        "max_ms": round(max(values), 1),
        "jitter_ms": round(statistics.mean(abs(b-a) for a,b in zip(values,values[1:])),1) if len(values)>1 else None,
        "samples":len(values),
    }


def cloudflare_server_ms(header):
    """Match Cloudflare Speedtest JS cfGetServerTime (Server-Timing)."""
    if not header:
        return None
    match=re.search(r"(?:^|,\s*)cfReq(?:uest)?Dur(?:ation)?;\s*dur=([0-9.]+)",header,re.I)
    if match and float(match.group(1))>0.01:
        return float(match.group(1))
    found=re.findall(r"(?:^|,\s*)cfSpeed[a-zA-Z]*;\s*dur=([0-9.]+)",header,re.I)
    total=sum(float(v) for v in found)
    return total if total>0.01 else None


class RatePacer:
    """Shared byte pacing across all test workers; changes NO OS configuration.

    This limits only NetCheck test traffic. It cannot implement system-wide
    CAKE/SQM or guarantee reduced queueing for other applications.
    """
    def __init__(self, mbps):
        if not 0 < mbps < 100000:
            raise ValueError("Invalid requested test speed")
        self.bytes_per_second=mbps*1_000_000/8
        self.lock=threading.Lock()
        self.next_at=time.monotonic()

    def wait(self, count, stop):
        with self.lock:
            now=time.monotonic()
            ready=max(self.next_at,now)
            self.next_at=ready+count/self.bytes_per_second
        remaining=ready-time.monotonic()
        while remaining>0 and not stop.is_set():
            if stop.wait(min(.05,remaining)):break
            remaining=ready-time.monotonic()


class HTTPClient:
    def __init__(self, timeout=3):
        self.timeout=timeout
        self.conn=None

    def close(self):
        if self.conn is not None:
            try:self.conn.close()
            except OSError:pass
        self.conn=None

    def connection(self):
        if self.conn is None:
            self.conn=http.client.HTTPSConnection(HOST,timeout=self.timeout)
        return self.conn

    def headers(self, upload=False):
        headers={
            "User-Agent":AGENT,
            "Cache-Control":"no-store",
            "Accept-Encoding":"identity",
            # Browser requests to the same public NetCheck test endpoint include
            # the site's HTTP Origin/Referer. Cloudflare currently accepts large
            # downloads with these headers, and returns 403 without them.
            "Origin":"https://netcheck.borglibyte.no",
            "Referer":"https://netcheck.borglibyte.no/",
        }
        if upload: headers["Content-Type"]="application/octet-stream"
        return headers

    def download(self, size, record, stop, pacer=None):
        # One 100MB streamed GET per active connection, as in browser.
        conn=self.connection()
        path=f"/__down?bytes={size}&r={uuid.uuid4().hex}"
        try:
            conn.request("GET",path,headers=self.headers())
            response=conn.getresponse()
            if response.status!=200:
                raise OSError(f"HTTP {response.status} from Cloudflare download")
            while not stop.is_set():
                if pacer is not None:pacer.wait(131072,stop)
                if stop.is_set():break
                block=response.read(131072)
                if not block:break
                record(len(block))
            if stop.is_set():
                self.close()
            else:
                response.close()
        except (OSError,http.client.HTTPException):
            self.close()
            raise

    def upload(self, payload, record, stop, pacer=None):
        # Stream 10MB POST and measure bytes sent as upload progresses.
        # Like browser XHR progress: progress is counted during the upload,
        # not only after a whole HTTP transaction completes.
        conn=self.connection()
        path=f"/__up?r={uuid.uuid4().hex}"
        try:
            conn.putrequest("POST",path)
            for name,value in self.headers(upload=True).items():
                conn.putheader(name,value)
            conn.putheader("Content-Length",str(len(payload)))
            conn.endheaders()
            view=memoryview(payload)
            for offset in range(0,len(payload),131072):
                if stop.is_set():break
                chunk=view[offset:offset+131072]
                if pacer is not None:pacer.wait(len(chunk),stop)
                if stop.is_set():break
                conn.send(chunk)
                record(len(chunk))
            if stop.is_set():
                self.close()
                return
            response=conn.getresponse()
            if response.status!=200:
                raise OSError(f"HTTP {response.status} from Cloudflare upload")
            response.read()
        except (OSError,http.client.HTTPException):
            self.close()
            raise

    def http_ping(self, details=False):
        # Python's monotonic clock sees HTTP request-to-response-headers.
        # The browser library uses ResourceTiming requestStart/responseStart,
        # minus Cloudflare Server-Timing (cfSpeedEdge/Worker) instead.
        conn=self.connection()
        path="/__down?bytes=0&r="+uuid.uuid4().hex
        try:
            start=time.monotonic()
            conn.request("GET",path,headers=self.headers())
            response=conn.getresponse()
            elapsed=(time.monotonic()-start)*1000
            if response.status!=200:raise OSError(f"HTTP {response.status} from Cloudflare ping")
            server_ms=cloudflare_server_ms(response.getheader("Server-Timing"))
            response.read()
            if details:
                adjusted=max(0,elapsed-server_ms) if server_ms is not None else None
                return {"http_ms":elapsed,"server_ms":server_ms,"ping_ms":adjusted}
            return elapsed
        except (OSError,http.client.HTTPException):
            self.close()
            raise


def idle_ping():
    client=HTTPClient()
    times=[]
    errors=[]
    try:
        try:client.http_ping()   # Warm-up TLS
        except (OSError,http.client.HTTPException):pass
        for i in range(8):
            try:times.append(client.http_ping())
            except (OSError,http.client.HTTPException) as exc:errors.append(str(exc))
            if i!=7:time.sleep(.2)
    finally:
        client.close()
    return {"summary":describe(times) if len(times)>=4 else None,
            "samples":len(times),"errors":errors}



def cloudflare_ping():
    """2 warm-up + 20 probes; median and mean successive absolute delta."""
    client=HTTPClient()
    values=[]
    raw_http=[]
    server_times=[]
    errors=0
    missing_timing=0
    try:
        # Match website: warmup of two latency requests before 20 samples.
        for i in range(22):
            try:
                sample=client.http_ping(details=True)
                if i>=2:
                    raw_http.append(sample["http_ms"])
                    if sample["ping_ms"] is None:
                        missing_timing+=1
                    else:
                        values.append(sample["ping_ms"])
                        server_times.append(sample["server_ms"])
            except (OSError,http.client.HTTPException):
                if i>=2:errors+=1
    finally:
        client.close()
    # Do not label unadjusted timings 'ping' when server timing isn't available.
    return {"ping":describe(values) if len(values)>=10 and missing_timing==0 else None,
            "raw_http":describe(raw_http),"samples":len(values),
            "server_time":describe(server_times),
            "missing_server_timing":missing_timing,"errors":errors,
            "method":"Cloudflare Server-Timing subtracted from persistent HTTPS response time"}

def measure_phase(direction, seconds, workers, cap, request_size, ramp, on_progress=None, target_mbps=None):
    origin=time.monotonic()
    end=origin+seconds
    stop=threading.Event()
    lock=threading.Lock()
    state={"total":0,"ramp_bytes":None,"ramp_time":None,"errors":[]}
    loaded_http=[]
    loaded_adjusted=[]
    ping_errors=[]
    missing_timing=0
    payload=bytes(request_size) if direction=="upload" else None
    pacer=RatePacer(target_mbps) if target_mbps is not None else None

    def record_bytes(n):
        if n<=0:return
        now=time.monotonic()
        with lock:
            state["total"]+=n
            if state["total"]>=cap:
                stop.set()

    def ping_task():
        nonlocal missing_timing
        client=HTTPClient()
        try:
            # Warm up the independent TLS connection before collecting samples.
            try:client.http_ping(details=True)
            except (OSError,http.client.HTTPException):pass
            while not stop.is_set() and time.monotonic()<end:
                try:
                    sample=client.http_ping(details=True)
                    # Store both values from the SAME request so the comparison
                    # can use identical sampling, unlike the earlier raw-only code.
                    loaded_http.append(sample["http_ms"])
                    if sample["ping_ms"] is None:
                        missing_timing+=1
                    else:
                        loaded_adjusted.append(sample["ping_ms"])
                except (OSError,http.client.HTTPException) as exc:
                    ping_errors.append(str(exc))
                if stop.wait(.35):break
        finally:client.close()

    def bandwidth_task():
        client=HTTPClient()
        try:
            while not stop.is_set() and time.monotonic()<end:
                with lock:
                    total=state["total"]
                if total>=cap:
                    stop.set()
                    break
                try:
                    if direction=="download":client.download(request_size,record_bytes,stop,pacer=pacer)
                    else:client.upload(payload,record_bytes,stop,pacer=pacer)
                except (OSError,http.client.HTTPException) as exc:
                    with lock:state["errors"].append(str(exc))
                    stop.set()
                    return
        finally:client.close()

    ping_thread=threading.Thread(target=ping_task,daemon=True)
    ping_thread.start()
    with ThreadPoolExecutor(max_workers=workers) as executor:
        futures=[executor.submit(bandwidth_task) for _ in range(workers)]
        # Rolling 1-second display rate: display only; the final figure uses
        # the original measured byte delta and exact post-ramp time.
        history=deque([(origin,0)])
        next_update=origin
        ramp_captured=False
        try:
            while not stop.is_set() and time.monotonic()<end:
                now=time.monotonic()
                with lock:
                    total=state["total"]
                if not ramp_captured and now>=origin+ramp:
                    with lock:
                        state["ramp_bytes"]=state["total"]
                        state["ramp_time"]=now
                    ramp_captured=True
                history.append((now,total))
                while len(history)>2 and history[1][0]<now-1.0:
                    history.popleft()
                if on_progress and now>=next_update:
                    dt=history[-1][0]-history[0][0]
                    live=8*(history[-1][1]-history[0][1])/dt/1e6 if dt>0.2 else 0.0
                    try:on_progress(min(now-origin,seconds),seconds,live,not ramp_captured)
                    except Exception:pass  # Presentation must never break measurement.
                    next_update=now+.25
                if total>=cap:
                    stop.set()
                    break
                if all(f.done() for f in futures):break
                time.sleep(.04)
            stop.set()
            for future in futures:future.result()
        finally:
            if on_progress:
                try:on_progress(None,seconds,None,False)
                except Exception:pass
    if not ramp_captured:
        with lock:
            state["ramp_bytes"]=state["total"]
            state["ramp_time"]=time.monotonic()
    phase_end=time.monotonic()
    ping_thread.join(timeout=4)
    with lock:
        total=state["total"]
        base=state["ramp_bytes"]
        errors=list(state["errors"])
    measured_time=max(0,(min(phase_end,end)-state["ramp_time"]))
    measured_bytes=max(0,total-base)
    if errors:
        raise RuntimeError(direction+": Cloudflare HTTP-feil: "+", ".join(errors[:3])+
                           (f" ({len(errors)} feil totalt)" if len(errors)>3 else ""))
    if measured_time<0.5 or measured_bytes<50_000:
        raise RuntimeError(direction+": datagrensen ble nådd før det fantes et tilstrekkelig målevindu; resultat forkastes")
    return {
        "mbps":round(measured_bytes*8/measured_time/1_000_000,1),
        "bytes":total,
        "measured_bytes":measured_bytes,
        "seconds":round(measured_time,2),
        "http_loaded":describe(loaded_http) if len(loaded_http)>=2 else None,
        "http_loaded_count":len(loaded_http),
        # At least 3 valid requests are needed for a useful loaded estimate.
        # Never substitute raw HTTP for missing Server-Timing-adjusted ping.
        "ping_loaded":describe(loaded_adjusted)
            if len(loaded_adjusted)>=3 and missing_timing==0 else None,
        "ping_loaded_count":len(loaded_adjusted),
        "missing_server_timing":missing_timing,
        "http_ping_errors":len(ping_errors),
        "time_limit_seconds":seconds,
        "cap_bytes":cap,
        "streams":workers,
        "data_limit_reached": total>=cap,
        "short_measurement": measured_time<2.0,
        "target_mbps":round(target_mbps,1) if target_mbps is not None else None,
    }



class LiveProgress:
    """One updating terminal line on TTY, clean periodic lines otherwise."""
    def __init__(self,label,stream=None):
        self.label=label
        self.stream=stream if stream is not None else sys.stdout
        self.tty=bool(getattr(self.stream,"isatty",lambda:False)())
        self.last_width=0
        self.last_second=-1
        self.closed=False

    def __call__(self,elapsed,total,mbps,warmup):
        if self.closed:return
        if elapsed is None:
            if self.tty and self.last_width:
                self.stream.write("\n")
            self.stream.flush()
            self.closed=True
            return
        elapsed=max(0.0,float(elapsed))
        rate=max(0.0,float(mbps))
        if not self.tty and int(elapsed)//2==self.last_second:
            return
        if not self.tty:self.last_second=int(elapsed)//2
        bar_width=16
        fraction=min(1.0,elapsed/max(.01,total))
        filled=min(bar_width,int(fraction*bar_width))
        bar="█"*filled+"░"*(bar_width-filled) if self.tty else "#"*filled+"-"*(bar_width-filled)
        phase="klargjør" if warmup else "måler"
        message=f"  {self.label:<11} [{bar}] {elapsed:4.1f}/{total:g} s  {rate:6.1f} Mbit/s  {phase}"
        if self.tty:
            self.stream.write("\r"+message+" "*max(0,self.last_width-len(message)))
            self.last_width=len(message)
        else:
            self.stream.write(message+"\n")
        self.stream.flush()

def cloudflare_measure(show=True,ping_only=False,targets=None):
    if show:
        print("\nNetCheck CLI "+VERSION+"  |  Cloudflare",flush=True)
        print("─"*51)
        print("  1/3  Responstid og ping",flush=True)
    baseline=idle_ping()
    if baseline["summary"] is None:
        raise RuntimeError("For få gyldige HTTP-pingprøver mot Cloudflare")
    if show:print("       20 justerte pingprøver ...",flush=True)
    ping=cloudflare_ping()
    result={
        "provider":"Cloudflare","version":VERSION,
        "method":"Browser-like Cloudflare Server-Timing adjusted HTTPS latency",
        "http_idle":baseline["summary"],"http_idle_count":baseline["samples"],
        "ping":ping["ping"],"ping_details":ping
    }
    if ping_only:return result
    for direction,seconds,cap,size,ramp in (
        ("download",DOWNLOAD_SECONDS,DOWNLOAD_CAP_BYTES,DOWNLOAD_REQUEST_BYTES,DOWNLOAD_RAMP),
        ("upload",UPLOAD_SECONDS,UPLOAD_CAP_BYTES,UPLOAD_REQUEST_BYTES,UPLOAD_RAMP)
    ):
        label="Nedlasting" if direction=="download" else "Opplasting"
        if show:
            print("\n  "+("2/3" if direction=="download" else "3/3")+"  "+label+"  ·  "+str(STREAMS)+" forbindelser",flush=True)
        progress=LiveProgress(label) if show else None
        result[direction]=measure_phase(direction,seconds,STREAMS,cap,size,ramp,
                                        on_progress=progress,
                                        target_mbps=targets.get(direction) if targets else None)
    return result


def ookla_measure(server_id=None):
    binary=shutil.which("speedtest")
    if not binary:raise RuntimeError("Offisiell Ookla-klient 'speedtest' er ikke installert")
    args=[binary,"--format=json"]
    if server_id is not None:args.extend(["--server-id",str(server_id)])
    try:run=subprocess.run(args,capture_output=True,text=True,timeout=120)
    except subprocess.TimeoutExpired:raise RuntimeError("Ookla-testen overskred tidsgrensen")
    if run.returncode:
        raise RuntimeError("Ookla-feil: "+(run.stderr or run.stdout).strip()[:350])
    try:
        raw=json.loads(run.stdout)
        latency=raw["ping"]
        down=raw["download"]
        up=raw["upload"]
        return {
            "provider":"Ookla","version":VERSION,"method":"Official Speedtest CLI",
            "ping":{"latency_ms":round(float(latency["latency"]),1),
                    "jitter_ms":round(float(latency["jitter"]),1)},
            "download":{"mbps":round(float(down["bandwidth"])*8/1e6,1)},
            "upload":{"mbps":round(float(up["bandwidth"])*8/1e6,1)},
            "packet_loss_percent":raw.get("packetLoss"),
            "server":{k:raw.get("server",{}).get(k) for k in ("id","name","location","country")},
            "result_url":raw.get("result",{}).get("url")
        }
    except (ValueError,TypeError,KeyError) as exc:raise RuntimeError("Ugyldig Ookla JSON: "+str(exc))


def stability_comparison(baseline, limited, percentages):
    """Compare directions independently; avoid claiming general network health."""
    rows={}
    for direction in ("download","upload"):
        initial=baseline[direction]
        changed=limited[direction]
        bp=initial.get("ping_loaded") or {}
        lp=changed.get("ping_loaded") or {}
        before=bp.get("median_ms")
        after=lp.get("median_ms")
        valid=before is not None and after is not None
        change=round(after-before,1) if valid else None
        reduction=round((before-after)/before*100,1) if valid and before>0 else None
        before_speed=float(initial["mbps"])
        after_speed=float(changed["mbps"])
        speed_cost=round((before_speed-after_speed)/before_speed*100,1) if before_speed>0 else None
        # Both loaded measurements must exist, with enough source probes.
        before_count=(initial.get("ping_loaded_count")
                      or bp.get("samples") or 0)
        after_count=(changed.get("ping_loaded_count")
                     or lp.get("samples") or 0)
        enough=valid and before_count>=3 and after_count>=3
        if not enough:
            verdict="usikkert"
            explanation="For få gyldige pingmålinger til å vurdere endringen."
        elif change<=-20 and reduction is not None and reduction>=15:
            verdict="betydelig forbedring"
            explanation="Belastet ping ble betydelig lavere i denne testen."
        elif change<=-5:
            verdict="forbedring"
            explanation="Belastet ping ble lavere i denne testen."
        elif change>=10:
            verdict="forverring"
            explanation="Belastet ping ble høyere i denne testen."
        else:
            verdict="liten eller ingen endring"
            explanation="Forskjellen i belastet ping var liten."
        rows[direction]={
            "percent":percentages[direction],
            "full_mbps":initial["mbps"],
            "target_mbps":round(before_speed*percentages[direction]/100,1),
            "limited_mbps":changed["mbps"],
            "speed_change_percent":speed_cost,
            "ping_loaded_before_ms":before,
            "ping_loaded_after_ms":after,
            "ping_change_ms":change,
            "ping_reduction_percent":reduction,
            "ping_loaded_samples_before":before_count,
            "ping_loaded_samples_after":after_count,
            "verdict":verdict,
            "explanation":explanation,
        }
    return rows


def print_stability_comparison(report):
    percentages=report["percentages"]
    print("\n"+"═"*59)
    print("  NETCHECK · STABILITET OG HASTIGHET")
    print(f"  Ned {percentages['download']} %  |  Opp {percentages['upload']} %")
    print("═"*59)
    for direction,label in (("download","NEDLASTING"),("upload","OPPLASTING")):
        row=report["comparison"][direction]
        print(f"\n  {label} · {row['percent']} %")
        print("  "+"─"*51)
        print(f"  Hastighet, full test:      {row['full_mbps']:7.1f} Mbit/s")
        print(f"  Hastighet, begrenset:     {row['limited_mbps']:7.1f} Mbit/s")
        print(f"  Valgt mål:                {row['target_mbps']:7.1f} Mbit/s")
        if row["speed_change_percent"] is not None:
            print(f"  Hastighetsreduksjon:      {row['speed_change_percent']:6.1f} %")
        before=row["ping_loaded_before_ms"]
        after=row["ping_loaded_after_ms"]
        if before is None or after is None:
            print("  Ping under belastning:    Ikke nok gyldige målinger")
        else:
            print(f"  Ping under belastning:    {before:.1f} → {after:.1f} ms")
            print(f"  Endring i ping:           {row['ping_change_ms']:+.1f} ms")
            if row["ping_reduction_percent"] is not None:
                print(f"  Reduksjon i belastet ping:{row['ping_reduction_percent']:6.1f} %")
        print(f"\n  VURDERING: {row['verdict'].upper()}")
        print(f"  {row['explanation']}")
    print("\n"+"─"*59)
    down=report["comparison"]["download"]
    up=report["comparison"]["upload"]
    if down["verdict"]=="betydelig forbedring" or up["verdict"]=="betydelig forbedring":
        print("  KONKLUSJON: Betydelig lavere forsinkelse i minst én")
        print("  retning under denne begrensede testen.")
    elif down["verdict"] in ("forbedring","betydelig forbedring") or up["verdict"] in ("forbedring","betydelig forbedring"):
        print("  KONKLUSJON: Lavere belastet ping i minst én retning.")
    else:
        print("  KONKLUSJON: Ingen tydelig forbedring er påvist.")
    print("  Dette er én sammenligning, ikke bevis for varig stabilitet.")
    print("  Bare NetChecks testtrafikk ble begrenset.")
    print("  Ingen systeminnstillinger ble endret; ikke CAKE/SQM.")
    print("═"*59)

def main():
    parser=argparse.ArgumentParser(description="NetCheck CLI – Linux speed test; Cloudflare default, Ookla optional")
    parser.add_argument("--backend",choices=("cloudflare","ookla"),default="cloudflare",
                        help="Cloudflare matches web transfer parameters; Ookla is separate optional reference")
    parser.add_argument("--server-id",type=int,help="Ookla only: lock server ID (e.g. 36998)")
    parser.add_argument("--ping-only",action="store_true",help="Only Cloudflare HTTP latency")
    parser.add_argument("--stability-test",type=int,metavar="PERCENT",
                        help="Compare normal test with NetCheck traffic paced at PERCENT (30-95); no sudo")
    parser.add_argument("--download-percent",type=int,metavar="PERCENT",
                        help="Stability test only: download percent (30-100), independent of upload")
    parser.add_argument("--upload-percent",type=int,metavar="PERCENT",
                        help="Stability test only: upload percent (30-100), independent of download")
    parser.add_argument("--json",action="store_true",help="Output machine-readable JSON")
    parser.add_argument("--version",action="version",version="NetCheck CLI "+VERSION)
    args=parser.parse_args()
    if args.stability_test is not None and not 30<=args.stability_test<=95:
        parser.error("--stability-test must be between 30 and 95")
    if (args.download_percent is not None or args.upload_percent is not None) and args.stability_test is None:
        parser.error("--download-percent and --upload-percent require --stability-test")
    for value in (args.download_percent,args.upload_percent):
        if value is not None and not 30<=value<=100:
            parser.error("Separate download/upload percentages must be between 30 and 100")
    if args.stability_test is not None and (args.backend!="cloudflare" or args.ping_only):
        parser.error("--stability-test works only with full Cloudflare test")
    if args.backend=="cloudflare" and args.server_id is not None:
        parser.error("--server-id works only with --backend ookla")
    if args.backend=="ookla" and args.ping_only:
        parser.error("--ping-only requires Cloudflare")
    try:
        if args.stability_test is not None:
            percentages={
                "download":args.download_percent if args.download_percent is not None else args.stability_test,
                "upload":args.upload_percent if args.upload_percent is not None else args.stability_test,
            }
            show=not args.json
            if show:
                print("\nSTABILITETSTEST · ned %d %% / opp %d %%"%(
                    percentages["download"],percentages["upload"]))
                print("Kun NetChecks egen trafikk begrenses. Ingen sudo eller tc-regler.")
                print("\n══ TEST A · UTEN BEGRENSNING ══")
            baseline=cloudflare_measure(show=show)
            targets={key:max(.5,baseline[key]["mbps"]*percentages[key]/100)
                     for key in ("download","upload")}
            if show:
                print("\n══ TEST B · NED %d %% / OPP %d %% ══"%(
                    percentages["download"],percentages["upload"]))
                print("Mål: %.1f Mbit/s ned / %.1f Mbit/s opp"%(
                    targets["download"],targets["upload"]))
            limited=cloudflare_measure(show=show,targets=targets)
            result={
                "provider":"Cloudflare","version":VERSION,"method":"Test-traffic-only pacing",
                "percent":args.stability_test,"percentages":percentages,
                "baseline":baseline,"limited":limited,
                "comparison":stability_comparison(baseline,limited,percentages),
                "system_settings_modified":False
            }
            if args.json:print(json.dumps(result,ensure_ascii=False))
            else:print_stability_comparison(result)
            return 0
        result=ookla_measure(args.server_id) if args.backend=="ookla" else cloudflare_measure(not args.json,args.ping_only)
        if args.json:
            print(json.dumps(result,ensure_ascii=False))
        elif result["provider"]=="Ookla":
            print("\nRESULTAT – Ookla (offisiell klient)")
            print("Ping: %.1f ms | Jitter: %.1f ms"%(result["ping"]["latency_ms"],result["ping"]["jitter_ms"]))
            print("Nedlasting: %.1f Mbit/s"%result["download"]["mbps"])
            print("Opplasting: %.1f Mbit/s"%result["upload"]["mbps"])
            server=result["server"]
            print("Server: "+str(server.get("name") or "Ukjent")+" – "+str(server.get("location") or "Ukjent"))
            if result["packet_loss_percent"] is not None:
                print("Pakketap: %s %%"%result["packet_loss_percent"])
            if result["result_url"]:print("Resultat: "+result["result_url"])
        else:
            print("\n"+"═"*51)
            print("  NETCHECK  ·  MÅLERESULTAT")
            print("═"*51)
            ping=result["ping"]
            print("\n  RESPONSTID  ·  Uten belastning")
            print("  "+"─"*47)
            if ping:
                print("  Ping                  %7.1f ms"%ping["median_ms"])
                print("  Jitter                %7.1f ms"%ping["jitter_ms"])
            else:
                print("  Ping / jitter         Ikke tilgjengelig")
            print("  HTTP-responstid       %7.1f ms"%result["http_idle"]["median_ms"])
            for d,label in (("download","NEDLASTING"),("upload","OPPLASTING")):
                if d not in result:continue
                phase=result[d]
                print("\n  "+label)
                print("  "+"─"*47)
                print("  Hastighet             %7.1f Mbit/s"%phase["mbps"])
                adjusted=phase["ping_loaded"]
                print("  Ping under belastning "+
                      ("%7.1f ms"%adjusted["median_ms"] if adjusted else "Ikke nok prøver"))
                raw=phase["http_loaded"]
                print("  HTTP under belastning "+
                      ("%7.1f ms"%raw["median_ms"] if raw else "Ikke nok prøver"))
                if phase.get("short_measurement"):
                    print("  Obs: kort målevindu, resultatet kan variere.")
            print("\n"+"═"*51)
            print("  Målt mot Cloudflare. Ping er Server-Timing-justert.")
            print("  Python og nettleseren har ulike timing-API-er.")
            print("═"*51)
        return 0
    except (RuntimeError,OSError,KeyboardInterrupt) as exc:
        print("NetCheck: "+str(exc),file=sys.stderr)
        return 130 if isinstance(exc,KeyboardInterrupt) else 1

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