#!/usr/bin/env python3
"""
Wuerth VPN – plattformuebergreifende Tray-App (PyQt6).

Baut das VPN rootlos auf (openconnect + ocproxy als SOCKS5) und startet einen
dedizierten Browser, der per PAC nur die internen Hosts ueber das VPN schickt
und alles andere direkt. Start/Stop ueber das Tray-Icon.

macOS/Linux: lauffaehig. Windows: rootloser ocproxy-Pfad noch zu verifizieren.

Abhaengigkeiten:
  pip3 install PyQt6 --break-system-packages
  openconnect + ocproxy auf dem PATH (macOS: brew install openconnect ocproxy)
"""

from __future__ import annotations

import json
import os
import platform
import shutil
import signal
import socket
import subprocess
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

from PyQt6.QtCore import QTimer, Qt
from PyQt6.QtGui import (
    QAction, QColor, QIcon, QImage, QPainter, QPixmap,
    qAlpha, qBlue, qGreen, qRed, qRgba,
)
from PyQt6.QtWidgets import (
    QApplication, QSystemTrayIcon, QMenu, QInputDialog, QLineEdit, QMessageBox,
)

# ===== CONFIG =================================================================
SERVER        = "loki.witglobal.net"
VPN_USER      = "ex08802409"
PORT          = 11080            # SOCKS5 (ocproxy)
PACPORT       = 11081            # lokaler PAC-HTTP-Server
INTERNAL_GLOB = "*.wgn.wuerth.com"   # nur diese Hosts ueber das VPN
PROFILE       = os.path.expanduser("~/.wuerth-edge")
START_URL     = "https://www.wuerth.com"
ICON_PATH     = os.path.join(
    os.environ.get("RESOURCEPATH", os.path.dirname(os.path.abspath(__file__))),
    "wuerth.png",
)

# Lesezeichen, die ins frische Profil gesaet werden (Name, URL).
BOOKMARKS = [
    ("ArgoCD Dev",    "https://gitops.apps.ocp-dev01.wgn.wuerth.com"),
    ("ArgoCD Prod",   "https://gitops.apps.ocp-01.wgn.wuerth.com/"),
    ("OpenShift DEV", "https://console-openshift-console.apps.ocp-dev01.wgn.wuerth.com"),
    ("OpenShift Prod","https://console-openshift-console.apps.ocp-01.wgn.wuerth.com/dashboards"),
    ("Plato",         "https://plato.witglobal.net"),
    ("Wuerth GitHub", "https://github.com/login"),
    ("Wuerth",        "https://www.wuerth.com"),
]
# =============================================================================


# ---------- Plattform-Abstraktion --------------------------------------------
def _system() -> str:
    return platform.system()  # 'Darwin' | 'Linux' | 'Windows'


def browser_argv() -> list[str]:
    s = _system()
    if s == "Darwin":
        return ["/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"]
    if s == "Windows":
        for p in (
            r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
            r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
        ):
            if os.path.exists(p):
                return [p]
        return ["msedge.exe"]
    # Linux
    for name in ("microsoft-edge", "microsoft-edge-stable", "msedge"):
        path = shutil.which(name)
        if path:
            return [path]
    return ["microsoft-edge"]


def seed_profile():
    """Saet beim ersten Start Lesezeichen + sichtbare Leiste ins Profil."""
    default = os.path.join(PROFILE, "Default")
    bm_path = os.path.join(default, "Bookmarks")
    if not os.path.exists(bm_path):
        os.makedirs(default, exist_ok=True)
        children = [
            {"date_added": "0", "id": str(100 + i), "name": name,
             "type": "url", "url": url}
            for i, (name, url) in enumerate(BOOKMARKS)
        ]
        data = {
            "checksum": "",
            "roots": {
                "bookmark_bar": {"children": children, "date_added": "0",
                                 "id": "1", "name": "Lesezeichenleiste",
                                 "type": "folder"},
                "other":  {"children": [], "date_added": "0", "id": "2",
                           "name": "Weitere Lesezeichen", "type": "folder"},
                "synced": {"children": [], "date_added": "0", "id": "3",
                           "name": "Mobile Lesezeichen", "type": "folder"},
            },
            "version": 1,
        }
        with open(bm_path, "w") as f:
            json.dump(data, f, indent=2)

    # Lesezeichenleiste dauerhaft einblenden
    pref_path = os.path.join(default, "Preferences")
    if not os.path.exists(pref_path):
        os.makedirs(default, exist_ok=True)
        with open(pref_path, "w") as f:
            json.dump({"bookmark_bar": {"show_on_all_tabs": True}}, f)


