#!/usr/bin/env python3
"""ТСПУ-дозор: доступен ли сайт с ДОМАШНИХ линий российских операторов.

Блокировка по IP через ТСПУ выглядит как тишина у домашних абонентов при живом сайте
из дата-центров. Поэтому меряем через Globalping (api.globalping.io) с проб, помеченных
`eyeball-network` (домашние сети), и сравниваем с контрольной группой из другой страны.

Без регистрации: лимит 250 проб-замеров в час с одного адреса. Только стандартная
библиотека, работает на python3 от 3.8.

Пример:
  python3 tspu-watch.py --targets vasinki.ru ped-college.ru
  python3 tspu-watch.py --targets-file targets.txt --quiet-ok     # писать только тревоги
Телеграм: переменные TG_BOT_TOKEN и TG_CHAT_ID (или --tg-token/--tg-chat). Без них отчёт
идёт в stdout — заверните в свою отправку. Код выхода: 0 — всё ОК, 2 — есть тревога,
1 — замер не удался.

Правило тревоги (не менять без причины): ≥ 50 % домашних проб РФ молчат, а контроль
из-за границы отвечает → подозрение; повтор через 5 минут подтверждает → БЛОК.
Молчат все, включая контроль → ЛЕЖИТ (сервер, не адрес). Одна проба — шум.
"""
import argparse
import datetime as dt
import json
import os
import pathlib
import sys
import time
import urllib.error
import urllib.request
from typing import Dict, List, Optional

API = "https://api.globalping.io/v1"
UA = "tspu-watch/1.0 (+https://vasinki.ru/guides/cdn-dlya-sayta/)"


def http(method: str, url: str, body: Optional[dict] = None, timeout: int = 30):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(url, data=data, method=method,
                                 headers={"Content-Type": "application/json",
                                          "Accept": "application/json", "User-Agent": UA})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.loads(r.read().decode() or "{}"), dict(r.headers)


def create(target: str, ru_limit: int, control: str, control_limit: int) -> Optional[str]:
    body = {
        "type": "http",
        "target": target,
        "measurementOptions": {"request": {"method": "HEAD", "path": "/", "host": target},
                               "protocol": "HTTPS", "port": 443},
        "locations": [{"country": "RU", "tags": ["eyeball-network"], "limit": ru_limit},
                      {"country": control, "limit": control_limit}],
    }
    try:
        res, hdr = http("POST", API + "/measurements", body)
    except urllib.error.HTTPError as e:
        txt = e.read().decode(errors="replace")[:200]
        print(f"! {target}: API {e.code}: {txt}", file=sys.stderr)
        return None
    left = hdr.get("X-RateLimit-Remaining") or hdr.get("x-ratelimit-remaining")
    if left is not None and int(left) < 60:
        print(f"! осталось {left} проб-замеров в этот час", file=sys.stderr)
    return res.get("id")


def wait(mid: str, timeout: int = 90) -> Optional[dict]:
    t0 = time.time()
    while time.time() - t0 < timeout:
        try:
            res, _ = http("GET", f"{API}/measurements/{mid}")
        except urllib.error.HTTPError:
            time.sleep(2)
            continue
        if res.get("status") != "in-progress":
            return res
        time.sleep(2)
    return None


def measure(target: str, a) -> Optional[dict]:
    """Один замер: считаем, сколько ответило в РФ и в контроле."""
    mid = create(target, a.ru_limit, a.control, a.control_limit)
    if not mid:
        return None
    res = wait(mid)
    if not res:
        print(f"! {target}: замер {mid} не завершился", file=sys.stderr)
        return None
    ru_ok = ru_all = c_ok = c_all = 0
    ru_fail: List[str] = []
    ips: Dict[str, int] = {}
    for r in res.get("results", []):
        p, rr = r.get("probe", {}), r.get("result", {})
        # Ответил — значит адрес достижим: любой HTTP-код, даже 4xx/5xx. Молчание — это
        # status != finished или нет кода (таймаут, обрыв TLS, RST).
        ok = rr.get("status") == "finished" and isinstance(rr.get("statusCode"), int)
        ip = rr.get("resolvedAddress")
        if ip:
            ips[ip] = ips.get(ip, 0) + 1
        if p.get("country") == "RU":
            ru_all += 1
            ru_ok += ok
            if not ok:
                ru_fail.append(f"{p.get('city')}/{p.get('network')}")
        else:
            c_all += 1
            c_ok += ok
    return {"id": mid, "ru_ok": ru_ok, "ru_all": ru_all, "c_ok": c_ok, "c_all": c_all,
            "ru_fail": ru_fail, "ip": max(ips, key=ips.get) if ips else "?"}


def verdict(m: dict, threshold: float) -> str:
    if m["ru_all"] == 0:
        return "НЕТ ПРОБ"
    fail_share = 1 - m["ru_ok"] / m["ru_all"]
    ctrl_alive = m["c_all"] > 0 and m["c_ok"] > 0
    if fail_share >= threshold and ctrl_alive:
        return "БЛОК?"
    if fail_share >= threshold and m["c_all"] > 0 and not ctrl_alive:
        return "ЛЕЖИТ"
    if fail_share >= threshold:
        return "БЛОК?"          # контроля нет — подтверждаем повтором
    return "OK"


