# -*- coding: utf-8 -*-
"""Build sing-box config: Claude/proxy-apps -> Squid; per-app VPN/direct; unknown -> block."""
from __future__ import annotations

import json
import os
import sys
from datetime import datetime
from pathlib import Path
from typing import Any

# Вывод уходит в pipe, а не в консоль, поэтому Python берёт кодировку системы
# (здесь cp1251). Одна не-ASCII буква в диагностическом сообщении роняла сборку
# конфига целиком — и именно на аварийных ветках, где такие сообщения и печатаются.
# Кодировку задаём явно, чтобы диагностика никогда не была причиной отказа.
for _stream in (sys.stdout, sys.stderr):
    try:
        _stream.reconfigure(encoding="utf-8", errors="replace")  # type: ignore[union-attr]
    except Exception:
        pass

# Allow importing app_rules from product desktop when CLAUDE_PROXY_HOME/desktop-path is set
_DESKTOP_CANDIDATES = [
    Path(os.environ.get("USERPROFILE", "")) / ".claude-proxy" / "product" / "desktop",
]
_dp = Path(os.environ.get("CLAUDE_PROXY_HOME", "") or "") / "desktop-path.txt"
if _dp.is_file():
    try:
        # utf-8-sig: PowerShell Set-Content -Encoding UTF8 writes BOM
        _DESKTOP_CANDIDATES.insert(0, Path(_dp.read_text(encoding="utf-8-sig").strip()))
    except Exception:
        pass
for _c in _DESKTOP_CANDIDATES:
    if _c.is_dir() and str(_c) not in sys.path:
        sys.path.insert(0, str(_c))

try:
    from app_rules import channel_groups_for_builder  # type: ignore
except Exception:
    channel_groups_for_builder = None  # type: ignore

try:
    from app_rules import PROBE_EXES  # type: ignore
except Exception:
    # Keep in sync with app_rules.PROBE_EXES
    PROBE_EXES = {  # type: ignore
        "cpc-probe-direct.exe": "direct",
        "cpc-probe-vpn.exe": "vpn",
        "cpc-probe-squid.exe": "proxy",
    }

# Hardening defaults. Mirrors settings_store.DEFAULTS; used when settings.json is
# missing or unreadable (e.g. builder invoked from the elevated reload path).
HARDENING_DEFAULTS: dict[str, Any] = {
    "strict_route": True,
    "tunnel_ipv6": True,
    "secure_dns": True,
    "clash_api_port": 9095,
}

# Address block for the tunnel's IPv6 side. Without it sing-box never sees IPv6 at
# all, so on an IPv6-capable network every "block"/"proxy" app egresses natively.
TUN_INET6 = "fdfe:dcba:9876::1/126"
DNS_RESOLVER_IP = "1.1.1.1"


def load_settings_for_build(home: Path) -> dict[str, Any]:
    data = dict(HARDENING_DEFAULTS)
    path = home / "settings.json"
    if path.is_file():
        try:
            raw = json.loads(path.read_text(encoding="utf-8-sig"))
            if isinstance(raw, dict):
                for key in HARDENING_DEFAULTS:
                    if key in raw:
                        data[key] = raw[key]
        except Exception:
            pass
    # Env override wins - lets the UI force a plain config while diagnosing.
    for key in ("strict_route", "tunnel_ipv6", "secure_dns"):
        env_key = "CPC_" + key.upper()
        val = os.environ.get(env_key, "").strip()
        if val:
            data[key] = val not in {"0", "false", "False", "no"}
    return data


def clash_api_credentials(home: Path, port: int) -> dict[str, Any]:
    """Stable local-only controller address + secret, persisted for the UI to reuse."""
    import secrets as _secrets

    path = home / "merged" / "clash-api.json"
    data: dict[str, Any] = {}
    if path.is_file():
        try:
            raw = json.loads(path.read_text(encoding="utf-8-sig"))
            if isinstance(raw, dict):
                data = raw
        except Exception:
            data = {}
    secret = str(data.get("secret") or "").strip()
    if not secret:
        secret = _secrets.token_urlsafe(24)
    out = {"host": "127.0.0.1", "port": int(port), "secret": secret}
    try:
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8")
    except Exception:
        pass
    return out


