#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
svc-proxy – Proxy-Einstellungen für systemd-Dienste verwalten.

Bedienung per ncurses-Menü (ohne Parameter) oder per Kommandozeile.
Ausführliche Hilfe: svc-proxy --help
"""

import argparse
import base64
import curses
import ipaddress
import json
import locale
import os
import pwd
import re
import socket
import ssl
import struct
import subprocess
import sys
import tempfile
import time
from glob import glob as glob_paths
from urllib.parse import quote, unquote, urlsplit

VERSION = "1.2.0"
PROG = "svc-proxy"

DEFAULT_CONFIG_DIR = "/etc/svc-proxy"
DEFAULT_SYSTEMD_DIR = "/etc/systemd/system"
DROPIN_NAME = "50-svc-proxy.conf"

PROXY_TYPES = ["http", "https", "socks5", "socks5h", "socks4"]
DEFAULT_NO_PROXY = "localhost,127.0.0.1,::1"
FIELDS = ["type", "host", "port", "user", "password", "no_proxy"]
FIELD_LABELS = {
    "type": "Proxy-Typ",
    "host": "Host",
    "port": "Port",
    "user": "Benutzer",
    "password": "Kennwort",
    "no_proxy": "No-Proxy",
}
PROXY_ENV_VARS = ["http_proxy", "https_proxy", "ftp_proxy", "all_proxy"]

# Modus pro Dienst: Proxy-Variablen oder proxychains (LD_PRELOAD, alle TCP-Verbindungen)
MODES = ["env", "proxychains"]
MODE_LABELS = {
    "env": "Proxy-Variablen",
    "proxychains": "proxychains (alle TCP-Verbindungen)",
}
PROXYCHAINS_TYPES = {"http": "http", "socks4": "socks4", "socks5": "socks5", "socks5h": "socks5"}
PROXYCHAINS_LIB_GLOBS = [
    "/usr/lib/*/libproxychains.so.4",
    "/usr/lib/libproxychains.so.4",
    "/usr/lib*/libproxychains4.so",
    "/usr/lib*/proxychains-ng/libproxychains4.so",
    "/usr/local/lib/libproxychains4.so",
]
# Ziele, die im proxychains-Modus immer direkt (ohne Proxy) erreicht werden
PROXYCHAINS_LOCALNETS = [
    "127.0.0.0/255.0.0.0",
    "::1/128",
    "10.0.0.0/255.0.0.0",
    "172.16.0.0/255.240.0.0",
    "192.168.0.0/255.255.0.0",
    "169.254.0.0/255.255.0.0",
    "fc00::/7",
    "fe80::/10",
    "0.0.0.0:53/0.0.0.0",
    "[::]:53/0",
]

# Verbindungstest
TEST_TARGETS_WEB = ["example.com:80", "example.com:443"]
TEST_TARGETS_MAIL = [
    "gmail-smtp-in.l.google.com:25",  # SMTP (MX-Zustellung)
    "smtp.gmail.com:587",             # SMTP Submission
    "smtp.gmail.com:465",             # SMTPS
    "pop.gmail.com:995",              # POP3S
    "imap.gmail.com:993",             # IMAPS
]
TEST_TIMEOUT = 6.0
TLS_PORTS = {443, 465, 563, 636, 853, 993, 995, 8443}
HTTP_PORTS = {80, 443, 8080, 8443}
SOCKS5_ERRORS = {
    1: "allgemeiner Fehler des Proxys",
    2: "vom Regelwerk des Proxys nicht erlaubt (Port/Ziel gesperrt?)",
    3: "Netz nicht erreichbar",
    4: "Host nicht erreichbar",
    5: "Verbindung vom Ziel abgelehnt",
    6: "Zeitüberschreitung (TTL abgelaufen)",
    7: "Befehl nicht unterstützt",
    8: "Adresstyp nicht unterstützt",
}

UNIT_RE = re.compile(r"^[A-Za-z0-9:_.@\\-]+$")
HOST_RE = re.compile(r"^[A-Za-z0-9._:-]+$")
NO_PROXY_RE = re.compile(r"^[A-Za-z0-9.,:*/_\[\]-]*$")

HELP_TEXT = """\
{prog} {version} – Proxy für systemd-Dienste setzen

Hängt an beliebige systemd-Dienste ein Drop-in an, das den Dienst über einen
Proxy laufen lässt. Pro Dienst gibt es zwei Modi:

  env          (Standard) Setzt http_proxy, https_proxy, ftp_proxy, all_proxy und
               no_proxy (auch in GROSSBUCHSTABEN). Wirkt bei allen Programmen, die
               diese Variablen beachten (curl, wget, apt, Python, Go, Docker, ...).
  proxychains  Lädt proxychains-ng per LD_PRELOAD in den Dienst. Dadurch laufen ALLE
               ausgehenden TCP-Verbindungen über den Proxy, auch bei Programmen, die
               keine Proxy-Variablen kennen (z.B. Kerio Connect: SMTP, POP3, IMAP).
               Direkt (ohne Proxy) bleiben: localhost, private Netze (10/8, 172.16/12,
               192.168/16, fc00::/7, Link-Local), DNS (Port 53) und alle IP-/Netz-
               Einträge aus der No-Proxy-Liste. Benötigt: apt install proxychains4
               Proxy-Typen: http, socks4, socks5 (socks5h wird wie socks5 behandelt).

AUFRUF
  {prog}                          ncurses-Menü starten (wie '{prog} tui')
  {prog} BEFEHL [OPTIONEN]        Kommandozeilen-Modus (für Automatisierung)

BEFEHLE
  tui                    Menü starten: alle Dienste auflisten, auswählen, bearbeiten
  list [--all]           Dienste mit Proxy-Konfiguration anzeigen
                         (--all: alle Dienste des Systems)
  show [DIENST]          Effektive Einstellungen eines Dienstes anzeigen
                         (ohne DIENST: den globalen Proxy)
  global [FELDER]        Globalen Proxy setzen/ändern (ohne FELDER: anzeigen)
  global --clear         Globalen Proxy löschen
  set DIENST... [FELDER] Proxy für einen oder mehrere Dienste setzen/ändern
  unset DIENST...        Proxy von Diensten entfernen
  unset --all            Proxy von allen Diensten entfernen
  apply                  Alle Drop-ins aus der Konfiguration neu schreiben
  test [DIENST] [ZIELE]  Verbindungstest über den Proxy des Dienstes
                         (ohne DIENST: globaler Proxy, oder ad hoc mit --proxy)
  help                   Diese Hilfe anzeigen

FELDER (für 'global' und 'set')
  --proxy URL            Komplette Proxy-URL, z.B. http://user:pass@proxy:3128
                         Setzt Typ, Host, Port, Benutzer und Kennwort auf einmal.
                         Einzelne Felder dahinter überschreiben die URL-Werte.
  --type TYP             http | https | socks5 | socks5h | socks4
  --host HOST            Hostname oder IP des Proxys
  --port PORT            Port des Proxys (1-65535)
  --user NAME            Benutzername (weglassen = Proxy ohne Anmeldung)
  --password KENNWORT    Kennwort (Achtung: in der Prozessliste sichtbar!)
  --password-stdin       Kennwort von der Standardeingabe lesen (empfohlen)
  --no-proxy LISTE       Kommagetrennte Ausnahmen, z.B. localhost,10.0.0.0/8,.intern
  --mode MODUS           (nur 'set') env | proxychains   (Standard: env, wird nicht
                         vom globalen Proxy geerbt)

  Ein Feld mit leerem Wert (z.B. --user "") wird geleert. Bei 'set' werden nur
  die angegebenen Felder geändert, die übrigen bleiben erhalten.

  --reset                (nur 'set') Vorher alle Felder des Dienstes leeren.
                         'set DIENST --reset' = Dienst nutzt komplett den globalen Proxy.

VERERBUNG (Dienst <-> global)
  Jedes leere Feld eines Dienstes übernimmt den globalen Wert:
    Typ, Host, Port, No-Proxy  leer -> globaler Wert
    Benutzer/Kennwort          leer -> globale Zugangsdaten, ABER nur wenn auch
                               der Host vom globalen Proxy kommt. Hat der Dienst
                               einen eigenen Host und keinen Benutzer, wird der
                               Proxy ohne Anmeldung verwendet.
  Ändert man den globalen Proxy, werden alle Dienste, die ihn (teilweise) erben,
  automatisch mit angepasst.

VERBINDUNGSTEST ('test')
  Prüft der Reihe nach:
    1. Ist der Proxy erreichbar (TCP)?
    2. Klappt der Handshake inkl. Anmeldung (SOCKS5/SOCKS4/HTTP CONNECT)?
       Klare Meldung bei falschem Kennwort, falschem Proxy-Typ, gesperrtem Port.
    3. Für jedes Ziel: Tunnel aufbauen und Begrüßung lesen (SMTP "220",
       POP3 "+OK", IMAP "* OK", HTTP-Status). Ports 443/465/993/995 per TLS.
    4. Im Modus proxychains zusätzlich: derselbe Test über die proxychains-
       Bibliothek mit der Konfiguration des Dienstes (so verbindet der Dienst).
  --target HOST:PORT     Testziel (mehrfach möglich). Standard: {web}
  --mail                 Mail-Ziele testen: SMTP 25/587/465, POP3S 995, IMAPS 993 (Gmail)
  --timeout SEK          Zeitlimit pro Schritt (Standard: {timeout:g} s)
  FELDER (--proxy, --host, ...) überschreiben die gespeicherten Werte nur für den
  Test. Mit --proxy ohne DIENST geht der Test auch ohne root.
  Rückgabewert: 0 = alles ok, 1 = mindestens ein Test fehlgeschlagen.

NEUSTART / RELOAD
  Nach jeder Änderung wird 'systemctl daemon-reload' ausgeführt. Die neuen
  Werte gelten aber erst nach einem Neustart des Dienstes.
  --restart              Geänderte Dienste ohne Nachfrage neu starten
                         (nur laufende Dienste, per 'systemctl try-restart')
  --no-restart           Nicht neu starten und nicht nachfragen
  (keins von beiden)     Im Terminal wird nachgefragt; ohne Terminal kein Neustart.
                         Im Menü erscheint beim Beenden eine Auswahlliste.
  --no-reload            Kein 'systemctl daemon-reload' ausführen

WEITERE OPTIONEN
  --config-dir DIR       Konfigurationsverzeichnis (Standard: {cfg})
  --systemd-dir DIR      Ziel für Drop-ins (Standard: {sysd})
  -h, --help             Diese Hilfe anzeigen
  -V, --version          Version anzeigen

