"""Multi-proxy pool with health ranking and instant failover.

Manages multiple discovered proxies, monitors their health,
and provides instant failover when one dies.

Also provides network-aware retry with jitter for CSCA scenarios.

Usage:
    from src.utils.proxy_pool import ProxyPool, network_retry

    pool = ProxyPool()
    pool.discover()  # find all available proxies

    url = pool.get_best()  # get healthiest proxy URL
    pool.mark_failed(url)  # mark a proxy as failed
    url = pool.get_best()  # instantly get next best
"""

from __future__ import annotations

import logging
import random
import socket
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Callable, TypeVar

log = logging.getLogger(__name__)

T = TypeVar("T")


@dataclass
class ProxyEntry:
    """A single proxy with health tracking."""
    url: str
    scheme: str = ""
    host: str = ""
    port: int = 0
    source: str = ""

    # Health tracking
    success_count: int = 0
    fail_count: int = 0
    last_check: float = 0.0
    last_success: float = 0.0
    last_fail: float = 0.0
    latency_ms: float = 0.0  # last measured latency
    is_alive: bool = True

    @property
    def score(self) -> float:
        """Health score (higher = better). Used for ranking."""
        if not self.is_alive:
            return -1.0

        total = self.success_count + self.fail_count
        if total == 0:
            return 0.5  # untested, neutral score

        success_rate = self.success_count / total
        # Penalize high latency
        latency_penalty = min(self.latency_ms / 1000.0, 1.0)  # cap at 1s
        return success_rate - (latency_penalty * 0.3)


class ProxyPool:
    """Pool of proxies with health monitoring and instant failover."""

    def __init__(self, check_interval: float = 60.0) -> None:
        self._proxies: list[ProxyEntry] = []
        self._lock = threading.Lock()
        self._check_interval = check_interval
        self._monitor_thread: threading.Thread | None = None
        self._stop_event = threading.Event()

    @property
    def size(self) -> int:
        with self._lock:
            return len(self._proxies)

    @property
    def alive_count(self) -> int:
        with self._lock:
            return sum(1 for p in self._proxies if p.is_alive)

    def discover(self) -> int:
        """Discover all available proxies using proxy_tunnel detection.

        Returns number of proxies found.
        """
        from .proxy_tunnel import (
            _proxy_from_env,
            _proxy_from_macos_system,
            _proxy_from_windows_system,
            _COMMON_PROXY_PORTS,
        )
        import sys

        found: list[ProxyEntry] = []
        seen_urls: set[str] = set()

        def _add(url: str, scheme: str, host: str, port: int, source: str) -> None:
            if url and url not in seen_urls:
                seen_urls.add(url)
                found.append(ProxyEntry(
                    url=url, scheme=scheme, host=host, port=port, source=source,
                ))

        # 1. Environment variables
        env_proxy = _proxy_from_env()
        if env_proxy and env_proxy.is_valid:
            _add(env_proxy.url, env_proxy.scheme, env_proxy.host, env_proxy.port, env_proxy.source)

        # 2. System proxy
        sys_proxy = None
        if sys.platform == "darwin":
            sys_proxy = _proxy_from_macos_system()
        elif sys.platform == "win32":
            sys_proxy = _proxy_from_windows_system()
        if sys_proxy and sys_proxy.is_valid:
            _add(sys_proxy.url, sys_proxy.scheme, sys_proxy.host, sys_proxy.port, sys_proxy.source)

        # 3. Scan ALL common proxy ports (not just first match)
        for scheme, port in _COMMON_PROXY_PORTS:
            try:
                sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
                sock.settimeout(0.5)
                t0 = time.monotonic()
                sock.connect(("127.0.0.1", port))
                latency = (time.monotonic() - t0) * 1000
                sock.close()

                url = f"{scheme}://127.0.0.1:{port}"
                entry = ProxyEntry(
                    url=url, scheme=scheme, host="127.0.0.1",
                    port=port, source=f"scan:{port}",
                    latency_ms=latency, is_alive=True,
                )
                if url not in seen_urls:
                    seen_urls.add(url)
                    found.append(entry)
            except (OSError, socket.timeout):
                continue

        with self._lock:
            # Merge: keep existing health data for known proxies
            existing = {p.url: p for p in self._proxies}
            for entry in found:
                if entry.url in existing:
                    old = existing[entry.url]
                    entry.success_count = old.success_count
                    entry.fail_count = old.fail_count
                    entry.last_success = old.last_success
                    entry.last_fail = old.last_fail
            self._proxies = found

        log.info("Proxy pool: discovered %d proxies", len(found))
        return len(found)

    def add(self, url: str, scheme: str = "", host: str = "", port: int = 0) -> None:
        """Manually add a proxy to the pool."""
        with self._lock:
            if any(p.url == url for p in self._proxies):
                return
            self._proxies.append(ProxyEntry(
                url=url, scheme=scheme, host=host, port=port, source="manual",
            ))

    def get_best(self) -> str:
        """Get the best available proxy URL. Empty string if none alive."""
        with self._lock:
            alive = [p for p in self._proxies if p.is_alive]
            if not alive:
                return ""
            alive.sort(key=lambda p: p.score, reverse=True)
            return alive[0].url

    def get_all_alive(self) -> list[str]:
        """Get all alive proxy URLs sorted by score."""
        with self._lock:
            alive = [p for p in self._proxies if p.is_alive]
            alive.sort(key=lambda p: p.score, reverse=True)
            return [p.url for p in alive]

    def mark_success(self, url: str, latency_ms: float = 0.0) -> None:
        """Mark a proxy as successfully used."""
        with self._lock:
            for p in self._proxies:
                if p.url == url:
                    p.success_count += 1
                    p.last_success = time.monotonic()
                    p.is_alive = True
                    if latency_ms > 0:
                        p.latency_ms = latency_ms
                    break

    def mark_failed(self, url: str) -> None:
        """Mark a proxy as failed."""
        with self._lock:
            for p in self._proxies:
                if p.url == url:
                    p.fail_count += 1
                    p.last_fail = time.monotonic()
                    # Mark dead after 3 consecutive failures
                    recent_fails = p.fail_count - p.success_count
                    if recent_fails >= 3:
                        p.is_alive = False
                        log.warning("Proxy marked dead: %s (score=%.2f)", url, p.score)
                    break

    def start_monitor(self) -> None:
        """Start background health monitoring thread."""
        if self._monitor_thread and self._monitor_thread.is_alive():
            return
        self._stop_event.clear()
        self._monitor_thread = threading.Thread(
            target=self._monitor_loop, daemon=True, name="proxy-pool-monitor",
        )
        self._monitor_thread.start()

    def stop_monitor(self) -> None:
        """Stop background health monitoring."""
        self._stop_event.set()
        if self._monitor_thread:
            self._monitor_thread.join(timeout=5)
            self._monitor_thread = None

    def _monitor_loop(self) -> None:
        """Periodically check proxy health."""
        while not self._stop_event.is_set():
            self._stop_event.wait(self._check_interval)
            if self._stop_event.is_set():
                break
            self._health_check()

    def _health_check(self) -> None:
        """Check all proxies and update health status."""
        with self._lock:
            proxies = list(self._proxies)

        for proxy in proxies:
            try:
                t0 = time.monotonic()
                sock = socket.create_connection(
                    (proxy.host or "127.0.0.1", proxy.port), timeout=3.0,
                )
                latency = (time.monotonic() - t0) * 1000
                sock.close()

                with self._lock:
                    proxy.is_alive = True
                    proxy.latency_ms = latency
                    proxy.last_check = time.monotonic()
                    if not proxy.is_alive:
                        log.info("Proxy recovered: %s (%.0fms)", proxy.url, latency)
            except (OSError, socket.timeout):
                with self._lock:
                    was_alive = proxy.is_alive
                    proxy.is_alive = False
                    proxy.last_check = time.monotonic()
                    if was_alive:
                        log.warning("Proxy went down: %s", proxy.url)

        # Try to discover new proxies if all are dead
        with self._lock:
            all_dead = all(not p.is_alive for p in self._proxies) if self._proxies else True

        if all_dead:
            log.info("All proxies dead — re-discovering...")
            self.discover()