def _tool(name: str) -> str:
    path = shutil.which(name)
    if path:
        return path
    if _system() == "Darwin":
        cand = f"/opt/homebrew/bin/{name}"
        if os.path.exists(cand):
            return cand
    return name


# ---------- PAC-Server (in-process) ------------------------------------------
PAC_BODY = (
    "function FindProxyForURL(url, host) {\n"
    f'  if (shExpMatch(host, "{INTERNAL_GLOB}")) {{\n'
    f'    return "SOCKS5 127.0.0.1:{PORT}";\n'
    "  }\n"
    '  return "DIRECT";\n'
    "}\n"
)


class _PacHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        body = PAC_BODY.encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/x-ns-proxy-autoconfig")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *_):
        pass


def start_pac_server() -> ThreadingHTTPServer:
    srv = ThreadingHTTPServer(("127.0.0.1", PACPORT), _PacHandler)
    threading.Thread(target=srv.serve_forever, daemon=True).start()
    return srv


# ---------- Icon -------------------------------------------------------------
def dot_icon(color: str) -> QIcon:
    pm = QPixmap(22, 22)
    pm.fill(Qt.GlobalColor.transparent)
    p = QPainter(pm)
    p.setRenderHint(QPainter.RenderHint.Antialiasing)
    p.setBrush(QColor(color))
    p.setPen(Qt.PenStyle.NoPen)
    p.drawEllipse(4, 4, 14, 14)
    p.end()
    return QIcon(pm)


_BASE_IMG: QImage | None = None
_ICON_CACHE: dict[str, QIcon] = {}


def _base_image() -> QImage:
    global _BASE_IMG
    if _BASE_IMG is None:
        img = QImage(ICON_PATH)
        if img.isNull():
            _BASE_IMG = QImage()
        else:
            _BASE_IMG = img.scaled(
                44, 44, Qt.AspectRatioMode.KeepAspectRatio,
                Qt.TransformationMode.SmoothTransformation,
            ).convertToFormat(QImage.Format.Format_ARGB32)
    return _BASE_IMG


def logo_icon(color: str) -> QIcon:
    """Wuerth-Wappen in der Zustandsfarbe; weisser Hintergrund -> transparent."""
    if color in _ICON_CACHE:
        return _ICON_CACHE[color]
    base = _base_image()
    if base.isNull():
        return dot_icon(color)

    tgt = QColor(color)
    img = QImage(base)
    for y in range(img.height()):
        for x in range(img.width()):
            px = img.pixel(x, y)
            a = qAlpha(px)
            if a < 20:
                continue
            r, g, b = qRed(px), qGreen(px), qBlue(px)
            hi, lo = max(r, g, b), min(r, g, b)
            if hi > 225 and (hi - lo) < 30:          # weiss -> transparent
                img.setPixel(x, y, qRgba(0, 0, 0, 0))
            elif hi < 90:                            # schwarzer Text -> belassen
                continue
            else:                                    # rote Flaeche -> Zustandsfarbe
                img.setPixel(x, y, qRgba(tgt.red(), tgt.green(), tgt.blue(), a))

    icon = QIcon(QPixmap.fromImage(img))
    _ICON_CACHE[color] = icon
    return icon