BEISPIELE
  # Menü starten
  sudo {prog}

  # Globalen Proxy ohne Anmeldung setzen
  sudo {prog} global --proxy http://proxy.firma.local:3128

  # Globalen Proxy mit Anmeldung, Kennwort sicher über stdin
  echo 'geheim' | sudo {prog} global --proxy http://proxy:3128 --user max --password-stdin

  # Ausnahmen für den globalen Proxy festlegen
  sudo {prog} global --no-proxy localhost,127.0.0.1,::1,.firma.local,10.0.0.0/8

  # Dienste den globalen Proxy nutzen lassen und gleich neu starten
  sudo {prog} set docker nginx.service --restart

  # Eigener SOCKS-Proxy ohne Anmeldung für einen Dienst
  sudo {prog} set myapp --proxy socks5h://10.1.1.1:1080

  # Kerio Connect: alle Mailverbindungen über SOCKS5, Proxy-Daten vom globalen Proxy
  sudo apt install proxychains4
  sudo {prog} set kerio-connect --mode proxychains --restart

  # Kerio mit eigenem SOCKS5-Proxy inkl. Anmeldung, LAN-Netz 192.0.2.0/24 direkt
  sudo {prog} set kerio-connect --mode proxychains --proxy socks5://192.0.2.10:1080 \\
       --user max --password-stdin --no-proxy 192.0.2.0/24

  # Zurück zu den Proxy-Variablen
  sudo {prog} set kerio-connect --mode env

  # Nur den Port eines Dienstes ändern (Rest bleibt)
  sudo {prog} set myapp --port 1081 --no-restart

  # Dienst wieder komplett auf den globalen Proxy zurücksetzen
  sudo {prog} set myapp --reset

  # Proxy von einem Dienst entfernen
  sudo {prog} unset myapp --restart

  # Verbindungstests
  sudo {prog} test                                   # globaler Proxy, Web-Ziele
  sudo {prog} test kerio-connect --mail              # Mail-Ports über Kerios Proxy
  sudo {prog} test kerio-connect --target mx.example.com:25 --target pop.example.com:995
  {prog} test --proxy socks5://192.0.2.10:1080 --mail    # ad hoc, ohne Speichern

  # Übersicht und Details
  sudo {prog} list
  sudo {prog} show docker

DATEIEN
  {cfg}/config.json                     Konfiguration (global + Dienste, 0600)
  {cfg}/env/DIENST.env                  erzeugte Umgebungsdatei (0600)
  {cfg}/proxychains/DIENST.conf         proxychains-Konfiguration (Modus proxychains)
  {sysd}/DIENST.d/{dropin}    erzeugtes Drop-in

  Dienstnamen ohne Endung erhalten automatisch '.service'.
