from __future__ import annotations

import asyncio
import logging
import random
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, save_settings
from .fetcher import fetch_proxy_json, normalize_entries
from .firewall import disengage as fw_disengage, engage as fw_engage, is_admin
from .gost_util import build_gost_cmd, ensure_gost, popen_no_window, terminate_process
from .sysproxy import clear_system_proxy, set_system_proxy
from .validator import check_chain_exit_ip, get_direct_ip, validate_proxies

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=4, thread_name_prefix="fetcher")


def _filter_by_mode(pool: list[str], mode: str) -> list[str]:
    """Filter pool by obfuscation/protocol mode."""
    if mode == "http_only":
        return [u for u in pool if u.startswith("http://")]
    if mode == "socks5_only":
        return [u for u in pool if u.startswith("socks5://")]
    # "random_mix" and "auto" — keep all, caller will shuffle
    return list(pool)


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 = load_settings()
        self._current_chain: list[str] = []

        # 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()

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

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

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

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

    def start(self) -> None:
        if self._thread and self._thread.is_alive():
            return
        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)
        self._teardown_network()
        self._notify({"type": "state", "running": False})

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

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

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

    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})

        # ── 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

        # ── Real IP ──────────────────────────────────────────────────────────
        real_ip = await get_direct_ip(self._settings.ip_check_url)
        if real_ip:
            self._notify({"type": "real_ip", "ip": real_ip})
            self._notify({"type": "log", "text": f"Your real IP: {real_ip}"})
        else:
            self._notify({"type": "log", "text": "Could not determine real IP — leak detection disabled."})

        # ── 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})

        # ── 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)
            )

            # Pinned (manual) chain mode — skip pool management
            if self._settings.use_pinned_chain and self._settings.pinned_chain:
                chain = list(self._settings.pinned_chain)
                rotation_num += 1
                self._notify({"type": "rotation", "n": rotation_num})
                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"})
                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})
            await self._run_chain(gost, chain, real_ip)

            if self._stop.is_set():
                break

        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"})
        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),
        })

        terminate_process(self._proc)
        self._proc = popen_no_window(cmd)

        await asyncio.sleep(2.0)

        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."})
            return False

        local_proxy = f"http://{listen}"
        timeout = min(30.0, self._settings.validation_timeout_seconds + 12.0)
        exit_ip = await check_chain_exit_ip(local_proxy, self._settings.ip_check_url, timeout)

        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 real_ip and exit_ip == real_ip:
            self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip})
            self._notify({"type": "log", "text": f"Leak! Exit={exit_ip} == real IP. Rotating."})
            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}"})
        set_system_proxy(
            self._settings.local_host,
            self._settings.local_port,
            self._settings.proxy_bypass,
        )
        self._notify({"type": "log", "text": f"System proxy → {self._settings.listen_addr()}"})

        # ── Health monitor loop ───────────────────────────────────────────────
        while not self._stop.is_set():
            result = await self._wait_health_interval()
            if result in ("stop", "rotate"):
                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..."})
            exit_ip = await check_chain_exit_ip(local_proxy, self._settings.ip_check_url, timeout)

            if not exit_ip or (real_ip and exit_ip == real_ip):
                self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip})
                self._notify({"type": "log", "text": "Health check failed — 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
    # ─────────────────────────────────────────────────────────────────────────

    def _pick_chain(self) -> list[str]:
        """Pick chain_length hops: optional fixed last hop + random prefix from pool."""
        s = self._settings
        k = max(1, s.chain_length)
        manual = self._manual_exit_url()
        if manual and not s.use_pinned_chain:
            # Last hop is always manual; earlier hops come from pool (never duplicate manual)
            mid_need = k - 1
            candidates = [
                u for u in self._available
                if u not in self._blacklist and u != manual
            ]
            if s.obfuscation_mode == "http_only":
                candidates = [u for u in candidates if u.startswith("http://")]
            elif s.obfuscation_mode == "socks5_only":
                candidates = [u for u in candidates if u.startswith("socks5://")]
            elif s.obfuscation_mode == "random_mix":
                random.shuffle(candidates)

            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]

        # Default: all hops from rotating pool
        candidates = [u for u in self._available if u not in self._blacklist]
        if s.obfuscation_mode == "http_only":
            candidates = [u for u in candidates if u.startswith("http://")]
        elif s.obfuscation_mode == "socks5_only":
            candidates = [u for u in candidates if u.startswith("socks5://")]
        elif s.obfuscation_mode == "random_mix":
            random.shuffle(candidates)

        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_event_loop()

        # Fetch all sources concurrently in thread pool (they are blocking)
        async def _fetch_one(url: str) -> list[str]:
            try:
                rows = await loop.run_in_executor(
                    _FETCH_POOL, lambda: fetch_proxy_json(url)
                )
                entries = normalize_entries(rows, s.prefer_elite)
                self._notify({"type": "log", "text": f"Fetched {len(entries)} from source."})
                return entries
            except Exception as e:
                self._notify({"type": "log", "text": f"Fetch error: {e!s}"})
                return []

        results = await asyncio.gather(*(_fetch_one(u) for u in s.sources))
        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!"})
            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]

        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})

        good = await validate_proxies(
            unique,
            s.ip_check_url,
            s.validation_concurrency,
            s.validation_timeout_seconds,
            on_progress=on_prog,
        )
        random.shuffle(good)

        mex = normalize_proxy_url(s.manual_exit_proxy)
        if mex and not s.use_pinned_chain:
            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)}"})
        self._notify({"type": "phase", "phase": "running"})
        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."})
                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 = (
            url.replace("http://", "")
               .replace("socks5://", "s5://")
               .replace("socks4://", "s4://")
               .replace("https://", "")
        )
        return url[:30] + "…" if len(url) > 32 else url