def product_home() -> Path:
    env = os.environ.get("CLAUDE_PROXY_HOME", "").strip()
    if env:
        return Path(env)
    return Path(os.environ["USERPROFILE"]) / ".claude-proxy-control"


def load_env(path: Path) -> dict[str, str]:
    env: dict[str, str] = {}
    for raw in path.read_text(encoding="utf-8").splitlines():
        line = raw.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        k, v = line.split("=", 1)
        env[k.strip()] = v.strip()
    return env


def extract_vless(sota: dict[str, Any]) -> list[dict[str, Any]]:
    out: list[dict[str, Any]] = []
    for ob in sota.get("outbounds") or []:
        if not isinstance(ob, dict) or ob.get("type") != "vless":
            continue
        node = dict(ob)
        tag = str(node.get("tag") or "proxy")
        node["tag"] = "sota-vless" if tag in {"proxy", "sota", "vpn"} else f"sota-{tag}"
        mux = node.get("multiplex")
        if mux is None or mux == {} or mux is False:
            node.pop("multiplex", None)
        transport = node.get("transport")
        if transport is None or transport == {}:
            node.pop("transport", None)
        flow = str(node.get("flow") or "")
        if "vision" in flow.lower():
            node.pop("multiplex", None)
        if not node.get("packet_encoding"):
            node["packet_encoding"] = "xudp"
        out.append(node)
    return out


def _groups_fallback(home: Path) -> dict[str, list[str]]:
    """If app_rules import failed — migrate from direct-apps.json only."""
    direct: list[str] = []
    seen: set[str] = set()
    path = home / "merged" / "direct-apps.json"
    if path.is_file():
        try:
            raw = json.loads(path.read_text(encoding="utf-8"))
            for item in raw if isinstance(raw, list) else []:
                if not isinstance(item, dict):
                    continue
                name = Path(str(item.get("exe") or "")).name
                key = name.lower()
                if not name or key in {"claude.exe", "cowork-svc.exe"} or key in seen:
                    continue
                seen.add(key)
                direct.append(name)
                if name != key:
                    direct.append(key)
        except Exception:
            pass
    return {"direct": direct, "vpn": [], "proxy": [], "block": []}


def _tcp_ok(host: str, port: int, timeout: float = 3.0) -> bool:
    if not host or port <= 0:
        return False
    import socket

    try:
        with socket.create_connection((host, int(port)), timeout=timeout):
            return True
    except Exception:
        return False


def _vpn_channel_usable(home: Path, vpn_server: str, vpn_port: int) -> tuple[bool, str]:
    """
    Fail-closed gate for apps with channel=vpn.
    If VPN node is dead — those apps must NOT fall through to home internet.
    """
    # Fresh Start-VpnClaude status beats a lagging monitor-status.json
    status_path = home / "merged" / "status.json"
    if status_path.is_file():
        try:
            st = json.loads(status_path.read_text(encoding="utf-8-sig"))
            if st.get("ok") and st.get("vpnModeActive") and st.get("vpnProbeOk"):
                eg = str(st.get("vpnEgress") or "").strip()
                return True, f"status.json vpn ok egress={eg or '?'}"
            # Отрицательный вердикт принимаем только от свежей записи: status.json
            # переписывается лишь при перезапуске TUN, и вчерашнее "vpnProbeOk=false"
            # иначе блокировало бы VPN-приложения до следующего применения маршрутов.
            if (
                st.get("ok")
                and st.get("vpnModeActive")
                and st.get("vpnProbeOk") is False
                and _sample_age_sec(str(st.get("time") or "")[:19]) <= MONITOR_SAMPLE_MAX_AGE_SEC
            ):
                return False, "status.json vpnProbeOk=false - VPN apps -> BLOCK"
        except Exception:
            pass

    # Monitor: only trust a negative result when it saw TUN up (stale tun_up=false
    # with vpn_node TCP-ok used to false-fail / confuse UI after a good Start).
    mon = home / "merged" / "monitor-status.json"
    if mon.is_file():
        try:
            data = json.loads(mon.read_text(encoding="utf-8-sig"))
            vpn = data.get("vpn_node") or {}
            age = _sample_age_sec(data.get("time"))
            if data.get("tun_up") is True and "ok" in vpn and age <= MONITOR_SAMPLE_MAX_AGE_SEC:
                ok = bool(vpn.get("ok"))
                detail = str(vpn.get("detail") or ("monitor ok" if ok else "monitor fail"))
                return ok, detail
        except Exception:
            pass
    # Fallback: TCP to VLESS server.
    # Осторожно: пока поднят TUN, этот ответ ничего не значит — sing-box завершает
    # рукопожатие локально, и connect() успешен к любому адресу. Здесь это
    # безопасно только потому, что ошибка склоняет к «канал жив»: VPN-приложения
    # не блокируются, а просто не достучатся до мёртвого узла. Ужесточать эту
    # ветку без протокольной проверки VLESS нельзя.
    if _tcp_ok(vpn_server, vpn_port):
        return True, f"TCP {vpn_server}:{vpn_port} ok"
    return False, f"TCP {vpn_server}:{vpn_port} fail - VPN apps will be BLOCKed"


