from __future__ import annotations

import atexit
import asyncio
import logging
import random
import signal
import threading
import time
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from typing import Any

from .config import (
    Settings,
    load_settings,
    normalize_proxy_url,
    redact_proxy_url,
    save_settings,
)
from .dns_leak import flush_dns_cache
from .fetcher import fetch_proxy_json, normalize_entries
from .fingerprint import (
    apply_webrtc_hardening,
    disable_ipv6_on_adapters,
    enable_ipv6_on_adapters,
    get_computer_name,
    random_hostname,
    set_computer_name,
)
from .firewall import disengage as fw_disengage, emergency_disengage as fw_emergency_disengage, engage as fw_engage, is_admin
from .gost_util import build_gost_cmd, ensure_gost, popen_no_window, read_gost_log_tail, terminate_process
from .leak_detect import is_chain_leak, leak_reason
from .mac_spoof import restore_macs, spoof_all_physical
from .privacy_lan import (
    LanSnapshot,
    engage_lan_lockdown,
    restore_lan,
)
from .telemetry_kill import (
    TelemetrySnapshot,
    engage_telemetry_kill,
    restore_telemetry,
)
from .sysproxy import (
    clear_system_proxy,
    detect_policy_overrides,
    diagnose_system_proxy,
    is_system_proxy_set,
    set_system_proxy,
)
from .validator import (
    check_chain_exit_ip,
    check_https_tunnel,
    get_direct_ip,
    validate_proxies,
)
from .vpn_detect import VpnStatus, detect_vpn
from .win_compat import probe as _win_probe

log = logging.getLogger(__name__)

Notify = Callable[[dict[str, Any]], None]

# Thread pool for blocking I/O (proxy list fetching) so we don't stall asyncio
_FETCH_POOL = ThreadPoolExecutor(max_workers=8, thread_name_prefix="fetcher")


# Back-compat for tests
from .leak_detect import is_same_subnet as _is_same_network  # noqa: F401


async def _probe_tcp(host: str, port: int, timeout: float = 6.0) -> bool:
    """Open and close a TCP socket; True if the handshake completes within *timeout*."""
    try:
        reader, writer = await asyncio.wait_for(
            asyncio.open_connection(host, port), timeout=timeout,
        )
        writer.close()
        try:
            await writer.wait_closed()
        except Exception:  # noqa: BLE001
            pass
        return True
    except Exception:  # noqa: BLE001
        return False