def send_telegram(text: str, token: str, chat: str, api: str) -> bool:
    body = {"chat_id": chat, "text": f"<pre>{text}</pre>", "parse_mode": "HTML",
            "disable_web_page_preview": True}
    try:
        http("POST", f"{api}/bot{token}/sendMessage", body)
        return True
    except (urllib.error.URLError, OSError) as e:
        print(f"! телеграм: {e}", file=sys.stderr)
        return False


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--targets", nargs="*", default=[], help="домены, по одному на сервер")
    ap.add_argument("--targets-file", help="файл: домен в строке, # — комментарий")
    ap.add_argument("--ru-limit", type=int, default=15, help="сколько домашних проб РФ (по умолчанию 15)")
    ap.add_argument("--control", default="DE", help="страна контрольной группы (DE)")
    ap.add_argument("--control-limit", type=int, default=3)
    ap.add_argument("--threshold", type=float, default=0.5, help="доля молчащих для тревоги (0.5)")
    ap.add_argument("--confirm-wait", type=int, default=300, help="секунд до повторного замера при подозрении")
    ap.add_argument("--no-confirm", action="store_true")
    ap.add_argument("--quiet-ok", action="store_true", help="не слать отчёт, если всё ОК")
    ap.add_argument("--state", default="tspu-state.json")
    ap.add_argument("--tg-token", default=os.environ.get("TG_BOT_TOKEN"))
    ap.add_argument("--tg-chat", default=os.environ.get("TG_CHAT_ID"))
    ap.add_argument("--tg-api", default=os.environ.get("TG_API", "https://api.telegram.org"),
                    help="база Bot API; с сервера в РФ — адрес своего прокси")
    a = ap.parse_args()

    targets = list(a.targets)
    if a.targets_file:
        for line in pathlib.Path(a.targets_file).read_text().splitlines():
            line = line.split("#", 1)[0].strip()
            if line:
                targets.append(line)
    if not targets:
        ap.error("нужны --targets или --targets-file")

    rows, alarms, suspects = [], [], []
    results: Dict[str, dict] = {}
    for t in targets:
        m = measure(t, a)
        if not m:
            rows.append((t, "?", "замер не удался", ""))
            continue
        v = verdict(m, a.threshold)
        results[t] = m
        if v in ("БЛОК?", "ЛЕЖИТ"):
            suspects.append(t)
        rows.append((t, m["ip"], f"РФ {m['ru_ok']}/{m['ru_all']}  контроль {m['c_ok']}/{m['c_all']}", v))

    # Подозрение подтверждаем вторым замером: пробы у волонтёров ходят и падают, один
    # прогон — не доказательство.
    if suspects and not a.no_confirm:
        time.sleep(a.confirm_wait)
        for t in suspects:
            m2 = measure(t, a)
            if not m2:
                continue
            v2 = verdict(m2, a.threshold)
            results[t]["confirm"] = m2
            final = {"БЛОК?": "БЛОК", "ЛЕЖИТ": "ЛЕЖИТ"}.get(v2, "OK (шум)")
            rows = [(rt, ip, f"{s}  → повтор РФ {m2['ru_ok']}/{m2['ru_all']}", final)
                    if rt == t else (rt, ip, s, v) for (rt, ip, s, v) in rows]
    elif suspects:
        rows = [(rt, ip, s, v + " (без подтверждения)") if v in ("БЛОК?", "ЛЕЖИТ") else (rt, ip, s, v)
                for (rt, ip, s, v) in rows]

    alarms = [r for r in rows if r[3].startswith(("БЛОК", "ЛЕЖИТ"))]
    now = dt.datetime.now().strftime("%Y-%m-%d %H:%M")
    head = ("🚨 ТСПУ-дозор: " if alarms else "ТСПУ-дозор ОК · ") + now
    w = max(len(r[0]) for r in rows)
    lines = [head] + [f"{r[0]:<{w}}  {r[1]:<15} {r[2]}  {r[3]}" for r in rows]
    for t in [r[0] for r in alarms]:
        m = results.get(t, {})
        fails = (m.get("confirm") or m).get("ru_fail", [])
        if fails:
            lines.append(f"  молчат: {', '.join(fails[:8])}" + (" …" if len(fails) > 8 else ""))
    if any(r[3].startswith("БЛОК") for r in alarms):
        lines.append("Блок адреса: запасной адрес → A-запись (TTL 300), см. часть 5 ранбука.")
    if any(r[3].startswith("ЛЕЖИТ") for r in alarms):
        lines.append("Лежит отовсюду: это сервер, не блокировка — SSH, панель хостера, диск, память.")
    text = "\n".join(lines)
    print(text)

    try:
        pathlib.Path(a.state).write_text(json.dumps(
            {"at": now, "rows": rows, "results": results}, ensure_ascii=False, indent=1))
    except OSError as e:
        print(f"! состояние не записано: {e}", file=sys.stderr)

    if a.tg_token and a.tg_chat and (alarms or not a.quiet_ok):
        send_telegram(text, a.tg_token, a.tg_chat, a.tg_api)
    if any(r[1] == "?" for r in rows) and not results:
        return 1
    return 2 if alarms else 0


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