def _sample_age_sec(stamp: Any) -> float:
    """Возраст отсчёта монитора в секундах; inf, если время не разобрать."""
    try:
        return (datetime.now() - datetime.fromisoformat(str(stamp))).total_seconds()
    except Exception:
        return float("inf")


# Отсчёт старше этого — уже не свидетельство о текущем состоянии канала.
MONITOR_SAMPLE_MAX_AGE_SEC = 90


try:
    # Одна проверка на всю программу: интерфейс и сборщик конфига обязаны
    # отвечать на «жив ли прокси» одинаково, иначе на экране одно, а в конфиге
    # другое. Импорт может не пройти (сборщик запускают и в отрыве от desktop),
    # тогда работает запасная копия ниже.
    from status import squid_alive as _status_squid_alive  # type: ignore
except Exception:
    _status_squid_alive = None  # type: ignore


def _squid_alive(host: str, port: int, user: str = "", password: str = "", timeout: float = 4.0) -> bool:
    """Отвечает ли Squid как HTTP-прокси.

    Проверять живость сокетом здесь нельзя: пока поднят TUN, sing-box сам
    завершает рукопожатие локально, ещё не зная, достижим ли адрес, — connect()
    успешен даже к заведомо несуществующим IP. Свидетельством может быть только
    ответ самого прокси, поэтому шлём запрос и ждём любую строку статуса HTTP.
    Ответ 400/407 тоже годится: он доказывает, что на том конце живой Squid.
    """
    import base64
    import socket

    if not host or port <= 0:
        return False
    if _status_squid_alive is not None:
        try:
            return bool(_status_squid_alive(host, port, user, password, timeout)[0])
        except Exception:
            pass
    try:
        with socket.create_connection((host, int(port)), timeout=timeout) as s:
            s.settimeout(timeout)
            req = "GET / HTTP/1.1\r\nHost: cpc.health.check\r\nConnection: close\r\n"
            if user:
                token = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("ascii")
                req += f"Proxy-Authorization: Basic {token}\r\n"
            req += "\r\n"
            s.sendall(req.encode("ascii"))
            head = s.recv(64).decode("latin-1", "replace")
        return head.startswith("HTTP/")
    except Exception:
        return False


def _self_python_paths(home: Path) -> list[str]:
    """Полные пути интерпретатора, на котором работает сама программа.

    `python-path.txt` пишет окно при старте; рядом всегда лежит оконный
    pythonw.exe, под которым UI и работает на самом деле. Возвращаем оба —
    консольный нужен elevated-сборщику, оконный самому интерфейсу.
    """
    out: list[str] = []
    marker = home / "python-path.txt"
    try:
        raw = marker.read_text(encoding="utf-8-sig").strip()
    except Exception:
        raw = ""
    if not raw:
        return out
    exe = Path(raw)
    for name in (exe.name, "pythonw.exe", "python.exe"):
        cand = exe.with_name(name)
        try:
            if cand.is_file():
                real = str(cand.resolve())
                if real not in out:
                    out.append(real)
        except Exception:
            continue
    return out