# ------------------------------------------------------------------ #
# Network-aware retry with jitter
# ------------------------------------------------------------------ #

def network_retry(
    func: Callable[..., T],
    *args: Any,
    max_retries: int = 5,
    base_delay: float = 0.1,  # 100ms initial delay (fast first retry)
    max_delay: float = 30.0,
    jitter: bool = True,
    on_retry: Callable[[int, Exception], None] | None = None,
    **kwargs: Any,
) -> T:
    """Retry with exponential backoff + jitter for network operations.

    Tuned for CSCA scenarios:
    - Fast first retry (100ms) for transient network glitches
    - Exponential backoff with random jitter to avoid detection patterns
    - Max 30s delay to stay responsive

    Args:
        func: Function to retry
        max_retries: Maximum retry attempts
        base_delay: Initial delay in seconds (default 100ms)
        max_delay: Maximum delay between retries
        jitter: Add random jitter to avoid pattern detection
        on_retry: Optional callback(attempt, exception) on each retry
    """
    last_exc: Exception | None = None

    for attempt in range(max_retries + 1):
        try:
            return func(*args, **kwargs)
        except Exception as exc:
            last_exc = exc

            if attempt >= max_retries:
                break

            # Calculate delay with exponential backoff
            delay = min(base_delay * (2 ** attempt), max_delay)

            # Add random jitter (±50%) to avoid CSCA detecting regular patterns
            if jitter:
                delay *= 0.5 + random.random()  # 50% to 150% of calculated delay

            if on_retry:
                on_retry(attempt + 1, exc)

            log.debug(
                "Retry %d/%d after %.1fs: %s",
                attempt + 1, max_retries, delay, exc,
            )
            time.sleep(delay)

    raise last_exc  # type: ignore[misc]