""".format(prog=PROG, version=VERSION, cfg=DEFAULT_CONFIG_DIR,
           web=", ".join(TEST_TARGETS_WEB), timeout=TEST_TIMEOUT,
           sysd=DEFAULT_SYSTEMD_DIR, dropin=DROPIN_NAME)


class ProxyError(Exception):
    pass


class ProxyFatal(ProxyError):
    """Fehler, bei dem weitere Testziele sinnlos sind (falscher Typ, Anmeldung)."""


# --------------------------------------------------------------------------
# Hilfsfunktionen
# --------------------------------------------------------------------------

def empty_settings():
    s = {k: "" for k in FIELDS}
    s["mode"] = ""  # nur bei Diensten benutzt, wird nicht vererbt
    return s


def normalize_unit(name):
    name = name.strip()
    if not name or not UNIT_RE.match(name):
        raise ProxyError("Ungültiger Dienstname: %r" % name)
    if "." not in name:
        name += ".service"
    if name.endswith("@.service"):
        raise ProxyError("%s ist eine Vorlage – bitte eine Instanz angeben (z.B. name@xyz.service)" % name)
    return name


def atomic_write(path, content, mode):
    directory = os.path.dirname(path)
    os.makedirs(directory, exist_ok=True)
    fd, tmp = tempfile.mkstemp(dir=directory, prefix=".tmp-svc-proxy-")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            f.write(content)
        os.chmod(tmp, mode)
        os.replace(tmp, path)
    except BaseException:
        try:
            os.unlink(tmp)
        except OSError:
            pass
        raise


def write_if_different(path, content, mode):
    try:
        with open(path, encoding="utf-8") as f:
            if f.read() == content:
                return False
    except OSError:
        pass
    atomic_write(path, content, mode)
    return True


def remove_file(path):
    try:
        os.unlink(path)
        return True
    except FileNotFoundError:
        return False


def validate_settings(raw, is_global):
    """Prüft und bereinigt ein Feld-Dict. Wirft ProxyError."""
    s = empty_settings()
    for k in FIELDS:
        v = raw.get(k) or ""
        if any(ord(c) < 32 for c in v):
            raise ProxyError("%s enthält Steuerzeichen" % FIELD_LABELS[k])
        s[k] = v if k == "password" else v.strip()

    s["type"] = s["type"].lower()
    if s["type"] and s["type"] not in PROXY_TYPES:
        raise ProxyError("Unbekannter Proxy-Typ '%s' (erlaubt: %s)" % (s["type"], ", ".join(PROXY_TYPES)))

    host = s["host"]
    if host.startswith("[") and host.endswith("]"):
        host = host[1:-1]
    if host and not HOST_RE.match(host):
        raise ProxyError("Ungültiger Host: %s" % s["host"])
    s["host"] = host

    if s["port"]:
        if not s["port"].isdigit() or not 1 <= int(s["port"]) <= 65535:
            raise ProxyError("Ungültiger Port: %s (erlaubt: 1-65535)" % s["port"])
        s["port"] = str(int(s["port"]))

    if s["password"] and not s["user"]:
        raise ProxyError("Kennwort angegeben, aber kein Benutzername")

    s["no_proxy"] = re.sub(r"\s+", "", s["no_proxy"])
    if not NO_PROXY_RE.match(s["no_proxy"]):
        raise ProxyError("Ungültige No-Proxy-Liste: %s" % s["no_proxy"])

    s["mode"] = (raw.get("mode") or "").strip().lower()
    if s["mode"] and s["mode"] not in MODES:
        raise ProxyError("Unbekannter Modus '%s' (erlaubt: %s)" % (s["mode"], ", ".join(MODES)))

    if is_global:
        if s["mode"]:
            raise ProxyError("Den Modus gibt es nur pro Dienst, nicht global")
        if any(s[k] for k in ("host", "port", "user", "password")):
            if not s["host"]:
                raise ProxyError("Globaler Proxy: Host fehlt")
            if not s["port"]:
                raise ProxyError("Globaler Proxy: Port fehlt")
        if s["host"] and not s["type"]:
            s["type"] = "http"
    return s


def resolve(svc, glob):
    """Effektive Werte eines Dienstes. Liefert (werte, quellen) oder wirft ProxyError.
    quellen[feld] ist 'dienst', 'global' oder 'standard'."""
    eff, src = {}, {}

    def take(key, default=""):
        if svc[key]:
            eff[key], src[key] = svc[key], "dienst"
        elif glob[key]:
            eff[key], src[key] = glob[key], "global"
        else:
            eff[key], src[key] = default, "standard"

    take("type", "http")
    take("host")
    take("port")
    take("no_proxy")
    if svc["user"]:
        eff["user"], eff["password"] = svc["user"], svc["password"]
        src["user"] = src["password"] = "dienst"
    elif svc["host"]:
        eff["user"] = eff["password"] = ""
        src["user"] = src["password"] = "dienst"
    else:
        eff["user"], eff["password"] = glob["user"], glob["password"]
        src["user"] = src["password"] = "global"

    eff["mode"] = svc.get("mode") or "env"
    src["mode"] = "dienst" if svc.get("mode") else "standard"

    if not eff["host"]:
        raise ProxyError("kein Proxy-Host (weder beim Dienst noch global gesetzt)")
    if not eff["port"]:
        raise ProxyError("kein Proxy-Port (weder beim Dienst noch global gesetzt)")
    if eff["mode"] == "proxychains":
        if eff["type"] not in PROXYCHAINS_TYPES:
            raise ProxyError("proxychains kann keinen '%s'-Proxy (nur http, socks4, socks5)" % eff["type"])
        if re.search(r"\s", eff["user"] + eff["password"]):
            raise ProxyError("proxychains: Benutzer/Kennwort dürfen keine Leerzeichen enthalten")
    return eff, src


def find_proxychains_lib():
    override = os.environ.get("SVC_PROXY_PROXYCHAINS_LIB")
    if override:
        return override
    for pattern in PROXYCHAINS_LIB_GLOBS:
        for path in sorted(glob_paths(pattern)):
            if os.path.isfile(path):
                return path
    raise ProxyError("proxychains ist nicht installiert (Debian/Ubuntu: apt install proxychains4, "
                     "RHEL/Fedora: dnf install proxychains-ng)")


def proxy_ipv4(host):
    """proxychains akzeptiert in der ProxyList nur IPv4-Adressen."""
    try:
        addr = ipaddress.ip_address(host)
    except ValueError:
        addr = None
    if addr is not None:
        if addr.version != 4:
            raise ProxyError("proxychains braucht eine IPv4-Adresse für den Proxy, nicht %s" % host)
        return str(addr)
    try:
        infos = socket.getaddrinfo(host, None, socket.AF_INET, socket.SOCK_STREAM)
    except socket.gaierror as e:
        raise ProxyError("Proxy-Host %s nicht auflösbar (%s) – proxychains braucht eine IPv4-Adresse"
                         % (host, e.strerror))
    return infos[0][4][0]


def proxychains_localnets(no_proxy):
    """Standard-Ausnahmen plus alle IP-/Netz-Einträge aus der No-Proxy-Liste."""
    nets = list(PROXYCHAINS_LOCALNETS)
    for entry in no_proxy.split(","):
        entry = entry.strip().strip("[]")
        try:
            net = ipaddress.ip_network(entry, strict=False)
        except ValueError:
            continue  # Hostnamen/Domains kann proxychains nicht ausnehmen
        if net.version == 4:
            item = "%s/%s" % (net.network_address, net.netmask)
        else:
            item = "%s/%d" % (net.network_address, net.prefixlen)
        if item not in nets:
            nets.append(item)
    return nets


def check_mode(eff):
    """Prüft vor dem Speichern, ob der gewählte Modus nutzbar ist."""
    if eff["mode"] == "proxychains":
        find_proxychains_lib()
        proxy_ipv4(eff["host"])


def service_user(unit):
    """User= des Dienstes (leer = root)."""
    rc, out, _ = run_cmd(["systemctl", "show", "-p", "User", "-p", "DynamicUser", unit])
    if rc != 0:
        return "", False
    vals = dict(line.split("=", 1) for line in out.splitlines() if "=" in line)
    return vals.get("User", ""), vals.get("DynamicUser", "no") == "yes"


def proxy_url(eff, mask=False):
    auth = ""
    if eff.get("user"):
        auth = quote(eff["user"], safe="")
        if eff.get("password"):
            auth += ":" + ("****" if mask else quote(eff["password"], safe=""))
        auth += "@"
    host = eff["host"]
    if ":" in host:
        host = "[%s]" % host
    return "%s://%s%s:%s" % (eff["type"] or "http", auth, host, eff["port"])


def parse_proxy_url(url):
    url = url.strip()
    if "://" not in url:
        url = "http://" + url
    try:
        p = urlsplit(url)
        port = p.port
    except ValueError as e:
        raise ProxyError("Ungültige Proxy-URL %r: %s" % (url, e))
    scheme = p.scheme.lower()
    if scheme not in PROXY_TYPES:
        raise ProxyError("Unbekannter Proxy-Typ '%s' in URL (erlaubt: %s)" % (scheme, ", ".join(PROXY_TYPES)))
    if not p.hostname:
        raise ProxyError("Proxy-URL ohne Host: %r" % url)
    return {
        "type": scheme,
        "host": p.hostname,
        "port": str(port) if port else "",
        "user": unquote(p.username or ""),
        "password": unquote(p.password or ""),
    }


def is_inheriting(svc):
    return not any(svc[k] for k in FIELDS)


def mode_suffix(eff):
    return "  [proxychains]" if eff["mode"] == "proxychains" else ""


# --------------------------------------------------------------------------
# Konfiguration und Erzeugen der Dateien
# --------------------------------------------------------------------------

class Config:
    def __init__(self, config_dir, systemd_dir):
        self.config_dir = config_dir
        self.config_file = os.path.join(config_dir, "config.json")
        self.env_dir = os.path.join(config_dir, "env")
        self.pc_dir = os.path.join(config_dir, "proxychains")
        self.systemd_dir = systemd_dir
        self.global_ = empty_settings()
        self.services = {}

    # Pfade
    @staticmethod
    def safe_name(unit):
        return re.sub(r"[^A-Za-z0-9:_.@-]", "_", unit)

    def env_file(self, unit):
        return os.path.join(self.env_dir, self.safe_name(unit) + ".env")

    def pc_file(self, unit):
        return os.path.join(self.pc_dir, self.safe_name(unit) + ".conf")

    def dropin_dir(self, unit):
        return os.path.join(self.systemd_dir, unit + ".d")

    def dropin_file(self, unit):
        return os.path.join(self.dropin_dir(unit), DROPIN_NAME)

    # Laden / Speichern
    def load(self):
        try:
            with open(self.config_file, encoding="utf-8") as f:
                data = json.load(f)
        except FileNotFoundError:
            return
        except PermissionError:
            raise ProxyError("Keine Berechtigung für %s – bitte als root ausführen" % self.config_file)
        except (OSError, ValueError) as e:
            raise ProxyError("Konfiguration %s nicht lesbar: %s" % (self.config_file, e))

        def clean(d, keys):
            s = empty_settings()
            for k in keys:
                if isinstance(d, dict) and isinstance(d.get(k), str):
                    s[k] = d[k]
            return s

        self.global_ = clean(data.get("global"), FIELDS)
        self.services = {}
        for unit, s in (data.get("services") or {}).items():
            if UNIT_RE.match(unit):
                self.services[unit] = clean(s, FIELDS + ["mode"])

    def ensure_config_dir(self):
        # 0711: Dienste mit eigenem Benutzer müssen ihre proxychains-Datei erreichen,
        # dürfen das Verzeichnis aber nicht auflisten. config.json und env/ bleiben 0600/0700.
        os.makedirs(self.config_dir, mode=0o711, exist_ok=True)
        os.chmod(self.config_dir, 0o711)

    def save(self):
        self.ensure_config_dir()
        data = {
            "version": 1,
            "global": {k: self.global_[k] for k in FIELDS},
            "services": {u: self.services[u] for u in sorted(self.services)},
        }
        atomic_write(self.config_file, json.dumps(data, indent=2, ensure_ascii=False) + "\n", 0o600)

    # Drop-ins
    def render_env(self, unit, eff):
        lines = [
            "# Erzeugt von %s fuer %s - Aenderungen werden ueberschrieben." % (PROG, unit),
            "# Bearbeiten mit: %s set %s ..." % (PROG, unit),
        ]
        if eff["mode"] == "proxychains":
            lines.append('LD_PRELOAD="%s"' % find_proxychains_lib())
            lines.append('PROXYCHAINS_CONF_FILE="%s"' % self.pc_file(unit))
            lines.append('PROXYCHAINS_QUIET_MODE="1"')
            return "\n".join(lines) + "\n"
        url = proxy_url(eff)
        for var in PROXY_ENV_VARS:
            lines.append('%s="%s"' % (var, url))
            lines.append('%s="%s"' % (var.upper(), url))
        if eff["no_proxy"]:
            lines.append('no_proxy="%s"' % eff["no_proxy"])
            lines.append('NO_PROXY="%s"' % eff["no_proxy"])
        return "\n".join(lines) + "\n"

    def render_proxychains(self, unit, eff):
        entry = "%s %s %s" % (PROXYCHAINS_TYPES[eff["type"]], proxy_ipv4(eff["host"]), eff["port"])
        if eff["user"]:
            entry += " %s %s" % (eff["user"], eff["password"])
        lines = [
            "# Erzeugt von %s fuer %s - Aenderungen werden ueberschrieben." % (PROG, unit),
            "# Proxy: %s (%s)" % (proxy_url(eff, mask=True), eff["host"]),
            "strict_chain",
            "quiet_mode",
            "tcp_read_time_out 15000",
            "tcp_connect_time_out 8000",
            "# Kein proxy_dns: DNS wird normal aufgeloest (wichtig z.B. fuer MX-Abfragen).",
            "",
            "# Diese Ziele werden direkt (ohne Proxy) verbunden:",
        ]
        lines += ["localnet " + n for n in proxychains_localnets(eff["no_proxy"])]
        lines += ["", "[ProxyList]", entry]
        return "\n".join(lines) + "\n"

    def write_proxychains(self, unit, eff):
        """Schreibt die proxychains-Datei. Liefert (geändert, warnung)."""
        self.ensure_config_dir()
        os.makedirs(self.pc_dir, mode=0o755, exist_ok=True)
        os.chmod(self.pc_dir, 0o755)
        path = self.pc_file(unit)
        secret = bool(eff["user"])
        changed = write_if_different(path, self.render_proxychains(unit, eff), 0o600 if secret else 0o644)
        os.chmod(path, 0o600 if secret else 0o644)
        warning = None
        # Die Bibliothek liest die Datei im Dienst-Prozess, also mit dessen Benutzer.
        user, dynamic = service_user(unit)
        if secret and os.geteuid() == 0:
            uid = gid = 0
            if dynamic:
                warning = ("%s: nutzt DynamicUser – die proxychains-Datei mit Kennwort ist für den "
                           "Dienst nicht lesbar. Proxy ohne Anmeldung verwenden." % unit)
            elif user and user != "root":
                try:
                    pw = pwd.getpwnam(user)
                    uid, gid = pw.pw_uid, pw.pw_gid
                except KeyError:
                    warning = "%s: Benutzer '%s' unbekannt – proxychains-Datei evtl. nicht lesbar" % (unit, user)
            st = os.stat(path)
            if (st.st_uid, st.st_gid) != (uid, gid):
                os.chown(path, uid, gid)
        return changed, warning

    def render_dropin(self, unit):
        return (
            "# Erzeugt von %s - nicht manuell bearbeiten.\n"
            "# Entfernen mit: %s unset %s\n"
            "[Service]\n"
            "EnvironmentFile=%s\n" % (PROG, PROG, unit, self.env_file(unit))
        )

    def remove_unit_files(self, unit):
        changed = remove_file(self.dropin_file(unit))
        changed |= remove_file(self.env_file(unit))
        changed |= remove_file(self.pc_file(unit))
        try:
            os.rmdir(self.dropin_dir(unit))
        except OSError:
            pass
        return changed

    def apply_unit(self, unit):
        """Schreibt/entfernt die Dateien eines Dienstes. Liefert (geändert, warnung)."""
        svc = self.services.get(unit)
        if svc is None:
            return self.remove_unit_files(unit), None
        warning = None
        try:
            eff, _ = resolve(svc, self.global_)
            changed = False
            if eff["mode"] == "proxychains":
                changed, warning = self.write_proxychains(unit, eff)
            else:
                changed = remove_file(self.pc_file(unit))
            env = self.render_env(unit, eff)
        except ProxyError as e:
            return self.remove_unit_files(unit), "%s: %s – Proxy für diesen Dienst deaktiviert" % (unit, e)
        self.ensure_config_dir()
        os.makedirs(self.env_dir, mode=0o700, exist_ok=True)
        changed |= write_if_different(self.env_file(unit), env, 0o600)
        changed |= write_if_different(self.dropin_file(unit), self.render_dropin(unit), 0o644)
        return changed, warning

    def managed_dropins(self):
        """Units, für die ein Drop-in von svc-proxy existiert."""
        found = set()
        try:
            entries = os.listdir(self.systemd_dir)
        except OSError:
            return found
        for entry in entries:
            if entry.endswith(".d") and os.path.isfile(os.path.join(self.systemd_dir, entry, DROPIN_NAME)):
                found.add(entry[:-2])
        return found

    def apply_all(self):
        changed, warnings = set(), []
        for unit in sorted(set(self.services) | self.managed_dropins()):
            ch, warn = self.apply_unit(unit)
            if ch:
                changed.add(unit)
            if warn:
                warnings.append(warn)
        return changed, warnings


# --------------------------------------------------------------------------
# systemd
# --------------------------------------------------------------------------

def run_cmd(cmd):
    try:
        r = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                           universal_newlines=True)
    except FileNotFoundError:
        return 127, "", "%s nicht gefunden" % cmd[0]
    return r.returncode, r.stdout, r.stderr


def daemon_reload():
    rc, _, err = run_cmd(["systemctl", "daemon-reload"])
    if rc != 0:
        raise ProxyError("systemctl daemon-reload fehlgeschlagen: %s" % err.strip())


def unit_active(unit):
    rc, out, _ = run_cmd(["systemctl", "is-active", unit])
    return out.strip() or "unbekannt"


def restart_units(units):
    """Startet laufende Dienste neu. Liefert Liste von (unit, ok, text)."""
    results = []
    for unit in sorted(units):
        state = unit_active(unit)
        if state not in ("active", "reloading", "activating"):
            results.append((unit, True, "nicht aktiv (%s) – kein Neustart nötig" % state))
            continue
        rc, _, err = run_cmd(["systemctl", "try-restart", unit])
        if rc == 0:
            results.append((unit, True, "neu gestartet"))
        else:
            results.append((unit, False, "Neustart fehlgeschlagen: %s" % err.strip()))
    return results


def list_system_services():
    """Alle Dienste: {name: {'active':..., 'sub':..., 'file':..., 'desc':...}}"""
    units = {}
    rc, out, _ = run_cmd(["systemctl", "list-unit-files", "--type=service",
                          "--no-legend", "--no-pager", "--plain"])
    if rc == 0:
        for line in out.splitlines():
            parts = line.split()
            if not parts or parts[0].endswith("@.service"):
                continue
            units[parts[0]] = {"active": "", "sub": "", "file": parts[1] if len(parts) > 1 else "", "desc": ""}
    rc, out, _ = run_cmd(["systemctl", "list-units", "--type=service", "--all",
                          "--no-legend", "--no-pager", "--plain"])
    if rc == 0:
        for line in out.splitlines():
            parts = line.lstrip("●* ").split(None, 4)
            if len(parts) < 4:
                continue
            name, load, active, sub = parts[:4]
            info = units.setdefault(name, {"active": "", "sub": "", "file": "", "desc": ""})
            info["active"], info["sub"] = active, sub
            if load == "not-found":
                info["file"] = "not-found"
            info["desc"] = parts[4] if len(parts) > 4 else ""
    return units


# --------------------------------------------------------------------------
# Verbindungstest
# --------------------------------------------------------------------------

def parse_target(text):
    text = text.strip()
    m = re.match(r"^\[([0-9A-Fa-f:.]+)\]:(\d+)$", text) or re.match(r"^([A-Za-z0-9._-]+):(\d+)$", text)
    if not m or not 1 <= int(m.group(2)) <= 65535:
        raise ProxyError("Ungültiges Ziel %r (Format: HOST:PORT)" % text)
    return m.group(1), int(m.group(2))


def format_target(host, port):
    return "[%s]:%d" % (host, port) if ":" in host else "%s:%d" % (host, port)


def recv_exact(sock, n):
    buf = b""
    while len(buf) < n:
        chunk = sock.recv(n - len(buf))
        if not chunk:
            raise ProxyError("Proxy hat die Verbindung unerwartet geschlossen")
        buf += chunk
    return buf


def resolve_local(host, ipv4_only=False):
    family = socket.AF_INET if ipv4_only else 0
    try:
        infos = socket.getaddrinfo(host, None, family, socket.SOCK_STREAM)
    except socket.gaierror as e:
        raise ProxyError("Ziel %s lokal nicht auflösbar (%s)" % (host, e.strerror))
    infos.sort(key=lambda i: i[0] != socket.AF_INET)
    return infos[0][4][0]


def socks5_connect(sock, eff, host, port):
    methods = b"\x00\x02" if eff["user"] else b"\x00"
    sock.sendall(b"\x05" + bytes([len(methods)]) + methods)
    try:
        ver, method = recv_exact(sock, 2)
    except socket.timeout:
        raise ProxyFatal("Keine Antwort auf den SOCKS5-Handshake – ist der Proxy-Typ richtig (http statt socks5?)")
    if ver != 5:
        raise ProxyFatal("Keine SOCKS5-Antwort – ist der Proxy-Typ richtig (http statt socks5?)")
    if method == 0xFF:
        raise ProxyFatal("Proxy akzeptiert keine angebotene Anmeldemethode%s"
                         % ("" if eff["user"] else " – verlangt er Benutzer/Kennwort?"))
    if method == 2:
        if not eff["user"]:
            raise ProxyFatal("Proxy verlangt Benutzer/Kennwort")
        u, p = eff["user"].encode(), eff["password"].encode()
        sock.sendall(b"\x01" + bytes([len(u)]) + u + bytes([len(p)]) + p)
        if recv_exact(sock, 2)[1] != 0:
            raise ProxyFatal("Anmeldung abgelehnt – Benutzer oder Kennwort falsch")
    elif method != 0:
        raise ProxyFatal("Proxy verlangt eine nicht unterstützte Anmeldemethode (%d)" % method)

    if eff["type"] == "socks5h":
        name = host.encode("idna")
        addr = b"\x03" + bytes([len(name)]) + name
    else:
        ip = resolve_local(host)
        if ":" in ip:
            addr = b"\x04" + socket.inet_pton(socket.AF_INET6, ip)
        else:
            addr = b"\x01" + socket.inet_aton(ip)
    sock.sendall(b"\x05\x01\x00" + addr + struct.pack(">H", port))
    try:
        _, rep, _, atyp = recv_exact(sock, 4)
    except socket.timeout:
        raise ProxyError("Proxy erreicht das Ziel nicht (Zeitüberschreitung) – Port/Ziel gesperrt?")
    if rep != 0:
        raise ProxyError("Proxy meldet: %s" % SOCKS5_ERRORS.get(rep, "Fehlercode %d" % rep))
    if atyp == 1:
        recv_exact(sock, 4)
    elif atyp == 3:
        recv_exact(sock, recv_exact(sock, 1)[0])
    elif atyp == 4:
        recv_exact(sock, 16)
    recv_exact(sock, 2)
    return b""


def socks4_connect(sock, eff, host, port):
    ip = resolve_local(host, ipv4_only=True)
    sock.sendall(struct.pack(">BBH", 4, 1, port) + socket.inet_aton(ip) + eff["user"].encode() + b"\x00")
    try:
        reply = recv_exact(sock, 8)
    except socket.timeout:
        raise ProxyError("Keine Antwort vom Proxy – falscher Proxy-Typ oder Ziel nicht erreichbar?")
    if reply[0] != 0:
        raise ProxyFatal("Keine SOCKS4-Antwort – ist der Proxy-Typ richtig?")
    if reply[1] != 0x5A:
        reasons = {0x5B: "Anfrage abgelehnt oder Ziel nicht erreichbar",
                   0x5C: "identd des Clients nicht erreichbar",
                   0x5D: "identd-Benutzer passt nicht"}
        raise ProxyError("Proxy meldet: %s" % reasons.get(reply[1], "Code %d" % reply[1]))
    return b""


def http_connect(sock, eff, host, port):
    target = format_target(host, port)
    req = "CONNECT %s HTTP/1.1\r\nHost: %s\r\nUser-Agent: %s/%s\r\n" % (target, target, PROG, VERSION)
    if eff["user"]:
        cred = base64.b64encode(("%s:%s" % (eff["user"], eff["password"])).encode()).decode()
        req += "Proxy-Authorization: Basic %s\r\n" % cred
    sock.sendall((req + "\r\n").encode())
    data = b""
    while b"\r\n\r\n" not in data:
        try:
            chunk = sock.recv(4096)
        except socket.timeout:
            raise ProxyError("Keine Antwort vom Proxy – falscher Proxy-Typ (socks statt http?) "
                             "oder Ziel nicht erreichbar")
        if not chunk:
            if not data:
                raise ProxyFatal("Proxy hat ohne Antwort geschlossen – ist der Proxy-Typ richtig (socks statt http?)")
            break
        data += chunk
        if len(data) > 65536:
            raise ProxyFatal("Antwort des Proxys zu groß")
    head, _, rest = data.partition(b"\r\n\r\n")
    status = head.split(b"\r\n", 1)[0].decode("latin-1").strip()
    parts = status.split(None, 2)
    if len(parts) < 2 or not parts[0].startswith("HTTP/") or not parts[1].isdigit():
        raise ProxyFatal("Keine HTTP-Proxy-Antwort – ist der Proxy-Typ richtig?")
    code = int(parts[1])
    if code == 407:
        raise ProxyFatal("Anmeldung %s (407)" % ("abgelehnt – Benutzer/Kennwort falsch" if eff["user"]
                                                 else "erforderlich – Benutzer/Kennwort fehlt"))
    if code != 200:
        raise ProxyError("Proxy lehnt ab: %s" % status)
    return rest


def proxy_connect(eff, host, port, timeout):
    """Baut über den Proxy einen Tunnel zu host:port. Liefert (socket, schon empfangene Daten)."""
    sock = socket.create_connection((eff["host"], int(eff["port"])), timeout)
    try:
        if eff["type"] == "https":
            ctx = ssl.create_default_context()
            sock = ctx.wrap_socket(sock, server_hostname=eff["host"])
        if eff["type"] in ("http", "https"):
            rest = http_connect(sock, eff, host, port)
        elif eff["type"] == "socks4":
            rest = socks4_connect(sock, eff, host, port)
        else:
            rest = socks5_connect(sock, eff, host, port)
    except BaseException:
        sock.close()
        raise
    return sock, rest


def probe(sock, host, port, early=b"", timeout=TEST_TIMEOUT):
    """Prüft, ob über die Verbindung echte Daten fließen. Liefert (ok, text)."""
    sock.settimeout(timeout)
    prefix = ""
    if port in TLS_PORTS:
        ctx = ssl.create_default_context()
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE  # nur Erreichbarkeit testen, keine Zertifikatsprüfung
        try:
            try:
                ipaddress.ip_address(host)
                is_ip = True
            except ValueError:
                is_ip = False
            sock = ctx.wrap_socket(sock, server_hostname=None if is_ip else host)
        except (ssl.SSLError, OSError) as e:
            return False, "TLS-Handshake fehlgeschlagen (%s)" % (getattr(e, "reason", None) or e)
        prefix = "TLS ok, "
        early = b""
    if port in HTTP_PORTS:
        sock.sendall(("HEAD / HTTP/1.0\r\nHost: %s\r\nUser-Agent: %s/%s\r\n\r\n"
                      % (host, PROG, VERSION)).encode())
    data = early
    try:
        while b"\n" not in data and len(data) < 1024:
            chunk = sock.recv(1024)
            if not chunk:
                break
            data += chunk
    except socket.timeout:
        pass
    except OSError as e:
        return False, prefix + "Verbindung abgebrochen (%s)" % e
    finally:
        try:
            sock.close()
        except OSError:
            pass
    if not data:
        return True, prefix + "verbunden (keine Begrüßung innerhalb %d s)" % timeout
    line = data.split(b"\n", 1)[0].decode("utf-8", "replace").strip()
    return True, prefix + "Antwort: " + (line[:70] or "(leer)")


def probe_direct(targets, timeout):
    """Wird im proxychains-Selbsttest im Kindprozess ausgeführt: normale Verbindungen
    wie ein Programm ohne Proxy-Unterstützung – proxychains leitet sie um."""
    rc = 0
    for text in targets:
        host, port = parse_target(text)
        t0 = time.monotonic()
        try:
            sock = socket.create_connection((host, port), timeout)
            ok, msg = probe(sock, host, port, timeout=timeout)
        except (OSError, ProxyError) as e:
            ok, msg = False, describe_error(e)
        ms = (time.monotonic() - t0) * 1000
        print("%s\t%s → %s (%d ms)" % ("OK" if ok else "FAIL", format_target(host, port), msg, ms))
        rc |= 0 if ok else 1
    return rc


def describe_error(e):
    if isinstance(e, ProxyError):
        return str(e)
    if isinstance(e, socket.timeout):
        return "Zeitüberschreitung"
    if isinstance(e, ConnectionRefusedError):
        return "Verbindung abgelehnt"
    if isinstance(e, ConnectionResetError):
        return "Verbindung zurückgesetzt"
    if isinstance(e, socket.gaierror):
        return "Name nicht auflösbar (%s)" % e.strerror
    if isinstance(e, ssl.SSLError):
        return "TLS-Fehler (%s)" % (e.reason or e)
    return getattr(e, "strerror", None) or str(e)


def proxychains_selftest(pc_conf, targets, timeout):
    try:
        lib = find_proxychains_lib()
    except ProxyError as e:
        return [("fail", "proxychains-Test: %s" % e)]
    fd, path = tempfile.mkstemp(prefix="svc-proxy-test-", suffix=".conf")
    try:
        with os.fdopen(fd, "w") as f:
            f.write(pc_conf)
        env = dict(os.environ, LD_PRELOAD=lib, PROXYCHAINS_CONF_FILE=path, PROXYCHAINS_QUIET_MODE="1")
        cmd = [sys.executable, os.path.abspath(__file__), "_probe", str(timeout)]
        cmd += [format_target(h, p) for h, p in targets]
        try:
            r = subprocess.run(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                               universal_newlines=True, timeout=timeout * (len(targets) + 1) + 10)
        except subprocess.TimeoutExpired:
            return [("fail", "proxychains-Test: Zeitüberschreitung")]
    finally:
        os.unlink(path)
    results = []
    for line in r.stdout.splitlines():
        status, _, text = line.partition("\t")
        results.append(("ok" if status == "OK" else "fail", "proxychains: " + text))
    if not results:
        err = (r.stderr.strip().splitlines() or ["keine Ausgabe"])[-1]
        results.append(("fail", "proxychains-Test fehlgeschlagen: %s" % err))
    return results


def run_proxy_test(eff, targets, cfg=None, unit=None, timeout=TEST_TIMEOUT):
    """Testet den Proxy. Liefert Liste von (status, text), status: ok | fail | info."""
    results = [("info", "Proxy: %s%s" % (proxy_url(eff, mask=True), mode_suffix(eff)))]
    t0 = time.monotonic()
    try:
        socket.create_connection((eff["host"], int(eff["port"])), timeout).close()
    except socket.gaierror as e:
        results.append(("fail", "Proxy-Host %s nicht auflösbar (%s)" % (eff["host"], e.strerror)))
        return results
    except ConnectionRefusedError:
        results.append(("fail", "Proxy %s:%s lehnt die Verbindung ab – läuft dort ein Proxy auf diesem Port?"
                        % (eff["host"], eff["port"])))
        return results
    except socket.timeout:
        results.append(("fail", "Proxy %s:%s antwortet nicht (Zeitüberschreitung) – Firewall?"
                        % (eff["host"], eff["port"])))
        return results
    except OSError as e:
        results.append(("fail", "Proxy %s:%s nicht erreichbar: %s" % (eff["host"], eff["port"], describe_error(e))))
        return results
    results.append(("ok", "Proxy erreichbar: %s:%s (%d ms)"
                    % (eff["host"], eff["port"], (time.monotonic() - t0) * 1000)))

    for host, port in targets:
        t0 = time.monotonic()
        label = format_target(host, port)
        try:
            sock, early = proxy_connect(eff, host, port, timeout)
            ok, msg = probe(sock, host, port, early, timeout)
        except ProxyFatal as e:
            results.append(("fail", "%s → %s" % (label, e)))
            return results
        except (OSError, ProxyError) as e:
            ok, msg = False, describe_error(e)
        ms = (time.monotonic() - t0) * 1000
        results.append(("ok" if ok else "fail", "%s → %s (%d ms)" % (label, msg, ms)))

    if eff["mode"] == "proxychains":
        try:
            conf = (cfg or Config(DEFAULT_CONFIG_DIR, DEFAULT_SYSTEMD_DIR)).render_proxychains(unit or "test", eff)
        except ProxyError as e:
            results.append(("fail", "proxychains-Test: %s" % e))
        else:
            results.append(("info", "Test über proxychains (so verbindet der Dienst):"))
            results += proxychains_selftest(conf, targets, timeout)
    return results


# --------------------------------------------------------------------------
# ncurses-Oberfläche
# --------------------------------------------------------------------------

def put(win, y, x, text, attr=0, width=None):
    h, w = win.getmaxyx()
    if y < 0 or y >= h or x < 0 or x >= w:
        return
    maxw = w - x if width is None else min(width, w - x)
    if maxw <= 0:
        return
    try:
        win.addnstr(y, x, text, maxw, attr)
    except curses.error:
        pass


def is_enter(k):
    return k in ("\n", "\r", curses.KEY_ENTER)


def is_backspace(k):
    return k in ("\x7f", "\x08", curses.KEY_BACKSPACE)


class FormField:
    def __init__(self, key, label, value, kind="text", choices=None, secret=False, placeholder="",
                 display=None):
        self.key = key
        self.label = label
        self.kind = kind  # text | choice | button
        self.value = value
        self.choices = choices or []
        self.display = display or {}
        self.secret = secret
        self.placeholder = placeholder
        self.pos = len(value)
        self.off = 0


class TUI:
    def __init__(self, scr, cfg, args):
        self.scr = scr
        self.cfg = cfg
        self.args = args
        self.changed = set()
        self.filter = ""
        self.filter_mode = False
        self.only_configured = False
        self.sel = 0
        self.top = 0
        self.msg = ""
        self.msg_attr = 0
        self.units = {}
        self.visible = []
        self.init_colors()
        self.refresh_units()

    # ---------- Grundlagen ----------
    def init_colors(self):
        self.c_title = curses.A_REVERSE | curses.A_BOLD
        self.c_sel = curses.A_REVERSE
        self.c_dim = curses.A_DIM
        self.c_err = curses.A_BOLD
        self.c_ok = curses.A_BOLD
        self.c_global = curses.A_NORMAL
        self.c_own = curses.A_BOLD
        if curses.has_colors():
            try:
                curses.start_color()
                curses.use_default_colors()
                curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE)
                curses.init_pair(2, curses.COLOR_RED, -1)
                curses.init_pair(3, curses.COLOR_GREEN, -1)
                curses.init_pair(4, curses.COLOR_CYAN, -1)
                curses.init_pair(5, curses.COLOR_YELLOW, -1)
                curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_CYAN)
                self.c_title = curses.color_pair(1) | curses.A_BOLD
                self.c_err = curses.color_pair(2) | curses.A_BOLD
                self.c_ok = curses.color_pair(3) | curses.A_BOLD
                self.c_global = curses.color_pair(4)
                self.c_own = curses.color_pair(5) | curses.A_BOLD
                self.c_sel = curses.color_pair(6)
            except curses.error:
                pass

    def cursor(self, visible):
        try:
            curses.curs_set(1 if visible else 0)
        except curses.error:
            pass

    def set_msg(self, text, attr=None):
        self.msg = text
        self.msg_attr = self.c_ok if attr is None else attr

    def title_bar(self, text):
        h, w = self.scr.getmaxyx()
        put(self.scr, 0, 0, (" " + text).ljust(w), self.c_title)

    def key_bar(self, text):
        h, w = self.scr.getmaxyx()
        put(self.scr, h - 1, 0, (" " + text).ljust(w - 1), self.c_title)

    def refresh_units(self):
        self.units = list_system_services()
        for unit in self.cfg.services:
            self.units.setdefault(unit, {"active": "", "sub": "", "file": "?", "desc": ""})
        self.apply_filter()

    def apply_filter(self):
        f = self.filter.lower()
        names = sorted(self.units)
        if self.only_configured:
            names = [n for n in names if n in self.cfg.services]
        if f:
            names = [n for n in names if f in n.lower() or f in self.units[n]["desc"].lower()]
        self.visible = names
        self.sel = max(0, min(self.sel, len(names) - 1))

    def global_summary(self):
        g = self.cfg.global_
        if not g["host"]:
            return "(nicht gesetzt)"
        text = proxy_url(g, mask=True)
        if g["no_proxy"]:
            text += "   No-Proxy: " + g["no_proxy"]
        return text

    def service_summary(self, unit):
        svc = self.cfg.services.get(unit)
        if svc is None:
            return "", 0
        try:
            eff, _ = resolve(svc, self.cfg.global_)
        except ProxyError as e:
            return "FEHLER: " + str(e), self.c_err
        if is_inheriting(svc):
            return "global → " + proxy_url(eff, mask=True) + mode_suffix(eff), self.c_global
        return "eigen  → " + proxy_url(eff, mask=True) + mode_suffix(eff), self.c_own

    # ---------- Hauptliste ----------
    def draw_main(self):
        scr = self.scr
        scr.erase()
        h, w = scr.getmaxyx()
        self.title_bar("%s %s – Proxy für systemd-Dienste" % (PROG, VERSION))
        put(scr, 1, 1, "Global: ", curses.A_BOLD)
        put(scr, 1, 9, self.global_summary(), self.c_global if self.cfg.global_["host"] else self.c_dim)

        info = "%d Dienste" % len(self.visible)
        if self.only_configured:
            info += " (nur konfigurierte)"
        if self.filter or self.filter_mode:
            info += "   Filter: " + self.filter + ("_" if self.filter_mode else "")
        put(scr, 2, 1, info, self.c_dim if not self.filter_mode else curses.A_BOLD)

        name_w = max([len(n) for n in self.visible] + [10])
        name_w = min(name_w, max(20, w // 2 - 14))
        state_w = 10
        put(scr, 3, 0, "    %-*s %-*s %s" % (name_w, "Dienst", state_w, "Status", "Proxy"), curses.A_UNDERLINE)

        list_top, list_h = 4, max(1, h - 6)
        if self.sel < self.top:
            self.top = self.sel
        elif self.sel >= self.top + list_h:
            self.top = self.sel - list_h + 1
        for i in range(list_h):
            idx = self.top + i
            if idx >= len(self.visible):
                break
            unit = self.visible[idx]
            info = self.units.get(unit, {})
            svc = self.cfg.services.get(unit)
            mark = " "
            if svc is not None:
                mark = "G" if is_inheriting(svc) else "E"
            star = "*" if unit in self.changed else " "
            state = info.get("active") or info.get("file") or ""
            name = unit if len(unit) <= name_w else unit[:name_w - 1] + "…"
            summary, sattr = self.service_summary(unit)
            y = list_top + i
            line = " %s%s %-*s %-*s " % (mark, star, name_w, name, state_w, state[:state_w])
            if idx == self.sel:
                put(scr, y, 0, (line + summary).ljust(w), self.c_sel)
            else:
                put(scr, y, 0, line)
                put(scr, y, len(line), summary, sattr)
        if not self.visible:
            put(scr, list_top, 4, "Keine Dienste gefunden.", self.c_dim)

        put(scr, h - 2, 1, self.msg, self.msg_attr)
        if self.filter_mode:
            self.key_bar("Filter eingeben   Enter übernehmen   Esc Filter löschen")
        else:
            self.key_bar("↑↓ Wählen  Enter Bearbeiten  g Global  Entf Entfernen  "
                         "t Test  / Filter  c Nur konfigurierte  r Neustart  F1 Hilfe  q Ende")
        scr.refresh()

    def run(self):
        self.cursor(False)
        if not self.cfg.global_["host"]:
            self.set_msg("Tipp: Mit 'g' zuerst einen globalen Proxy festlegen.", self.c_dim)
        while True:
            self.draw_main()
            try:
                k = self.scr.get_wch()
            except curses.error:
                continue
            if k == curses.KEY_RESIZE:
                continue
            if self.filter_mode:
                self.handle_filter_key(k)
                continue
            self.msg = ""
            n = len(self.visible)
            h, _ = self.scr.getmaxyx()
            page = max(1, h - 7)
            if k in (curses.KEY_UP, "k"):
                self.sel = max(0, self.sel - 1)
            elif k in (curses.KEY_DOWN, "j"):
                self.sel = min(n - 1, self.sel + 1)
            elif k == curses.KEY_PPAGE:
                self.sel = max(0, self.sel - page)
            elif k == curses.KEY_NPAGE:
                self.sel = min(n - 1, self.sel + page)
            elif k == curses.KEY_HOME:
                self.sel = 0
            elif k == curses.KEY_END:
                self.sel = max(0, n - 1)
            elif is_enter(k) and n:
                self.edit_service(self.visible[self.sel])
            elif k in ("g", "G"):
                self.edit_global()
            elif k in (curses.KEY_DC, "d") and n:
                self.remove_service(self.visible[self.sel])
            elif k == "/":
                self.filter_mode = True
            elif k in ("c", "C"):
                self.only_configured = not self.only_configured
                self.apply_filter()
            elif k in ("t", "T") and n:
                self.test_service(self.visible[self.sel])
            elif k == "r" and n:
                self.restart_now([self.visible[self.sel]])
            elif k == curses.KEY_F5:
                self.refresh_units()
                self.set_msg("Dienstliste aktualisiert.")
            elif k in (curses.KEY_F1, "?", "h"):
                self.show_help()
            elif k in ("q", "Q", curses.KEY_F10):
                return self.ask_restart_on_exit()
            self.sel = max(0, min(self.sel, len(self.visible) - 1))

    def handle_filter_key(self, k):
        if is_enter(k):
            self.filter_mode = False
        elif k == "\x1b":
            self.filter_mode = False
            self.filter = ""
        elif is_backspace(k):
            self.filter = self.filter[:-1]
        elif isinstance(k, str) and k.isprintable():
            self.filter += k
        self.sel = 0
        self.top = 0
        self.apply_filter()

    # ---------- Aktionen ----------
    def after_change(self, changed, warnings=()):
        self.changed |= changed
        if changed and not self.args.no_reload:
            try:
                daemon_reload()
            except ProxyError as e:
                self.message("Fehler", [str(e)], error=True)
                return
        if warnings:
            self.message("Hinweis", list(warnings), error=True)

    def edit_service(self, unit):
        svc = self.cfg.services.get(unit)
        result = self.form(unit, svc or empty_settings(), is_global=False, exists=svc is not None)
        if result is None:
            return
        if result == "remove":
            self.remove_service(unit, confirm=False)
            return
        self.cfg.services[unit] = result
        self.cfg.save()
        ch, warn = self.cfg.apply_unit(unit)
        self.after_change({unit} if ch else set(), [warn] if warn else [])
        self.set_msg("%s gespeichert%s." % (unit, "" if ch else " (keine Änderung)"))

    def edit_global(self):
        g = dict(self.cfg.global_)
        if not g["host"] and not g["no_proxy"]:
            g["no_proxy"] = DEFAULT_NO_PROXY
        result = self.form(None, g, is_global=True, exists=bool(self.cfg.global_["host"]))
        if result is None:
            return
        if result == "remove":
            if not self.confirm("Globalen Proxy wirklich löschen? Dienste, die ihn erben, verlieren ihren Proxy."):
                return
            result = empty_settings()
        self.cfg.global_ = result
        self.cfg.save()
        changed, warnings = self.cfg.apply_all()
        self.after_change(changed, warnings)
        self.set_msg("Globaler Proxy gespeichert, %d Dienst(e) angepasst." % len(changed))

    def remove_service(self, unit, confirm=True):
        if unit not in self.cfg.services:
            self.set_msg("%s hat keinen Proxy." % unit, self.c_dim)
            return
        if confirm and not self.confirm("Proxy von %s entfernen?" % unit):
            return
        del self.cfg.services[unit]
        self.cfg.save()
        ch, _ = self.cfg.apply_unit(unit)
        self.after_change({unit} if ch else set())
        self.apply_filter()
        self.set_msg("Proxy von %s entfernt." % unit)

    def restart_now(self, units):
        if not self.confirm("%s jetzt neu starten?" % ", ".join(units)):
            return
        self.set_msg("Starte neu ...", self.c_dim)
        self.draw_main()
        results = restart_units(units)
        self.changed -= {u for u, ok, _ in results if ok}
        self.refresh_units()
        self.message("Neustart", ["%s: %s" % (u, t) for u, ok, t in results],
                     error=not all(ok for _, ok, _ in results))

    def ask_restart_on_exit(self):
        """Liefert die Liste der neu zu startenden Dienste."""
        changed = sorted(self.changed)
        if not changed or self.args.restart is False:
            return []
        if self.args.restart is True:
            return changed
        chosen = self.checklist(
            "Geänderte Dienste neu starten?",
            ["Die neuen Proxy-Einstellungen gelten erst nach einem Neustart.",
             "Leertaste: an/aus   a: alle/keine   Enter: neu starten   Esc: nicht neu starten"],
            changed)
        return chosen or []

    # ---------- Dialoge ----------
    def box(self, title, lines, height_extra=2):
        h, w = self.scr.getmaxyx()
        bw = min(w - 4, max([len(title) + 6] + [len(l) + 4 for l in lines] + [40]))
        bh = min(h - 2, len(lines) + 2 + height_extra)
        y, x = max(0, (h - bh) // 2), max(0, (w - bw) // 2)
        win = curses.newwin(bh, bw, y, x)
        win.keypad(True)
        win.erase()
        try:
            win.box()
        except curses.error:
            pass
        put(win, 0, 2, " %s " % title, curses.A_BOLD)
        return win, bh, bw

    def message(self, title, lines, error=False):
        win, bh, bw = self.box(title, lines)
        for i, l in enumerate(lines[:bh - 4]):
            put(win, 1 + i, 2, l, self.c_err if error else 0, bw - 4)
        put(win, bh - 2, 2, "[ OK – beliebige Taste ]", curses.A_REVERSE)
        win.refresh()
        win.get_wch()
        del win
        self.scr.touchwin()

    def busy(self, text):
        win, bh, bw = self.box("Bitte warten", [text], height_extra=0)
        put(win, 1, 2, text, curses.A_BOLD, bw - 4)
        win.refresh()
        del win

    def prompt(self, title, label, value=""):
        """Einzeilige Eingabe in einem Dialog. Liefert Text oder None (Esc)."""
        while True:
            win, bh, bw = self.box(title, [label, " " * 50])
            put(win, 1, 2, label, 0, bw - 4)
            field_w = bw - 4
            vis = value[-(field_w - 1):]
            put(win, 2, 2, vis.ljust(field_w), self.c_sel)
            put(win, bh - 2, 2, "Enter übernehmen   Esc abbrechen", self.c_dim)
            self.cursor(True)
            try:
                win.move(2, 2 + len(vis))
            except curses.error:
                pass
            win.refresh()
            k = win.get_wch()
            del win
            if is_enter(k):
                self.cursor(False)
                self.scr.touchwin()
                return value
            if k == "\x1b":
                self.cursor(False)
                self.scr.touchwin()
                return None
            if is_backspace(k):
                value = value[:-1]
            elif k == "\x15":
                value = ""
            elif isinstance(k, str) and k.isprintable():
                value += k

    def choose_targets(self):
        lines = [
            "Welche Ziele sollen über den Proxy getestet werden?",
            "",
            "  w   Web:  " + ", ".join(TEST_TARGETS_WEB),
            "  m   Mail: SMTP 25/587/465, POP3S 995, IMAPS 993 (Gmail)",
            "  b   beides",
            "  e   eigenes Ziel (HOST:PORT, mehrere mit Komma)",
        ]
        win, bh, bw = self.box("Verbindungstest", lines)
        for i, l in enumerate(lines):
            put(win, 1 + i, 2, l, 0, bw - 4)
        put(win, bh - 2, 2, "w / m / b / e wählen, Esc abbrechen", self.c_dim)
        win.refresh()
        while True:
            k = win.get_wch()
            if k in ("w", "W", "\n", "\r", curses.KEY_ENTER):
                names = TEST_TARGETS_WEB
            elif k in ("m", "M"):
                names = TEST_TARGETS_MAIL
            elif k in ("b", "B"):
                names = TEST_TARGETS_WEB + TEST_TARGETS_MAIL
            elif k in ("e", "E"):
                del win
                self.scr.touchwin()
                text = self.prompt("Eigenes Ziel", "Ziel(e) als HOST:PORT, z.B. mail.example.com:25",
                                   getattr(self, "last_target", ""))
                if not text:
                    return None
                self.last_target = text
                names = [t for t in text.split(",") if t.strip()]
            elif k in ("\x1b", "q"):
                del win
                self.scr.touchwin()
                return None
            else:
                continue
            try:
                return [parse_target(t) for t in names]
            except ProxyError as e:
                self.message("Fehler", [str(e)], error=True)
                return None

    def run_test(self, eff, unit, title):
        targets = self.choose_targets()
        if not targets:
            return
        self.busy("Verbindungstest läuft ... (bis zu %d s pro Ziel)" % TEST_TIMEOUT)
        results = run_proxy_test(eff, targets, self.cfg, unit)
        failed = sum(1 for st, _ in results if st == "fail")
        results.append(("info", ""))
        results.append(("ok" if not failed else "fail",
                        "Alles in Ordnung." if not failed else "%d Test(s) fehlgeschlagen." % failed))
        self.results_box(title, results)

    def results_box(self, title, results):
        lines = [("[ OK ] " if st == "ok" else "[FEHL] " if st == "fail" else "       ") + text
                 for st, text in results]
        win, bh, bw = self.box(title, lines)
        for i, (line, (st, _)) in enumerate(list(zip(lines, results))[:bh - 4]):
            attr = self.c_ok if st == "ok" else self.c_err if st == "fail" else curses.A_BOLD
            put(win, 1 + i, 2, line, attr, bw - 4)
        put(win, bh - 2, 2, "[ OK – beliebige Taste ]", curses.A_REVERSE)
        win.refresh()
        win.get_wch()
        del win
        self.scr.touchwin()

    def test_service(self, unit):
        svc = self.cfg.services.get(unit)
        try:
            eff, _ = resolve(svc or empty_settings(), self.cfg.global_)
        except ProxyError as e:
            self.message("Verbindungstest", [str(e)], error=True)
            return
        title = "Test %s%s" % (unit, "" if svc else " (globaler Proxy)")
        self.run_test(eff, unit if svc else None, title)

    def confirm(self, text):
        win, bh, bw = self.box("Bestätigen", [text])
        put(win, 1, 2, text, 0, bw - 4)
        put(win, bh - 2, 2, "[ j = Ja ]   [ n = Nein ]", curses.A_BOLD)
        win.refresh()
        while True:
            k = win.get_wch()
            if k in ("j", "J", "y", "Y"):
                res = True
                break
            if k in ("n", "N", "\x1b", "q"):
                res = False
                break
        del win
        self.scr.touchwin()
        return res

    def checklist(self, title, header, items):
        selected = set(items)
        cur, top = 0, 0
        while True:
            h, w = self.scr.getmaxyx()
            self.scr.erase()
            self.title_bar(title)
            for i, l in enumerate(header):
                put(self.scr, 2 + i, 2, l)
            list_top = 3 + len(header)
            list_h = max(1, h - list_top - 2)
            if cur < top:
                top = cur
            elif cur >= top + list_h:
                top = cur - list_h + 1
            for i in range(list_h):
                idx = top + i
                if idx >= len(items):
                    break
                line = "  [%s] %s" % ("x" if items[idx] in selected else " ", items[idx])
                put(self.scr, list_top + i, 2, line.ljust(w - 4), self.c_sel if idx == cur else 0)
            self.key_bar("↑↓ Wählen  Leertaste an/aus  a alle/keine  Enter neu starten  Esc abbrechen")
            self.scr.refresh()
            k = self.scr.get_wch()
            if k == curses.KEY_UP:
                cur = max(0, cur - 1)
            elif k == curses.KEY_DOWN:
                cur = min(len(items) - 1, cur + 1)
            elif k == " ":
                selected ^= {items[cur]}
            elif k in ("a", "A"):
                selected = set() if len(selected) == len(items) else set(items)
            elif is_enter(k):
                return [i for i in items if i in selected]
            elif k in ("\x1b", "q"):
                return None

    def show_help(self):
        lines = [
            "Markierungen in der Liste:",
            "  G  Dienst nutzt den globalen Proxy (alle Felder leer)",
            "  E  Dienst hat eigene Werte (leere Felder erben global)",
            "  *  in dieser Sitzung geändert, Neustart noch offen",
            "",
            "Vererbung: Leere Felder beim Dienst übernehmen den globalen Wert.",
            "Benutzer/Kennwort werden nur geerbt, wenn auch der Host global ist.",
            "Eigener Host ohne Benutzer = Proxy ohne Anmeldung.",
            "",
            "Modus (pro Dienst):",
            "  Proxy-Variablen  setzt http_proxy & Co. – nur für Programme, die sie beachten",
            "  proxychains      leitet ALLE TCP-Verbindungen des Dienstes über den Proxy",
            "                   (z.B. Kerio SMTP/POP3). Lokale/private Netze und DNS gehen direkt.",
            "",
            "Tasten: Enter bearbeiten, g global, Entf/d entfernen, / filtern,",
            "c nur konfigurierte, r Dienst neu starten, t Verbindungstest,",
            "F5 Liste neu laden, q Ende. Im Formular: F3 testet die eingegebenen Werte.",
            "Nach jeder Änderung wird 'systemctl daemon-reload' ausgeführt.",
            "Beim Beenden kann man geänderte Dienste neu starten lassen.",
        ]
        self.message("Hilfe", lines)

    # ---------- Formular ----------
    def form(self, unit, settings, is_global, exists):
        g = self.cfg.global_

        def ph(key):
            if is_global:
                return ""
            if key in ("user", "password"):
                return "(global, falls Host leer)" if g[key] else ""
            return ("global: " + g[key]) if g[key] else ""

        if is_global:
            type_choices = PROXY_TYPES
            tval = settings["type"] or "http"
        else:
            type_choices = [""] + PROXY_TYPES
            tval = settings["type"]
        fields = []
        if not is_global:
            fields.append(FormField("mode", "Modus", settings["mode"] or "env", kind="choice",
                                    choices=MODES, display=MODE_LABELS))
        fields.append(FormField("type", "Proxy-Typ", tval, kind="choice", choices=type_choices))
        for key in ("host", "port", "user", "password", "no_proxy"):
            fields.append(FormField(key, FIELD_LABELS[key], settings[key],
                                    secret=(key == "password"), placeholder=ph(key)))
        buttons = [FormField("save", "Speichern", "", kind="button"),
                   FormField("test", "Testen", "", kind="button"),
                   FormField("cancel", "Abbrechen", "", kind="button")]
        if exists:
            buttons.append(FormField("remove", "Proxy entfernen" if not is_global else "Global löschen",
                                     "", kind="button"))
        items = fields + buttons
        cur = [f.key for f in fields].index("host")
        error = ""

        def collect():
            return {f.key: f.value for f in fields}

        while True:
            scr = self.scr
            scr.erase()
            h, w = scr.getmaxyx()
            if is_global:
                self.title_bar("Globaler Proxy")
                put(scr, 2, 2, "Gilt für alle Dienste, deren Felder leer sind.", self.c_dim)
            else:
                info = self.units.get(unit, {})
                self.title_bar("Proxy für %s" % unit)
                desc = info.get("desc") or ""
                put(scr, 2, 2, "%s  [%s]" % (desc, info.get("active") or info.get("file") or "?"), self.c_dim)

            label_w = 12
            input_x = 2 + label_w + 3
            input_w = max(10, min(w - input_x - 2, 60))
            cursor_pos = None
            step = 2 if h >= 4 + len(fields) * 2 + 9 else 1
            for i, f in enumerate(fields):
                y = 4 + i * step
                focused = (i == cur)
                put(scr, y, 2, f.label.rjust(label_w), curses.A_BOLD if focused else 0)
                put(scr, y, 2 + label_w, " : ")
                if f.kind == "choice":
                    if f.display:
                        shown = f.display.get(f.value, f.value)
                    else:
                        shown = f.value if f.value else "(global: %s)" % (g["type"] or "http")
                    text = "◀ %s ▶" % shown
                    put(scr, y, input_x, text.ljust(input_w), self.c_sel if focused else curses.A_UNDERLINE)
                else:
                    val = "*" * len(f.value) if f.secret else f.value
                    if f.pos < f.off:
                        f.off = f.pos
                    if f.pos - f.off >= input_w:
                        f.off = f.pos - input_w + 1
                    vis = val[f.off:f.off + input_w]
                    attr = self.c_sel if focused else curses.A_UNDERLINE
                    put(scr, y, input_x, vis.ljust(input_w), attr)
                    if not f.value and f.placeholder:
                        put(scr, y, input_x, f.placeholder[:input_w], attr | curses.A_DIM)
                    if focused:
                        cursor_pos = (y, input_x + f.pos - f.off)

            by = 4 + len(fields) * step
            bx = input_x
            for j, b in enumerate(buttons):
                focused = (len(fields) + j == cur)
                label = "< %s >" % b.label
                put(scr, by, bx, label, self.c_sel if focused else curses.A_BOLD)
                bx += len(label) + 2

            # Vorschau
            py = by + 2
            try:
                cand = validate_settings(collect(), is_global)
                if is_global:
                    preview = proxy_url(cand, mask=True) if cand["host"] else "(kein globaler Proxy)"
                else:
                    eff, _ = resolve(cand, g)
                    preview = proxy_url(eff, mask=True) + mode_suffix(eff)
                    if eff["no_proxy"]:
                        preview += "   No-Proxy: " + eff["no_proxy"]
                put(scr, py, 2, "Ergebnis: ", curses.A_BOLD)
                put(scr, py, 12, preview, self.c_ok)
            except ProxyError as e:
                put(scr, py, 2, "Ergebnis: ", curses.A_BOLD)
                put(scr, py, 12, str(e), self.c_err)
            if error:
                put(scr, py + 1, 2, error, self.c_err)
            if not is_global:
                put(scr, py + 2, 2, "Leere Felder = globaler Wert. Benutzer/Kennwort werden nur geerbt,", self.c_dim)
                put(scr, py + 3, 2, "wenn auch der Host leer ist; eigener Host ohne Benutzer = ohne Anmeldung.", self.c_dim)
                if fields[0].value == "proxychains":
                    put(scr, py + 4, 2, "proxychains: alle TCP-Verbindungen über den Proxy; direkt bleiben localhost,", self.c_dim)
                    put(scr, py + 5, 2, "private Netze, DNS (Port 53) und IP-/Netz-Einträge aus No-Proxy.", self.c_dim)
            else:
                put(scr, py + 3, 2, "Benutzer/Kennwort leer = Proxy ohne Anmeldung.", self.c_dim)

            self.key_bar("Tab/↑↓ Feld  ←→ Auswahl  Enter weiter  F2 Speichern  F3 Testen  Strg+U leeren  Esc Abbrechen")
            if cursor_pos and items[cur].kind == "text":
                self.cursor(True)
                try:
                    scr.move(*cursor_pos)
                except curses.error:
                    pass
            else:
                self.cursor(False)
            scr.refresh()

            try:
                k = scr.get_wch()
            except curses.error:
                continue
            item = items[cur]
            action = None

            if k == curses.KEY_RESIZE:
                continue
            elif k == "\x1b":
                action = "cancel"
            elif k == curses.KEY_F2:
                action = "save"
            elif k == curses.KEY_F3:
                action = "test"
            elif k in ("\t", curses.KEY_DOWN):
                cur = (cur + 1) % len(items)
            elif k in (curses.KEY_BTAB, curses.KEY_UP):
                cur = (cur - 1) % len(items)
            elif is_enter(k):
                if item.kind == "button":
                    action = item.key
                else:
                    cur = (cur + 1) % len(items)
            elif item.kind == "button":
                if k == curses.KEY_RIGHT:
                    cur = min(len(items) - 1, cur + 1)
                elif k == curses.KEY_LEFT:
                    cur = max(len(fields), cur - 1)
                elif k == " ":
                    action = item.key
            elif item.kind == "choice":
                idx = item.choices.index(item.value) if item.value in item.choices else 0
                if k in (curses.KEY_RIGHT, " ", "+"):
                    item.value = item.choices[(idx + 1) % len(item.choices)]
                elif k in (curses.KEY_LEFT, "-"):
                    item.value = item.choices[(idx - 1) % len(item.choices)]
            else:  # Textfeld
                if k == curses.KEY_LEFT:
                    item.pos = max(0, item.pos - 1)
                elif k == curses.KEY_RIGHT:
                    item.pos = min(len(item.value), item.pos + 1)
                elif k in (curses.KEY_HOME, "\x01"):
                    item.pos = 0
                elif k in (curses.KEY_END, "\x05"):
                    item.pos = len(item.value)
                elif is_backspace(k):
                    if item.pos > 0:
                        item.value = item.value[:item.pos - 1] + item.value[item.pos:]
                        item.pos -= 1
                elif k == curses.KEY_DC:
                    item.value = item.value[:item.pos] + item.value[item.pos + 1:]
                elif k == "\x15":  # Strg+U
                    item.value, item.pos, item.off = "", 0, 0
                elif isinstance(k, str) and k.isprintable():
                    item.value = item.value[:item.pos] + k + item.value[item.pos:]
                    item.pos += len(k)

            if action == "test":
                self.cursor(False)
                try:
                    cand = validate_settings(collect(), is_global)
                    eff, _ = resolve(empty_settings() if is_global else cand, cand if is_global else g)
                except ProxyError as e:
                    error = "Test nicht möglich: %s" % e
                    continue
                self.run_test(eff, unit, "Test %s (ungespeicherte Werte)" % (unit or "globaler Proxy"))
                error = ""
                continue
            if action == "cancel":
                self.cursor(False)
                return None
            if action == "remove":
                self.cursor(False)
                return "remove"
            if action == "save":
                try:
                    cand = validate_settings(collect(), is_global)
                    if not is_global:
                        eff, _ = resolve(cand, g)
                        check_mode(eff)
                except ProxyError as e:
                    error = "Nicht gespeichert: %s" % e
                    continue
                self.cursor(False)
                return cand
            error = ""


def run_tui(cfg, args):
    os.environ.setdefault("ESCDELAY", "25")
    try:
        to_restart = curses.wrapper(lambda scr: TUI(scr, cfg, args).run())
    except KeyboardInterrupt:
        to_restart = []
    if to_restart:
        print("Starte geänderte Dienste neu:")
        return 0 if print_restart_results(restart_units(to_restart)) else 1
    return 0


# --------------------------------------------------------------------------
# Kommandozeile
# --------------------------------------------------------------------------

def print_restart_results(results):
    ok_all = True
    for unit, ok, text in results:
        print("  %-40s %s" % (unit, text))
        ok_all &= ok
    return ok_all


def finish(changed, warnings, args):
    for w in warnings:
        print("Warnung: " + w, file=sys.stderr)
    if not changed:
        print("Keine Änderungen an Diensten nötig.")
        return 0
    print("Geänderte Dienste: " + ", ".join(sorted(changed)))
    if not args.no_reload:
        daemon_reload()
        print("systemd neu geladen (daemon-reload).")
    restart = args.restart
    if restart is None and sys.stdin.isatty() and sys.stdout.isatty():
        try:
            ans = input("Geänderte Dienste jetzt neu starten? [j/N] ").strip().lower()
        except EOFError:
            ans = ""
        restart = ans in ("j", "ja", "y", "yes")
    if restart:
        return 0 if print_restart_results(restart_units(changed)) else 1
    print("Hinweis: Die Änderungen gelten erst nach einem Neustart, z.B.:")
    print("  systemctl restart " + " ".join(sorted(changed)))
    return 0


def fields_from_args(args):
    """Liefert nur die per Parameter angegebenen Felder."""
    vals = {}
    if args.proxy is not None:
        vals.update(parse_proxy_url(args.proxy))
    for key in ("type", "host", "port", "user", "password", "no_proxy", "mode"):
        v = getattr(args, key)
        if v is not None:
            vals[key] = v
    if args.password_stdin:
        line = sys.stdin.readline()
        vals["password"] = line.rstrip("\r\n")
    return vals


def cmd_global(cfg, args):
    if args.clear:
        cfg.global_ = empty_settings()
    else:
        vals = fields_from_args(args)
        if not vals:
            return cmd_show(cfg, argparse.Namespace(unit=None))
        new = dict(cfg.global_)
        if not new["host"] and not new["no_proxy"] and "no_proxy" not in vals:
            new["no_proxy"] = DEFAULT_NO_PROXY
        new.update(vals)
        cfg.global_ = validate_settings(new, is_global=True)
    cfg.save()
    print("Globaler Proxy: " + (proxy_url(cfg.global_, mask=True) if cfg.global_["host"] else "(gelöscht)"))
    changed, warnings = cfg.apply_all()
    return finish(changed, warnings, args)


def cmd_set(cfg, args):
    vals = fields_from_args(args)
    units = [normalize_unit(u) for u in args.units]
    staged = {}
    for unit in units:
        base = empty_settings() if args.reset or unit not in cfg.services else dict(cfg.services[unit])
        base.update(vals)
        svc = validate_settings(base, is_global=False)
        try:
            eff, _ = resolve(svc, cfg.global_)
            check_mode(eff)
        except ProxyError as e:
            raise ProxyError("%s: %s" % (unit, e))
        staged[unit] = svc
        print("%s -> %s%s%s" % (unit, proxy_url(eff, mask=True),
                                "  (global)" if is_inheriting(svc) else "", mode_suffix(eff)))
    cfg.services.update(staged)
    cfg.save()
    changed, warnings = set(), []
    for unit in units:
        ch, warn = cfg.apply_unit(unit)
        if ch:
            changed.add(unit)
        if warn:
            warnings.append(warn)
    return finish(changed, warnings, args)


def cmd_unset(cfg, args):
    if args.all:
        units = sorted(set(cfg.services) | cfg.managed_dropins())
    elif args.units:
        units = [normalize_unit(u) for u in args.units]
    else:
        raise ProxyError("Bitte Dienst(e) angeben oder --all verwenden")
    for unit in units:
        if unit not in cfg.services and unit not in cfg.managed_dropins():
            print("%s: hat keinen Proxy" % unit)
        cfg.services.pop(unit, None)
    cfg.save()
    changed = set()
    for unit in units:
        ch, _ = cfg.apply_unit(unit)
        if ch:
            changed.add(unit)
            print("%s: Proxy entfernt" % unit)
    return finish(changed, [], args)


def cmd_apply(cfg, args):
    changed, warnings = cfg.apply_all()
    return finish(changed, warnings, args)


def cmd_list(cfg, args):
    print("Global: " + (proxy_url(cfg.global_, mask=True) if cfg.global_["host"] else "(nicht gesetzt)"))
    if cfg.global_["no_proxy"]:
        print("        No-Proxy: " + cfg.global_["no_proxy"])
    print()
    if args.all:
        units = list_system_services()
        for u in cfg.services:
            units.setdefault(u, {"active": "", "file": "?"})
        names = sorted(units)
    else:
        units = {}
        names = sorted(cfg.services)
        if not names:
            print("Keine Dienste mit Proxy konfiguriert. (--all zeigt alle Dienste)")
            return 0
    width = max([len(n) for n in names] + [6])
    print("   %-*s  %-10s  %s" % (width, "Dienst", "Status", "Proxy"))
    for name in names:
        svc = cfg.services.get(name)
        state = (units.get(name) or {}).get("active") or (unit_active(name) if not args.all else "")
        if svc is None:
            mark, text = " ", ""
        else:
            mark = "G" if is_inheriting(svc) else "E"
            try:
                eff, _ = resolve(svc, cfg.global_)
                text = proxy_url(eff, mask=True) + mode_suffix(eff)
            except ProxyError as e:
                text = "FEHLER: %s" % e
        print(" %s %-*s  %-10s  %s" % (mark, width, name, state, text))
    print("\nG = nutzt globalen Proxy, E = eigene Werte")
    return 0


def cmd_show(cfg, args):
    if not args.unit:
        g = cfg.global_
        print("Globaler Proxy")
        if not g["host"]:
            print("  (nicht gesetzt)")
        for key in FIELDS:
            val = g[key]
            if key == "password" and val:
                val = "****"
            print("  %-10s %s" % (FIELD_LABELS[key], val or "-"))
        if g["host"]:
            print("  %-10s %s" % ("URL", proxy_url(g, mask=True)))
        return 0
    unit = normalize_unit(args.unit)
    svc = cfg.services.get(unit)
    print("Dienst:    %s" % unit)
    print("Status:    %s" % unit_active(unit))
    if svc is None:
        print("Proxy:     keiner")
        return 0
    print("Herkunft:  %s" % ("nutzt globalen Proxy" if is_inheriting(svc) else "eigene Werte (leere Felder = global)"))
    try:
        eff, src = resolve(svc, cfg.global_)
    except ProxyError as e:
        print("FEHLER:    %s" % e)
        return 1
    print("Modus:     %s" % MODE_LABELS[eff["mode"]])
    for key in FIELDS:
        val = eff[key]
        if key == "password" and val:
            val = "****"
        print("  %-10s %-35s (%s)" % (FIELD_LABELS[key], val or "-", src[key]))
    print("Proxy-URL: %s" % proxy_url(eff, mask=True))
    print("Drop-in:   %s" % cfg.dropin_file(unit))
    print("Env-Datei: %s" % cfg.env_file(unit))
    if eff["mode"] == "proxychains":
        print("proxychains-Konfiguration: %s" % cfg.pc_file(unit))
    return 0


def print_test_results(results):
    marks = {"ok": "[ OK ]  ", "fail": "[FEHLER]", "info": "        "}
    for status, text in results:
        print("%s %s" % (marks[status], text))
    failed = sum(1 for st, _ in results if st == "fail")
    print()
    print("Ergebnis: alles in Ordnung." if not failed else "Ergebnis: %d Test(s) fehlgeschlagen." % failed)
    return 1 if failed else 0


def test_targets(args):
    targets = [parse_target(t) for t in (args.target or [])]
    if args.mail:
        targets += [parse_target(t) for t in TEST_TARGETS_MAIL]
    if not targets:
        targets = [parse_target(t) for t in TEST_TARGETS_WEB]
    return targets


def cmd_test(cfg, args):
    vals = fields_from_args(args)
    targets = test_targets(args)
    if args.unit:
        unit = normalize_unit(args.unit)
        base = dict(cfg.services.get(unit) or empty_settings())
        base.update(vals)
        svc = validate_settings(base, is_global=False)
        what = "Dienst %s%s" % (unit, "" if unit in cfg.services else " (nicht konfiguriert → globaler Proxy)")
    else:
        unit = None
        svc = validate_settings(vals, is_global=False)
        what = "angegebener Proxy" if any(svc[k] for k in FIELDS) else "globaler Proxy"
    eff, _ = resolve(svc, cfg.global_)
    print("Verbindungstest: %s" % what)
    return print_test_results(run_proxy_test(eff, targets, cfg, unit, args.timeout))


def build_parser():
    def common(sub):
        d = argparse.SUPPRESS if sub else None
        p = argparse.ArgumentParser(add_help=False)
        g = p.add_mutually_exclusive_group()
        g.add_argument("--restart", dest="restart", action="store_const", const=True,
                       default=d)
        g.add_argument("--no-restart", dest="restart", action="store_const", const=False,
                       default=d)
        p.add_argument("--no-reload", action="store_true", default=argparse.SUPPRESS if sub else False)
        p.add_argument("--config-dir", default=d)
        p.add_argument("--systemd-dir", default=d)
        return p

    fields = argparse.ArgumentParser(add_help=False)
    fields.add_argument("--proxy")
    fields.add_argument("--type", type=str.lower, choices=PROXY_TYPES)
    fields.add_argument("--host")
    fields.add_argument("--port")
    fields.add_argument("--user")
    fields.add_argument("--password")
    fields.add_argument("--password-stdin", action="store_true")
    fields.add_argument("--no-proxy", dest="no_proxy")
    fields.add_argument("--mode", type=str.lower, choices=MODES)

    parser = argparse.ArgumentParser(prog=PROG, add_help=False, parents=[common(False)])
    parser.add_argument("-V", "--version", action="version", version="%s %s" % (PROG, VERSION))
    sub = parser.add_subparsers(dest="command")
    c = common(True)
    sub.add_parser("tui", add_help=False, parents=[c])
    sub.add_parser("help", add_help=False)
    p = sub.add_parser("list", add_help=False, parents=[c])
    p.add_argument("--all", action="store_true")
    p = sub.add_parser("show", add_help=False, parents=[c])
    p.add_argument("unit", nargs="?")
    p = sub.add_parser("global", add_help=False, parents=[c, fields])
    p.add_argument("--clear", action="store_true")
    p = sub.add_parser("set", add_help=False, parents=[c, fields])
    p.add_argument("units", nargs="+", metavar="DIENST")
    p.add_argument("--reset", action="store_true")
    p = sub.add_parser("unset", add_help=False, parents=[c])
    p.add_argument("units", nargs="*", metavar="DIENST")
    p.add_argument("--all", action="store_true")
    sub.add_parser("apply", add_help=False, parents=[c])
    p = sub.add_parser("test", add_help=False, parents=[c, fields])
    p.add_argument("unit", nargs="?")
    p.add_argument("--target", action="append", metavar="HOST:PORT")
    p.add_argument("--mail", action="store_true")
    p.add_argument("--timeout", type=float, default=TEST_TIMEOUT)
    return parser


def main(argv=None):
    argv = sys.argv[1:] if argv is None else argv
    if argv and argv[0] == "_probe":  # intern: Kindprozess des proxychains-Selbsttests
        return probe_direct(argv[2:], float(argv[1]))
    if "-h" in argv or "--help" in argv or (argv and argv[0] == "help"):
        print(HELP_TEXT)
        return 0
    args = build_parser().parse_args(argv)
    command = args.command or "tui"

    custom_dirs = bool(args.config_dir or args.systemd_dir)
    cfg = Config(args.config_dir or DEFAULT_CONFIG_DIR, args.systemd_dir or DEFAULT_SYSTEMD_DIR)
    # 'test' mit eigenem --proxy/--host geht auch ohne root (ohne gespeicherte Konfiguration)
    adhoc_test = (command == "test" and not args.unit and (args.proxy or args.host))
    if os.geteuid() != 0 and not custom_dirs and not adhoc_test:
        print("%s: Bitte als root ausführen (z.B. sudo %s)." % (PROG, PROG), file=sys.stderr)
        return 1
    try:
        locale.setlocale(locale.LC_ALL, "")
    except locale.Error:
        pass

    try:
        try:
            cfg.load()
        except ProxyError:
            if not adhoc_test:
                raise
        if command == "tui":
            if not sys.stdin.isatty() or not sys.stdout.isatty():
                raise ProxyError("Das Menü braucht ein Terminal. Für Skripte die Befehle nutzen (siehe --help).")
            return run_tui(cfg, args)
        handler = {
            "list": cmd_list, "show": cmd_show, "global": cmd_global, "set": cmd_set,
            "unset": cmd_unset, "apply": cmd_apply, "test": cmd_test,
        }[command]
        return handler(cfg, args)
    except ProxyError as e:
        print("%s: Fehler: %s" % (PROG, e), file=sys.stderr)
        return 1
    except OSError as e:
        print("%s: Fehler: %s" % (PROG, e), file=sys.stderr)
        return 1


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