def _squid_channel_usable(
    home: Path, host: str, port: int, user: str = "", password: str = ""
) -> tuple[bool, str]:
    """Fail-closed for Claude + channel=proxy apps: dead Squid -> BLOCK (no home leak).

    Отрицательный вердикт монитора перепроверяем настоящим запросом к прокси.
    Монитор опрашивает канал раз в 5 секунд через сам туннель, и одна неудачная
    выборка (таймаут, момент перезапуска) не должна переписывать весь конфиг в
    BLOCK: пользователь получал неработающий Claude до следующего применения
    маршрутов. Блокируем, только если и монитор, и живая проверка сходятся.
    """
    mon = home / "merged" / "monitor-status.json"
    if mon.is_file():
        try:
            data = json.loads(mon.read_text(encoding="utf-8-sig"))
            sq = data.get("squid") or {}
            age = _sample_age_sec(data.get("time"))
            if data.get("tun_up") is True and "ok" in sq and age <= MONITOR_SAMPLE_MAX_AGE_SEC:
                ok = bool(sq.get("ok"))
                detail = str(sq.get("detail") or ("squid ok" if ok else "squid fail"))
                if ok:
                    return True, detail
                if _squid_alive(host, port, user, password):
                    return True, f"monitor said fail ({detail}) but proxy answers"
                return False, detail
        except Exception:
            pass
    if _squid_alive(host, port, user, password):
        return True, f"proxy {host}:{port} answers"
    return False, f"proxy {host}:{port} silent - Claude/proxy apps -> BLOCK"


def validate_config(cfg: dict[str, Any]) -> list[str]:
    """Check what `sing-box check` does not.

    `sing-box check` validates the schema but happily accepts a route rule pointing at
    an outbound tag that does not exist, and a dns.final naming a server that does not
    exist. Both produce a config that looks fine and misroutes at runtime. The VPN tag
    is derived from the captured Sota config, so a naming change upstream would land
    exactly here.
    """
    errors: list[str] = []

    outbound_tags = {
        str(o.get("tag") or "")
        for o in cfg.get("outbounds") or []
        if isinstance(o, dict) and o.get("tag")
    }
    for i, rule in enumerate(cfg.get("route", {}).get("rules") or []):
        if not isinstance(rule, dict):
            continue
        tag = str(rule.get("outbound") or "")
        if tag and tag not in outbound_tags:
            errors.append(f"route.rules[{i}].outbound -> unknown tag '{tag}'")
    final = str(cfg.get("route", {}).get("final") or "")
    if final and final not in outbound_tags:
        errors.append(f"route.final -> unknown tag '{final}'")

    dns_tags = {
        str(s.get("tag") or "")
        for s in cfg.get("dns", {}).get("servers") or []
        if isinstance(s, dict) and s.get("tag")
    }
    dns_final = str(cfg.get("dns", {}).get("final") or "")
    if dns_final and dns_final not in dns_tags:
        errors.append(f"dns.final -> unknown server tag '{dns_final}'")
    resolver = cfg.get("route", {}).get("default_domain_resolver")
    if isinstance(resolver, str) and resolver and resolver not in dns_tags:
        errors.append(f"route.default_domain_resolver -> unknown server tag '{resolver}'")

    # New DNS format (sing-box 1.12+): omitting detour = dial like empty direct
    # (physical NIC). Explicit detour:"direct" is FATAL ("empty direct outbound").
    # Only flag a real deadlock: detour pointing at a missing / non-direct tag
    # while route.final is block.
    out_tags = {
        str(o.get("tag"))
        for o in (cfg.get("outbounds") or [])
        if isinstance(o, dict) and o.get("tag")
    }
    for s in cfg.get("dns", {}).get("servers") or []:
        if not isinstance(s, dict):
            continue
        detour = s.get("detour")
        if not detour:
            continue
        if detour == "direct":
            errors.append(
                f"dns server '{s.get('tag')}' has detour=direct "
                "(sing-box 1.12+: omit detour; empty direct dial is default)"
            )
        elif detour not in out_tags:
            errors.append(f"dns server '{s.get('tag')}' detour -> unknown outbound '{detour}'")

    return errors