class ChainService:
    """Background rotating proxy chain using GOST."""

    def __init__(self, notify: Notify) -> None:
        self._notify = notify
        self._stop = threading.Event()
        self._force_rotate = threading.Event()
        self._thread: threading.Thread | None = None
        self._proc = None
        self._settings_lock = threading.Lock()
        self._settings = load_settings()
        self._current_chain: list[str] = []
        # Sticky-exit: while time.monotonic() < self._sticky_until, leak-based
        # auto-rotation is skipped so signup flows keep the same egress IP.
        self._sticky_until: float = 0.0

        # Shuffle-and-drain pool state — never repeat a proxy within a cycle
        self._available: list[str] = []    # proxies not yet used this cycle
        self._used: set[str] = set()       # proxies used this cycle
        # Per-session blacklist: proxies that crashed GOST immediately
        self._blacklist: set[str] = set()

        self._vpn: VpnStatus = VpnStatus()
        self._mac_originals: dict[str, str] = {}
        self._hostname_original: str | None = None
        self._ipv6_adapters: list[str] = []
        self._webrtc_was_applied: bool = False
        self._lan_snap: LanSnapshot | None = None
        self._telemetry_snap: TelemetrySnapshot | None = None

        # Register emergency firewall disengage so a crash can't leave the
        # kill-switch permanently engaged.
        atexit.register(fw_emergency_disengage)
        for _sig in (signal.SIGTERM, signal.SIGINT):
            try:
                signal.signal(_sig, self._signal_handler)
            except (OSError, ValueError):
                pass  # not on main thread or unsupported on this platform

    def _signal_handler(self, signum: int, frame: Any) -> None:
        """Received SIGTERM/SIGINT — stop cleanly and ensure firewall is disengaged."""
        log.warning("Signal %s received — stopping ChainService.", signum)
        self._stop.set()
        terminate_process(self._proc)
        fw_emergency_disengage()

    def _manual_exit_url(self) -> str | None:
        with self._settings_lock:
            u = normalize_proxy_url(self._settings.manual_exit_proxy)
        return u if u else None

    @property
    def settings(self) -> Settings:
        with self._settings_lock:
            return self._settings

    @property
    def current_chain(self) -> list[str]:
        return list(self._current_chain)

    def update_settings(self, s: Settings) -> None:
        with self._settings_lock:
            self._settings = s
        save_settings(s)

    def start(self) -> None:
        if self._thread and self._thread.is_alive():
            return
        compat = _win_probe()
        self._notify({"type": "log", "text": f"Host: {compat.summary()}"})
        if not compat.has_net_cmdlets:
            self._notify({
                "type": "log",
                "text": (
                    "Legacy PowerShell detected — MAC spoof / IPv6 disable / "
                    "hostname spoof unavailable on this host. Proxy chain + "
                    "kill-switch + WebRTC policy still work."
                ),
            })
        self._stop.clear()
        self._thread = threading.Thread(
            target=self._run_thread, name="ChainService", daemon=True
        )
        self._thread.start()

    def stop(self) -> None:
        self._stop.set()
        terminate_process(self._proc)
        self._proc = None
        if self._thread:
            self._thread.join(timeout=15)
            if self._thread.is_alive():
                log.warning("ChainService thread did not exit in 15s — forcing firewall disengage.")
                fw_emergency_disengage()
        self._teardown_network()
        self._notify({"type": "state", "running": False})

    def rotate_now(self) -> None:
        self._force_rotate.set()

    def set_sticky(self, seconds: float) -> None:
        """Hold current exit for ``seconds`` (suppresses leak-driven rotation).

        Manual rotation still works. Pass 0 to clear sticky mode immediately.
        """
        if seconds <= 0:
            self._sticky_until = 0.0
            return
        self._sticky_until = time.monotonic() + float(seconds)

    def sticky_remaining(self) -> float:
        """Seconds remaining on sticky-exit hold (0.0 if not sticky)."""
        rem = self._sticky_until - time.monotonic()
        return rem if rem > 0 else 0.0

    # ─────────────────────────────────────────────────────────────────────────

    def _teardown_network(self) -> None:
        clear_system_proxy()
        self._notify({"type": "log", "text": "System proxy cleared."})
        self._restore_privacy()
        with self._settings_lock:
            ks_enabled = self._settings.kill_switch_enabled
        if is_admin() and ks_enabled:
            ok, msg = fw_disengage()
            self._notify({"type": "log", "text": msg})
        self._notify({"type": "firewall", "engaged": False})

    def _apply_privacy(self) -> None:
        s = self._settings
        if s.mac_spoof_enabled and is_admin():
            self._mac_originals, logs = spoof_all_physical(self._mac_originals or None)
            for ln in logs:
                self._notify({"type": "log", "text": f"MAC: {ln}"})
        elif s.mac_spoof_enabled:
            self._notify({"type": "log", "text": "MAC spoof enabled but not Admin — skipped."})

        if s.spoof_hostname_enabled and is_admin():
            self._hostname_original = get_computer_name()
            new_name = random_hostname()
            ok, msg = set_computer_name(new_name)
            self._notify({"type": "log", "text": f"Hostname: {msg}"})
        elif s.spoof_hostname_enabled:
            self._notify({"type": "log", "text": "Hostname spoof enabled but not Admin — skipped."})

        if s.disable_ipv6_while_active and is_admin():
            self._ipv6_adapters, logs = disable_ipv6_on_adapters()
            for ln in logs:
                self._notify({"type": "log", "text": ln})
        elif s.disable_ipv6_while_active:
            self._notify({"type": "log", "text": "IPv6 disable enabled but not Admin — skipped."})

        if s.harden_webrtc_enabled and is_admin():
            ok, msg = apply_webrtc_hardening(True)
            self._webrtc_was_applied = ok
            self._notify({"type": "log", "text": msg})
        elif s.harden_webrtc_enabled:
            self._notify({"type": "log", "text": "WebRTC hardening enabled but not Admin — skipped."})

        if s.lan_lockdown_enabled and is_admin():
            snap, logs = engage_lan_lockdown()
            self._lan_snap = snap
            for ln in logs:
                self._notify({"type": "log", "text": f"LAN: {ln}"})
        elif s.lan_lockdown_enabled:
            self._notify({"type": "log", "text": "LAN lockdown enabled but not Admin — skipped."})

        if s.telemetry_kill_enabled and is_admin():
            tsnap, tlogs = engage_telemetry_kill()
            self._telemetry_snap = tsnap
            for ln in tlogs[:8]:
                self._notify({"type": "log", "text": f"Telemetry: {ln}"})
            if len(tlogs) > 8:
                self._notify({"type": "log", "text": f"Telemetry: … +{len(tlogs) - 8} more changes."})
        elif s.telemetry_kill_enabled:
            self._notify({"type": "log", "text": "Telemetry kill enabled but not Admin — skipped."})

    def _restore_privacy(self) -> None:
        if self._mac_originals:
            for ln in restore_macs(self._mac_originals):
                self._notify({"type": "log", "text": f"MAC restore: {ln}"})
            self._mac_originals.clear()
        if self._hostname_original and is_admin():
            ok, msg = set_computer_name(self._hostname_original)
            self._notify({"type": "log", "text": f"Hostname restore: {msg}"})
            self._hostname_original = None
        if self._ipv6_adapters:
            for ln in enable_ipv6_on_adapters(self._ipv6_adapters):
                self._notify({"type": "log", "text": ln})
            self._ipv6_adapters.clear()
        if self._webrtc_was_applied and is_admin():
            apply_webrtc_hardening(False)
            self._webrtc_was_applied = False
            self._notify({"type": "log", "text": "WebRTC policy restored."})
        if self._lan_snap is not None and is_admin():
            for ln in restore_lan(self._lan_snap):
                self._notify({"type": "log", "text": f"LAN: {ln}"})
            self._lan_snap = None
        if self._telemetry_snap is not None and is_admin():
            for ln in restore_telemetry(self._telemetry_snap)[:8]:
                self._notify({"type": "log", "text": f"Telemetry: {ln}"})
            self._telemetry_snap = None

    def _run_thread(self) -> None:
        try:
            asyncio.run(self._async_main())
        except Exception:
            log.exception("service thread failed")
            self._notify({"type": "log", "text": "Fatal error in service thread (see log)."})
        finally:
            terminate_process(self._proc)
            self._proc = None
            self._teardown_network()
            self._notify({"type": "state", "running": False})

    # ─────────────────────────────────────────────────────────────────────────
    # MAIN ASYNC LOOP
    # ─────────────────────────────────────────────────────────────────────────

    async def _async_main(self) -> None:
        self._notify({"type": "state", "running": True})
        log.info(
            "Service start: chain_length=%d mode=%s sources=%d pinned=%s kill_switch=%s",
            self._settings.chain_length,
            self._settings.obfuscation_mode,
            len(self._settings.sources),
            self._settings.use_pinned_chain,
            self._settings.kill_switch_enabled,
        )

        # ── GOST setup ───────────────────────────────────────────────────────
        try:
            gost = ensure_gost()
            self._notify({"type": "log", "text": f"GOST ready: {gost}"})
        except Exception as e:
            self._notify({"type": "log", "text": f"GOST setup failed: {e}"})
            return

        if gost.stat().st_size < 10_000:
            self._notify({"type": "log", "text": "GOST appears quarantined. Re-downloading..."})
            gost.unlink(missing_ok=True)
            try:
                gost = ensure_gost()
            except Exception as e:
                self._notify({"type": "log", "text": f"GOST re-download failed: {e}"})
                return

        # ── VPN + direct IP ───────────────────────────────────────────────────
        self._vpn = detect_vpn()
        mode = "VPN-aware (/16)" if self._vpn.active else "strict (exact IP)"
        self._notify({
            "type": "vpn",
            "active": self._vpn.active,
            "label": self._vpn.label,
            "adapter": self._vpn.adapter,
            "leak_mode": mode,
        })
        self._notify({
            "type": "log",
            "text": (
                f"VPN: {self._vpn.label}"
                + (f" ({self._vpn.adapter})" if self._vpn.adapter else "")
                + f" — leak check: {mode}"
            ),
        })

        # Warm-up: retry the direct-IP lookup a few times so a transient
        # network blip during startup doesn't put us in permanent fail-closed.
        real_ip = None
        for attempt in range(3):
            real_ip = await get_direct_ip(self._settings.ip_check_url)
            if real_ip:
                break
            if attempt < 2:
                await asyncio.sleep(2.0)
        if real_ip:
            self._notify({"type": "real_ip", "ip": real_ip})
            label = "direct/VPN IP" if self._vpn.active else "your real IP"
            self._notify({"type": "log", "text": f"{label.capitalize()}: {real_ip}"})
        else:
            # Leak detection now FAILS CLOSED on unknown real_ip — any chain
            # whose exit IP can't be compared against a baseline will be
            # treated as a leak and rotated.  Make the operator aware up front.
            self._notify({"type": "log", "text": (
                "Could not determine direct IP after 3 attempts — leak detection "
                "will FAIL CLOSED (rotate on any chain whose exit IP cannot be "
                "verified). Fix internet / ip_check_url to restore."
            )})

        self._apply_privacy()

        # ── Firewall kill-switch ──────────────────────────────────────────────
        if self._settings.kill_switch_enabled:
            if is_admin():
                ok, msg = fw_engage(gost)
                self._notify({"type": "log", "text": msg})
                self._notify({"type": "firewall", "engaged": ok})
            else:
                self._notify({"type": "log", "text": "Kill-switch skipped (not Admin)."})
                self._notify({"type": "firewall", "engaged": False})
        else:
            self._notify({"type": "log", "text": "Kill-switch disabled in settings."})
            self._notify({"type": "firewall", "engaged": False})

        log.debug(
            "Listener %s | refresh=%ds health=%ds max_candidates=%d concurrency=%d",
            self._settings.listen_addr(),
            int(self._settings.full_refresh_seconds),
            int(self._settings.health_check_seconds),
            int(self._settings.max_candidates),
            int(self._settings.validation_concurrency),
        )

        # ── Main rotation loop ────────────────────────────────────────────────
        full_pool: list[str] = []
        last_full = 0.0
        rotation_num = 0

        while not self._stop.is_set():
            now = time.monotonic()
            need_refresh = (
                not full_pool
                or now - last_full >= float(self._settings.full_refresh_seconds)
            )
            log.debug(
                "Loop tick: full_pool=%d available=%d used=%d blacklist=%d need_refresh=%s age=%.0fs",
                len(full_pool),
                len(self._available),
                len(self._used),
                len(self._blacklist),
                need_refresh,
                (now - last_full) if full_pool else 0.0,
            )

            # Pinned (manual) chain mode — skip pool management
            if self._settings.use_pinned_chain and not self._settings.pinned_chain:
                self._notify({
                    "type": "log",
                    "text": (
                        "Pinned chain enabled but list is empty — add hops in Chain Builder "
                        "or disable 'Use pinned chain'."
                    ),
                })
            if self._settings.use_pinned_chain and self._settings.pinned_chain:
                chain = list(self._settings.pinned_chain)

                # Optional per-hop TCP probe so a known-dead pinned chain
                # doesn't loop forever on the 10 s retry below.
                if self._settings.validate_pinned_on_start:
                    from urllib.parse import urlparse as _urlparse
                    dead = []
                    for hop in chain:
                        try:
                            p = _urlparse(hop if "://" in hop else "http://" + hop)
                            if p.hostname and p.port:
                                if not await _probe_tcp(p.hostname, int(p.port), 6.0):
                                    dead.append(hop)
                        except Exception:  # noqa: BLE001
                            dead.append(hop)
                    if dead:
                        self._notify({"type": "log", "text": (
                            f"Pinned chain: {len(dead)}/{len(chain)} hop(s) failed TCP "
                            f"probe — proceeding anyway (will retry on failure)."
                        )})

                rotation_num += 1
                self._notify({"type": "rotation", "n": rotation_num})
                log.info("Pinned chain run: %d hops", len(chain))
                ok = await self._run_chain(gost, chain, real_ip)
                if not ok:
                    await self._sleep_interruptible(10)
                if self._stop.is_set():
                    break
                continue

            # Fixed exit only: hop count = 1 → chain is only the manual exit (no pool)
            mex = self._manual_exit_url()
            if mex and not self._settings.use_pinned_chain and self._settings.chain_length == 1:
                rotation_num += 1
                self._notify({"type": "rotation", "n": rotation_num})
                ok = await self._run_chain(gost, [mex], real_ip)
                if not ok:
                    await self._sleep_interruptible(15)
                if self._stop.is_set():
                    break
                continue

            # ── Pool refresh ─────────────────────────────────────────────────
            if need_refresh:
                self._notify({"type": "phase", "phase": "fetch"})
                log.info("Pool refresh: fetching %d source(s)…", len(self._settings.sources))
                full_pool = await self._build_pool()
                last_full = time.monotonic()
                self._available = list(full_pool)
                random.shuffle(self._available)
                self._used.clear()
                self._notify({"type": "pool", "count": len(full_pool)})

            if not full_pool:
                self._notify({"type": "log", "text": "Empty pool — retrying in 60s..."})
                await self._sleep_interruptible(60)
                last_full = 0.0
                continue

            # ── Pick next chain (shuffle-and-drain, no repeats per cycle) ────
            chain = self._pick_chain()
            if not chain:
                # Pool exhausted for this cycle — reshuffle and restart
                self._notify({"type": "log", "text": "Pool cycle complete — reshuffling for next round."})
                self._available = list(full_pool)
                random.shuffle(self._available)
                self._used.clear()
                chain = self._pick_chain()
                if not chain:
                    await self._sleep_interruptible(15)
                    continue

            rotation_num += 1
            self._notify({"type": "rotation", "n": rotation_num})
            log.info(
                "Auto chain #%d: %d hops | obfuscation=%s | pool_remain=%d",
                rotation_num,
                len(chain),
                self._settings.obfuscation_mode,
                len(self._available),
            )
            await self._run_chain(gost, chain, real_ip)

            if self._stop.is_set():
                break

        log.info("Main loop exit (stop requested or fatal).")
        terminate_process(self._proc)
        self._proc = None

    # ─────────────────────────────────────────────────────────────────────────
    # CHAIN RUNNER
    # ─────────────────────────────────────────────────────────────────────────

    async def _run_chain(
        self, gost: Any, chain: list[str], real_ip: str | None
    ) -> bool:
        """Start GOST with chain, verify exit IP, monitor until rotation/stop.
        Returns True if chain ran successfully, False if it immediately failed."""
        self._current_chain = list(chain)
        self._notify({"type": "hops", "hops": chain, "status": "connecting"})
        self._notify({"type": "phase", "phase": "gost_start"})
        if self._settings.flush_dns_on_rotate:
            ok, msg = flush_dns_cache()
            if ok:
                log.debug("DNS cache flushed before chain run")
        # Per-rotation MAC re-randomization (only if mac_spoof is also on, since
        # without spoof there's no original snapshot we own to mutate).
        if (
            self._settings.mac_rotate_on_chain_rotate
            and self._settings.mac_spoof_enabled
            and is_admin()
            and self._mac_originals
        ):
            _, logs = spoof_all_physical(self._mac_originals)
            for ln in logs:
                self._notify({"type": "log", "text": f"MAC rotate: {ln}"})
        listen = self._settings.listen_addr()
        cmd = build_gost_cmd(gost, listen, chain)
        self._notify({
            "type": "log",
            "text": f"Chain #{len(self._used) // max(1, self._settings.chain_length)}: "
                    + " → ".join(self._short(h) for h in chain),
        })
        red = " | ".join(redact_proxy_url(h) for h in chain)
        log.debug("GOST listen=http://%s | forwards (redacted): %s", listen, red)
        log.debug("GOST argv: %s … (%d args)", cmd[0], len(cmd))

        terminate_process(self._proc)
        self._proc = popen_no_window(cmd)
        self._notify({"type": "log", "text": "GOST started — warming up (2s)…"})

        for _ in range(4):
            if self._stop.is_set():
                terminate_process(self._proc)
                self._proc = None
                return False
            await asyncio.sleep(0.5)

        if self._proc.poll() is not None:
            # GOST died immediately — blacklist pool proxies (never blacklist user fixed exit)
            fixed = self._manual_exit_url()
            for h in chain:
                if fixed and h == fixed:
                    continue
                self._blacklist.add(h)
                self._available = [x for x in self._available if x != h]
            self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": None})
            self._notify({"type": "log", "text": "GOST exited immediately — proxies blacklisted."})
            tail = read_gost_log_tail(20)
            if tail.strip():
                for ln in tail.splitlines()[-8:]:
                    if ln.strip():
                        self._notify({"type": "log", "text": f"  GOST> {ln.rstrip()}"})
            return False

        local_proxy = f"http://{listen}"
        timeout = min(30.0, self._settings.validation_timeout_seconds + 12.0)
        self._notify({"type": "phase", "phase": "verify_chain"})
        self._notify({
            "type": "log",
            "text": f"Exit IP check through local proxy (≤{int(timeout * 2 + 5)}s)…",
        })
        t0 = time.monotonic()
        exit_ip = await check_chain_exit_ip(
            local_proxy, self._settings.ip_check_url, timeout, chain_hops=len(chain)
        )
        log.debug("Initial exit IP check took %.2fs → %s", time.monotonic() - t0, exit_ip or "none")

        if not exit_ip:
            self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": None})
            self._notify({"type": "log", "text": "Chain IP check failed. Rotating."})
            terminate_process(self._proc)
            self._proc = None
            return False

        if is_chain_leak(exit_ip, real_ip, self._vpn.active):
            reason = leak_reason(exit_ip, real_ip, self._vpn.active)
            self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip})
            self._notify({"type": "log", "text": f"Leak detected — {reason}. Rotating."})
            log.warning("Chain leak: exit=%s real=%s vpn=%s — %s", exit_ip, real_ip, self._vpn.active, reason)
            # Blacklist the whole chain so we don't reuse broken proxies
            fixed = self._manual_exit_url()
            for h in chain:
                if fixed and h == fixed:
                    continue
                self._blacklist.add(h)
                self._available = [x for x in self._available if x != h]
            terminate_process(self._proc)
            self._proc = None
            return False

        # HTTPS CONNECT must work before the chain is marked healthy.
        https_ok, https_msg = await check_https_tunnel(local_proxy, timeout)
        if not https_ok:
            self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip})
            self._notify({"type": "log", "text": (
                "HTTPS tunnel FAILED — chain forwards plain HTTP but refuses CONNECT. "
                "Browsers will time out on HTTPS sites. Rotating. " + https_msg
            )})
            fixed = self._manual_exit_url()
            for h in chain:
                if fixed and h == fixed:
                    continue
                self._blacklist.add(h)
                self._available = [x for x in self._available if x != h]
            terminate_process(self._proc)
            self._proc = None
            return False

        self._notify({"type": "hops", "hops": chain, "status": "healthy", "exit_ip": exit_ip})
        self._notify({"type": "log", "text": f"✓ Chain healthy — Exit IP: {exit_ip}"})
        self._notify({"type": "log", "text": f"✓ HTTPS tunnel OK — {https_msg}"})

        self._notify({"type": "phase", "phase": "running"})
        # Pre-flight: surface Group Policy locks (they will override us).
        pol_before = detect_policy_overrides()
        if pol_before:
            self._notify({"type": "log", "text": (
                f"Group Policy proxy lock detected ({len(pol_before)} entries) — "
                "these BEAT our settings. Browsers will keep the policy proxy "
                "(or DIRECT) until those keys are removed."
            )})
            for p in pol_before[:3]:
                self._notify({"type": "log", "text": f"  ! {p}"})

        set_system_proxy(
            self._settings.local_host,
            self._settings.local_port,
            self._settings.proxy_bypass,
        )
        applied = is_system_proxy_set()
        self._notify({
            "type": "log",
            "text": (
                f"System proxy → {self._settings.listen_addr()} "
                f"(HKCU + HKLM(adm) + Connections + WinHTTP) "
                f"{'OK' if applied else 'FAILED — registry write rejected'}"
            ),
        })
        # Post-flight diagnostic — every layer's actual state.
        for ln in diagnose_system_proxy():
            self._notify({"type": "log", "text": f"  proxy: {ln}"})

        # ── Health monitor loop ───────────────────────────────────────────────
        hc = int(self._settings.health_check_seconds)
        while not self._stop.is_set():
            log.debug("Health sleep: %ds until next exit check", hc)
            result = await self._wait_health_interval()
            if result in ("stop", "rotate"):
                log.debug("Health loop break: %s", result)
                break

            if self._proc is None or self._proc.poll() is not None:
                self._notify({"type": "log", "text": "GOST process died — rebuilding."})
                break

            self._notify({"type": "log", "text": "Health check..."})
            t1 = time.monotonic()
            exit_ip = await check_chain_exit_ip(
                local_proxy, self._settings.ip_check_url, timeout, chain_hops=len(chain)
            )
            log.debug("Periodic exit IP check %.2fs → %s", time.monotonic() - t1, exit_ip or "none")

            if is_chain_leak(exit_ip, real_ip, self._vpn.active):
                reason = leak_reason(exit_ip, real_ip, self._vpn.active)
                rem = self.sticky_remaining()
                if rem > 0:
                    self._notify({
                        "type": "log",
                        "text": (
                            f"Health check flagged ({reason}) — STICKY EXIT active "
                            f"({int(rem)}s left), skipping rotation."
                        ),
                    })
                    self._notify({"type": "hops", "hops": chain, "status": "healthy", "exit_ip": exit_ip})
                    continue
                self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip})
                self._notify({"type": "log", "text": f"Health check failed ({reason}) — rotating."})
                break

            self._notify({"type": "hops", "hops": chain, "status": "healthy", "exit_ip": exit_ip})
            self._notify({"type": "log", "text": f"✓ Still healthy — Exit IP: {exit_ip}"})

        terminate_process(self._proc)
        self._proc = None
        return True

    # ─────────────────────────────────────────────────────────────────────────
    # POOL MANAGEMENT
    # ─────────────────────────────────────────────────────────────────────────

    @staticmethod
    def _apply_mode_filter(candidates: list[str], mode: str) -> list[str]:
        if mode == "http_only":
            return [u for u in candidates if u.startswith("http://")]
        if mode == "socks5_only":
            return [u for u in candidates if u.startswith("socks5://")]
        if mode == "random_mix":
            out = list(candidates)
            random.shuffle(out)
            return out
        return list(candidates)

    def _pick_chain(self) -> list[str]:
        """Pick chain_length hops: optional fixed last hop + random prefix from pool."""
        s = self._settings
        chain = self._pick_chain_for_mode(s.obfuscation_mode)
        if chain:
            log.debug(
                "Picked chain len=%d mode=%s available_left=%d",
                len(chain),
                s.obfuscation_mode,
                len(self._available),
            )
            return chain
        if s.obfuscation_mode != "auto" and not s.use_pinned_chain:
            log.debug("Pick empty under mode=%s — retrying as auto", s.obfuscation_mode)
            self._notify({
                "type": "log",
                "text": "Obfuscation filter left nothing usable — retrying this pick with ALL protocols (auto).",
            })
            ch2 = self._pick_chain_for_mode("auto")
            if ch2:
                log.debug("Auto retry picked len=%d", len(ch2))
            return ch2
        log.debug("Pick chain returned empty (pool exhausted?)")
        return []

    def _pick_chain_for_mode(self, mode: str) -> list[str]:
        """Build one chain using given mode (http_only / socks5_only / random_mix / auto)."""
        s = self._settings
        k = max(1, s.chain_length)
        manual = self._manual_exit_url()
        if manual and not s.use_pinned_chain:
            mid_need = k - 1
            base = [
                u for u in self._available
                if u not in self._blacklist and u != manual
            ]
            candidates = self._apply_mode_filter(base, mode)

            if mid_need == 0:
                return [manual]
            if len(candidates) < mid_need:
                return []

            prefix = candidates[:mid_need]
            pset = set(prefix)
            self._available = [u for u in self._available if u not in pset]
            self._used.update(prefix)
            self._used.add(manual)
            return prefix + [manual]

        base = [u for u in self._available if u not in self._blacklist]
        candidates = self._apply_mode_filter(base, mode)

        if len(candidates) < k:
            return []

        picked = candidates[:k]
        picked_set = set(picked)
        self._available = [u for u in self._available if u not in picked_set]
        self._used.update(picked)
        return picked

    async def _build_pool(self) -> list[str]:
        """Fetch and validate proxies. Runs blocking I/O in thread pool."""
        s = self._settings
        loop = asyncio.get_running_loop()
        log.info("_build_pool: %d source URL(s), prefer_elite=%s", len(s.sources), s.prefer_elite)

        # Fetch all sources concurrently in thread pool (they are blocking)
        async def _fetch_one(url: str) -> list[str]:
            try:
                rows = await asyncio.wait_for(
                    loop.run_in_executor(
                        _FETCH_POOL,
                        lambda u=url: fetch_proxy_json(u, timeout=50.0),
                    ),
                    timeout=55.0,
                )
                entries = normalize_entries(rows, s.prefer_elite)
                if s.prefer_elite and rows and not entries:
                    self._notify({
                        "type": "log",
                        "text": (
                            f"Source returned {len(rows)} proxies but none marked “elite” — all skipped. "
                            "Turn off “Elite proxies only” in Chain Builder if every fetch is empty."
                        ),
                    })
                self._notify({"type": "log", "text": f"Fetched {len(entries)} from source."})
                return entries
            except asyncio.TimeoutError:
                self._notify({"type": "log", "text": f"Fetch timed out (55s): {url[:60]}..."})
                return []
            except Exception as e:
                self._notify({"type": "log", "text": f"Fetch error: {e!s}"})
                return []

        src_urls = list(s.sources) if s.sources else list(Settings().sources)
        if not s.sources:
            self._notify({
                "type": "log",
                "text": "Settings had no proxy sources — using built-in defaults (save Settings to persist).",
            })
        if not src_urls:
            self._notify({"type": "log", "text": "No proxy source URLs configured — cannot build a pool."})
            return []

        results = await asyncio.gather(*(_fetch_one(u) for u in src_urls))
        raw_urls: list[str] = []
        for chunk in results:
            raw_urls.extend(chunk)

        if not raw_urls:
            self._notify({
                "type": "log",
                "text": "No proxies fetched from any source (check network, URLs, or turn off “Elite only” if every list is filtered empty).",
            })
            return []

        # Deduplicate, remove blacklisted, shuffle before capping
        seen: set[str] = set()
        unique: list[str] = []
        for u in raw_urls:
            if u not in seen and u not in self._blacklist:
                seen.add(u)
                unique.append(u)

        random.shuffle(unique)
        if len(unique) > s.max_candidates:
            unique = unique[: s.max_candidates]
        log.debug("Unique candidates after dedupe/cap: %d (max_candidates=%d)", len(unique), s.max_candidates)

        self._notify({"type": "phase", "phase": "validate"})
        self._notify({"type": "log", "text": f"Validating {len(unique)} candidates..."})

        def on_prog(done: int, total: int) -> None:
            self._notify({"type": "validate_progress", "done": done, "total": total})

        target = max(s.min_pool_size, s.chain_length * 3)
        good = await validate_proxies(
            unique,
            s.ip_check_url,
            s.validation_concurrency,
            s.validation_timeout_seconds,
            on_progress=on_prog,
            target=target,
        )
        random.shuffle(good)

        mex = normalize_proxy_url(s.manual_exit_proxy)
        if mex and not s.use_pinned_chain:
            self._notify({"type": "phase", "phase": "exit_check"})
            self._notify({"type": "log", "text": "Validating fixed exit proxy…"})
            v = await validate_proxies(
                [mex],
                s.ip_check_url,
                1,
                s.validation_timeout_seconds,
                on_progress=None,
            )
            if v:
                self._notify({"type": "log", "text": "Fixed exit proxy: OK (reachable)."})
            else:
                self._notify({
                    "type": "log",
                    "text": "Fixed exit proxy: validation failed — will still attempt; check URL/credentials.",
                })

        self._notify({"type": "log", "text": f"Valid proxies: {len(good)} / {len(unique)}"})
        log.info("_build_pool done: valid=%d / tested=%d", len(good), len(unique))
        return good

    # ─────────────────────────────────────────────────────────────────────────
    # UTILITIES
    # ─────────────────────────────────────────────────────────────────────────

    async def _wait_health_interval(self) -> str | None:
        """Sleep for health_check_seconds. Returns 'stop', 'rotate', or None."""
        total = float(self._settings.health_check_seconds)
        end = time.monotonic() + total
        while time.monotonic() < end:
            if self._stop.is_set():
                return "stop"
            if self._force_rotate.is_set():
                self._force_rotate.clear()
                self._notify({"type": "log", "text": "Manual rotate triggered."})
                if self._settings.flush_dns_on_rotate:
                    ok, msg = flush_dns_cache()
                    self._notify({"type": "log", "text": f"DNS flush: {msg}" if ok else f"DNS flush failed: {msg}"})
                return "rotate"
            remaining = end - time.monotonic()
            self._notify({"type": "countdown", "secs": max(0, int(remaining))})
            await asyncio.sleep(1.0)
        self._notify({"type": "countdown", "secs": 0})
        return None

    async def _sleep_interruptible(self, seconds: float) -> None:
        end = time.monotonic() + seconds
        while time.monotonic() < end:
            if self._stop.is_set():
                return
            await asyncio.sleep(0.2)

    @staticmethod
    def _short(url: str) -> str:
        url = redact_proxy_url(url)
        url = (
            url.replace("http://", "")
               .replace("socks5://", "s5://")
               .replace("socks4://", "s4://")
               .replace("https://", "")
        )
        return url[:30] + "…" if len(url) > 32 else url