# ---------- App --------------------------------------------------------------
class VpnTray:
    def __init__(self, app: QApplication):
        self.app = app
        self.oc: subprocess.Popen | None = None
        self.browser: subprocess.Popen | None = None
        self.vpn_pw: str | None = None
        self.state = "disconnected"

        self.pac = start_pac_server()

        self.tray = QSystemTrayIcon()
        self.tray.setIcon(logo_icon("#9a9a9a"))
        self.tray.setToolTip("Wuerth VPN – getrennt")

        self.menu = QMenu()
        self.act_status = QAction("Getrennt")
        self.act_status.setEnabled(False)
        self.act_connect = QAction("Verbinden")
        self.act_disconnect = QAction("Trennen")
        self.act_open = QAction("Edge oeffnen")
        self.act_quit = QAction("Beenden")

        self.act_connect.triggered.connect(self.connect)
        self.act_disconnect.triggered.connect(self.disconnect)
        self.act_open.triggered.connect(self.launch_browser)
        self.act_quit.triggered.connect(self.quit)

        for a in (self.act_status, None, self.act_connect, self.act_disconnect,
                  self.act_open, None, self.act_quit):
            if a is None:
                self.menu.addSeparator()
            else:
                self.menu.addAction(a)

        self.tray.setContextMenu(self.menu)
        self.tray.show()
        self._set_state("disconnected")

        # Pollt openconnect/SOCKS-Status
        self.timer = QTimer()
        self.timer.timeout.connect(self._tick)
        self.timer.start(700)

    # --- UI-State ---
    def _set_state(self, state: str):
        self.state = state
        color, label = {
            "disconnected": ("#9a9a9a", "Getrennt"),
            "connecting":   ("#e6a817", "Verbinde…"),
            "connected":    ("#cc0000", "Verbunden"),
            "error":        ("#666666", "Fehler"),
        }[state]
        self.tray.setIcon(logo_icon(color))
        self.tray.setToolTip(f"Wuerth VPN – {label}")
        self.act_status.setText(label)
        self.act_connect.setEnabled(state in ("disconnected", "error"))
        self.act_disconnect.setEnabled(state in ("connecting", "connected"))
        self.act_open.setEnabled(state == "connected")

    # --- Verbinden ---
    def connect(self):
        if not self.vpn_pw:
            pw, ok = QInputDialog.getText(
                None, "Wuerth VPN", f"VPN-Passwort fuer {VPN_USER}:",
                QLineEdit.EchoMode.Password,
            )
            if not (ok and pw):
                return
            self.vpn_pw = pw

        cmd = [
            _tool("openconnect"),
            "--protocol=anyconnect",
            "--user", VPN_USER,
            "--passwd-on-stdin",
            "--script", f"{_tool('ocproxy')} -D {PORT}",
            "--script-tun",
            f"https://{SERVER}",
        ]
        try:
            self._log = open(os.path.expanduser("~/.wuerth-vpn.log"), "wb")
            self.oc = subprocess.Popen(
                cmd, stdin=subprocess.PIPE,
                stdout=self._log, stderr=subprocess.STDOUT,
            )
            self.oc.stdin.write((self.vpn_pw + "\n").encode())
            self.oc.stdin.flush()
        except FileNotFoundError:
            self._set_state("error")
            QMessageBox.critical(None, "Wuerth VPN", "openconnect nicht gefunden.")
            return

        self._set_state("connecting")

    # --- Trennen ---
    def disconnect(self):
        self._kill_browser()
        if self.oc and self.oc.poll() is None:
            # SIGINT -> openconnect sendet ein sauberes BYE und meldet die
            # Session am Gateway ab (statt sie per SIGTERM hart zu kappen).
            try:
                self.oc.send_signal(signal.SIGINT)
                self.oc.wait(timeout=4)
            except Exception:
                try:
                    self.oc.terminate()
                except Exception:
                    pass
        self.oc = None
        self._set_state("disconnected")

    def _kill_browser(self):
        # Ueber das dedizierte Profil beenden – trifft nur diese Instanz,
        # nicht ein evtl. normal laufendes Edge.
        if _system() in ("Darwin", "Linux"):
            # Muster ohne fuehrenden Bindestrich, sonst parst pkill es als Option.
            subprocess.run(
                ["pkill", "-f", f"user-data-dir={PROFILE}"],
                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
            )
        elif self.browser and self.browser.poll() is None:
            self.browser.terminate()
        self.browser = None

    # --- Browser ---
    def launch_browser(self):
        # Immer starten: laeuft schon eine Instanz mit diesem Profil, oeffnet
        # sie nur ein neues Fenster mit START_URL; sonst startet sie frisch.
        seed_profile()
        argv = browser_argv() + [
            f"--user-data-dir={PROFILE}",
            f"--proxy-pac-url=http://127.0.0.1:{PACPORT}/wuerth.pac",
            f"--host-resolver-rules=MAP {INTERNAL_GLOB} ~NOTFOUND",
            "--no-first-run", "--no-default-browser-check",
            "--disable-features=msSmartScreenProtection",
            "--test-type",  # unterdrueckt die "unsupported flag"-Warnleiste
            START_URL,
        ]
        self.browser = subprocess.Popen(argv)

    # --- Poll ---
    def _tick(self):
        if self.state == "connecting":
            if self.oc and self.oc.poll() is not None:
                self._set_state("error")
                return
            if _port_open(PORT):
                self._set_state("connected")
                self.launch_browser()
        elif self.state == "connected":
            if self.oc and self.oc.poll() is not None:
                self.disconnect()

    def quit(self):
        self.disconnect()
        self.pac.shutdown()
        self.app.quit()


def _port_open(port: int) -> bool:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(0.3)
        return s.connect_ex(("127.0.0.1", port)) == 0


def main():
    # Kein Dock-Icon: ueber LSUIElement im py2app-Bundle (greift beim Start).
    app = QApplication([])
    app.setApplicationName("WuerthVPN")
    app.setQuitOnLastWindowClosed(False)
    _tray = VpnTray(app)  # noqa: F841  (Referenz halten)
    app.exec()


if __name__ == "__main__":
    main()