def main() -> int:
    home = product_home()
    proxy_env = home / "proxy.env"
    sota_path = home / "merged" / "sota-captured" / "latest.json"
    out_path = home / "merged" / "vpn-claude-config.json"
    backup_dir = home / "merged" / "backups"
    tun_name = os.environ.get("VPN_CLAUDE_TUN_NAME", "cpc-tun").strip() or "cpc-tun"

    if not proxy_env.is_file():
        print("FAIL: missing proxy.env", file=sys.stderr)
        return 2

    env = load_env(proxy_env)
    for key in ("PROXY_HOST", "PROXY_PORT", "PROXY_USER", "PROXY_PASS"):
        if key not in env:
            print(f"FAIL: proxy.env missing {key}", file=sys.stderr)
            return 2

    # Sota — источник VPN-канала, а не условие работы. Без него программа обязана
    # собираться и маршрутизировать остальное: Claude через Squid, что-то напрямую,
    # незнакомое в block. Иначе на машине без Sota продукт просто не запускается,
    # хотя большая часть его работы от VPN не зависит.
    vless_nodes: list[dict[str, Any]] = []
    if sota_path.is_file():
        try:
            vless_nodes = extract_vless(json.loads(sota_path.read_text(encoding="utf-8")))
        except Exception as e:
            print(f"WARN: could not read captured Sota config ({e}) - VPN channel unavailable")
    else:
        print("WARN: no captured Sota config - VPN channel unavailable")

    host = env["PROXY_HOST"]
    port = int(env["PROXY_PORT"])
    user = env["PROXY_USER"]
    password = env["PROXY_PASS"]

    if vless_nodes:
        vpn_tag = vless_nodes[0]["tag"]
        vpn_server = str(vless_nodes[0].get("server") or "")
        vpn_port = int(vless_nodes[0].get("server_port") or 0)
    else:
        vpn_tag = ""
        vpn_server = ""
        vpn_port = 0
        print("WARN: no vless node - apps on the VPN channel will be BLOCKed")

    if channel_groups_for_builder:
        groups = channel_groups_for_builder()
        print("channel_groups: from app_rules.channel_groups_for_builder")
    else:
        groups = _groups_fallback(home)
        print("channel_groups: FALLBACK direct-apps.json (app_rules import failed)")

    def _has(lst: list, name: str) -> bool:
        return name.lower() in {str(x).lower() for x in lst}

    print(
        "channel_sample: "
        f"firefox direct={_has(groups.get('direct') or [], 'firefox.exe')} "
        f"vpn={_has(groups.get('vpn') or [], 'firefox.exe')} "
        f"proxy={_has(groups.get('proxy') or [], 'firefox.exe')} "
        f"block={_has(groups.get('block') or [], 'firefox.exe')} "
        f"CPC_APPLY_FINAL={os.environ.get('CPC_APPLY_FINAL','')} "
        f"CPC_QUARANTINE={os.environ.get('CPC_QUARANTINE','')}"
    )

    if vpn_tag:
        vpn_ok, vpn_why = _vpn_channel_usable(home, vpn_server, vpn_port)
    else:
        vpn_ok, vpn_why = False, "no Sota node captured"
    squid_ok, squid_why = _squid_channel_usable(home, host, port, user, password)
    vpn_apps = list(groups.get("vpn") or [])
    proxy_apps = list(groups.get("proxy") or [])
    block_apps = list(groups.get("block") or [])

    # Fail-closed: VPN channel broken → those apps go to block (no home leak)
    vpn_forced_block = False
    if vpn_apps and not vpn_ok:
        block_apps = list(dict.fromkeys(block_apps + vpn_apps))
        vpn_apps = []
        vpn_forced_block = True
        print(f"WARN: VPN channel down ({vpn_why}) - vpn apps -> BLOCK (no internet)")

    # Fail-closed: Squid down → Claude + proxy-apps BLOCK (no home / no VPN leak)
    squid_forced_block = False
    claude_outbound = "claude-squid"
    if not squid_ok:
        squid_forced_block = True
        claude_outbound = "block"
        if proxy_apps:
            block_apps = list(dict.fromkeys(block_apps + proxy_apps))
            proxy_apps = []
        print(f"WARN: Squid down ({squid_why}) - Claude + proxy-apps -> BLOCK")

    # CPC's own health probes are pinned to exactly one outbound each, and are placed
    # ahead of every user rule. They deliberately ignore the fail-closed forcing above:
    # a probe must report on the channel it names, not on the fallback that replaced it,
    # otherwise "squid ok" can never be distinguished from "squid replaced by block".
    probe_outbounds = {"direct": "direct", "vpn": vpn_tag, "proxy": "claude-squid"}
    probe_rules: list[dict[str, Any]] = []
    for probe_exe, probe_channel in PROBE_EXES.items():
        target = probe_outbounds.get(probe_channel)
        if target:
            probe_rules.append({"process_name": [probe_exe], "outbound": target})

    # Интерпретатор самой программы — тоже мимо общего правила, иначе она душит
    # себя: route.final = block, и как только туннель поднят, окно теряет связь
    # с лицензионным сервером. Продление, heartbeat, «сменить ключ» перестают
    # работать, а сама программа при этом появляется в списке неизвестных
    # приложений и предлагает выбрать ей канал — вплоть до «заблокировать».
    #
    # Пиним по ПОЛНОМУ ПУТИ, а не по имени: python.exe у пользователя свой, и
    # разрешать всё, что так называется, значит открыть дыру любому скрипту.
    self_rules: list[dict[str, Any]] = []
    self_paths = _self_python_paths(home)
    if self_paths:
        self_rules.append({"process_path": self_paths, "outbound": "direct"})
        print("self: pinned own interpreter to direct (" + str(len(self_paths)) + " paths)")
    else:
        print("WARN: own interpreter path unknown - the app may block its own licence traffic")

    process_rules: list[dict[str, Any]] = []
    if groups.get("direct"):
        process_rules.append({"process_name": groups["direct"], "outbound": "direct"})
    if vpn_apps:
        process_rules.append({"process_name": vpn_apps, "outbound": vpn_tag})
    # Same Squid as Claude; block UDP so QUIC cannot bypass the HTTP proxy
    if proxy_apps:
        process_rules.append(
            {"process_name": proxy_apps, "network": "udp", "outbound": "block"}
        )
        # Same IPv6 guard Claude already has. The Squid host has no global IPv6
        # address, so an IPv6 request reaching it just burns a connect attempt and
        # comes back 503 - visible in Cursor as "Reconnecting". Fail it locally instead.
        process_rules.append(
            {"process_name": proxy_apps, "ip_version": 6, "outbound": "block"}
        )
        process_rules.append({"process_name": proxy_apps, "outbound": "claude-squid"})
    if block_apps:
        process_rules.append({"process_name": block_apps, "outbound": "block"})

    hardening = load_settings_for_build(home)
    strict_route = bool(hardening.get("strict_route", True))
    tunnel_ipv6 = bool(hardening.get("tunnel_ipv6", True))
    secure_dns = bool(hardening.get("secure_dns", True))
    try:
        clash_port = int(hardening.get("clash_api_port") or 9095)
    except Exception:
        clash_port = 9095
    clash = clash_api_credentials(home, clash_port)

    # DNS. Plain UDP hands every visited domain to the ISP no matter which channel the
    # app is on, so the split does nothing for query privacy. DoH keeps it encrypted.
    # sing-box 1.12+ new DNS dialer already equals empty direct (physical NIC).
    # Do NOT set detour:"direct" — that is FATAL: "empty direct outbound makes no sense".
    if secure_dns:
        dns_servers: list[dict[str, Any]] = [
            {"tag": "secure", "type": "https", "server": DNS_RESOLVER_IP},
            {"tag": "local", "type": "udp", "server": DNS_RESOLVER_IP},
        ]
        dns_final = "secure"
    else:
        dns_servers = [
            {"tag": "local", "type": "udp", "server": DNS_RESOLVER_IP}
        ]
        dns_final = "local"

    tun_address = ["172.19.0.1/30"]
    if tunnel_ipv6:
        tun_address.append(TUN_INET6)

    cfg: dict[str, Any] = {
        "log": {"level": "info", "timestamp": True},
        # ipv4_only: HTTP Squid CONNECT to IPv6 literals often returns 503 and
        # Claude Desktop shows "Couldn't connect" even though routing to claude-squid is correct.
        "dns": {
            "servers": dns_servers,
            "final": dns_final,
            "strategy": "ipv4_only",
        },
        "experimental": {
            "clash_api": {
                "external_controller": f"{clash['host']}:{clash['port']}",
                "secret": clash["secret"],
            }
        },
        "inbounds": [
            {
                "type": "tun",
                "tag": "tun-in",
                "interface_name": tun_name,
                "address": tun_address,
                "mtu": 1400,
                "auto_route": True,
                "strict_route": strict_route,
                "stack": "mixed",
            }
        ],
        "outbounds": [
            {
                "type": "http",
                "tag": "claude-squid",
                "server": host,
                "server_port": port,
                "username": user,
                "password": password,
            },
            *vless_nodes,
            {"type": "direct", "tag": "direct"},
            {"type": "block", "tag": "block"},
        ],
        "route": {
            "auto_detect_interface": True,
            "default_domain_resolver": dns_final,
            "rules": [
                {"action": "sniff"},
                {"protocol": "dns", "action": "hijack-dns"},
                {"ip_cidr": [f"{host}/32"], "outbound": "direct"},
                {"ip_cidr": [f"{DNS_RESOLVER_IP}/32"], "outbound": "direct"},
            ]
            + (
                [{"ip_cidr": [f"{vpn_server}/32"], "outbound": "direct"}]
                if vpn_server
                else []
            )
            + [
                {"ip_is_private": True, "outbound": "direct"},
            ]
            + probe_rules
            + self_rules
            + [
                {
                    "process_name": ["Claude.exe", "claude.exe", "cowork-svc.exe"],
                    "network": "udp",
                    "outbound": "block",
                },
                {
                    "process_name": ["Claude.exe", "claude.exe", "cowork-svc.exe"],
                    "ip_version": 6,
                    "outbound": "block",
                },
                {
                    "process_name": ["Claude.exe", "claude.exe", "cowork-svc.exe"],
                    "outbound": claude_outbound,
                },
            ]
            + process_rules,
            # Unknown apps blocked until user picks Direct / VPN / Proxy in the UI
            "final": "block",
        },
    }

    problems = validate_config(cfg)
    if problems:
        for p in problems:
            print(f"FAIL: invalid config: {p}", file=sys.stderr)
        print("FAIL: refusing to write a config that would misroute", file=sys.stderr)
        return 3

    backup_dir.mkdir(parents=True, exist_ok=True)
    if out_path.is_file():
        stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
        bak = backup_dir / f"vpn-claude-config.{stamp}.json"
        bak.write_text(out_path.read_text(encoding="utf-8"), encoding="utf-8")
        print(f"Backup: {bak}")

    out_path.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
    print(f"Config: {out_path}")
    print(f"TUN name: {tun_name}")
    print(f"Squid: {host}:{port} (user={user})")
    if vless_nodes:
        print(f"Sota VLESS nodes: {len(vless_nodes)} ({vpn_tag} -> {vpn_server}:{vpn_port})")
    else:
        print("Sota VLESS nodes: 0 - VPN channel unavailable, the rest still routes")
    print(
        f"Apps direct={len(groups.get('direct') or [])} "
        f"vpn={len(vpn_apps)} proxy={len(proxy_apps)} block={len(block_apps)} "
        f"vpn_ok={vpn_ok} squid_ok={squid_ok} "
        f"forced_block_vpn={vpn_forced_block} forced_block_squid={squid_forced_block}"
    )
    print(
        f"Hardening: strict_route={strict_route} tunnel_ipv6={tunnel_ipv6} "
        f"secure_dns={secure_dns} (dns.final={dns_final}) "
        f"clash_api=127.0.0.1:{clash['port']} probes={len(probe_rules)}"
    )
    print(
        "Mode: Claude+proxy->Squid; apps->direct/VPN/proxy; unknown->BLOCK; "
        "VPN-down=>VPN-apps BLOCK; Squid-down=>Claude/proxy BLOCK (fail-closed)"
    )
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
