from __future__ import annotations
import socket
import ipaddress
from pathlib import Path
from typing import Optional, Dict, Any
from datetime import datetime, timedelta, timezone
import json
import random
import threading
import sys
from ..debug import debug_log

try:
    import geoip2.database
    import geoip2.errors
    GEOIP2_AVAILABLE = True
except ImportError:
    GEOIP2_AVAILABLE = False
except Exception as e:
    # Catch any native/DLL loading errors
    GEOIP2_AVAILABLE = False
    if sys.platform == 'win32':
        print(f"  ⚠️  geoip2 import failed: {e}", flush=True)

try:
    import miniupnpc
    MINIUPNPC_AVAILABLE = True
except ImportError:
    MINIUPNPC_AVAILABLE = False
except Exception as e:
    # Catch any native/DLL loading errors (common on Windows)
    MINIUPNPC_AVAILABLE = False
    if sys.platform == 'win32':
        print(f"  ⚠️  miniupnpc import failed: {e}", flush=True)

# Session flag to show location warning only once per session
_location_warning_shown = False

# Simple in-memory caches (module scoped)
# Cache for location lookups keyed by final resolved IP -> location_info dict
_location_cache: Dict[str, Dict[str, Any]] = {}

# Cache for CGNAT OpenDNS public IP resolution attempts keyed by original CGNAT IP.
# Value is tuple: (timestamp, resolved_public_ip or None). We avoid re-querying OpenDNS
# more than once per hour for the same CGNAT IP. This respects user privacy by
# minimising external DNS queries.
_cgnat_resolution_cache: Dict[str, tuple[datetime, Optional[str]]] = {}

# TTL for CGNAT OpenDNS resolution attempts
_CGNAT_RESOLUTION_TTL = timedelta(hours=1)

# Disk cache paths (share directory with geoip DB for locality)
def _cache_base_dir() -> Path:
    return Path.home() / ".local" / "share" / "jarvis"

_LOCATION_CACHE_FILE = _cache_base_dir() / "location_cache.json"
_CGNAT_CACHE_FILE = _cache_base_dir() / "cgnat_cache.json"

_cache_lock = threading.RLock()

def _load_disk_caches() -> None:
    """Load caches from disk into memory (best-effort)."""
    try:
        base = _cache_base_dir()
        base.mkdir(parents=True, exist_ok=True)
    except Exception:
        return
    now = datetime.now(timezone.utc)
    with _cache_lock:
        # Location cache
        try:
            if _LOCATION_CACHE_FILE.exists():
                with _LOCATION_CACHE_FILE.open("r", encoding="utf-8") as f:
                    raw = json.load(f)
                # Expect mapping ip -> {data: {...}, ts: iso}
                for ip, payload in raw.items():
                    data = payload.get("data") if isinstance(payload, dict) else None
                    ts_str = payload.get("ts") if isinstance(payload, dict) else None
                    if not isinstance(data, dict) or not ts_str:
                        continue
                    try:
                        ts = datetime.fromisoformat(ts_str)
                        if ts.tzinfo is None:
                            ts = ts.replace(tzinfo=timezone.utc)
                    except Exception:
                        continue
                    # TTL for location cache can vary; default 60 minutes (aligned with config default).
                    ttl_minutes = payload.get("ttl") if isinstance(payload, dict) else None
                    try:
                        ttl_minutes = int(ttl_minutes)
                    except Exception:
                        ttl_minutes = 60
                    if now - ts < timedelta(minutes=ttl_minutes):
                        _location_cache[ip] = data
        except Exception:
            pass
        # CGNAT resolution cache
        try:
            if _CGNAT_CACHE_FILE.exists():
                with _CGNAT_CACHE_FILE.open("r", encoding="utf-8") as f:
                    raw = json.load(f)
                for cgnat_ip, payload in raw.items():
                    if not isinstance(payload, dict):
                        continue
                    ts_str = payload.get("ts")
                    resolved = payload.get("resolved")
                    if not ts_str:
                        continue
                    try:
                        ts = datetime.fromisoformat(ts_str)
                        if ts.tzinfo is None:
                            ts = ts.replace(tzinfo=timezone.utc)
                    except Exception:
                        continue
                    if now - ts < _CGNAT_RESOLUTION_TTL:
                        _cgnat_resolution_cache[cgnat_ip] = (ts, resolved)
        except Exception:
            pass

def _persist_disk_caches(location_cache_minutes: int = 60) -> None:
    """Persist in-memory caches to disk (best-effort)."""
    with _cache_lock:
        try:
            base = _cache_base_dir()
            base.mkdir(parents=True, exist_ok=True)
        except Exception:
            return
        # Location cache serialisation
        try:
            loc_out = {}
            now = datetime.now(timezone.utc).isoformat()
            for ip, data in _location_cache.items():
                loc_out[ip] = {"data": data, "ts": now, "ttl": int(location_cache_minutes)}
            with _LOCATION_CACHE_FILE.open("w", encoding="utf-8") as f:
                json.dump(loc_out, f)
        except Exception:
            pass
        # CGNAT cache
        try:
            cgnat_out = {}
            for ip, (ts, resolved) in _cgnat_resolution_cache.items():
                cgnat_out[ip] = {"ts": ts.isoformat(), "resolved": resolved}
            with _CGNAT_CACHE_FILE.open("w", encoding="utf-8") as f:
                json.dump(cgnat_out, f)
        except Exception:
            pass

# Load caches on module import
_load_disk_caches()


def _get_local_network_ip() -> Optional[str]:
    """
    Get the local network IP address without making external calls.
    This respects privacy by not contacting third-party services.

    Note: This returns the local IP, not the public IP, so geolocation
    will only work if the user manually configures their public IP.
    """
    try:
        # Using context manager ensures socket is always closed
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
            # Connect to a non-routable address to determine local IP
            # This doesn't actually send any data
            s.connect(("10.254.254.254", 80))
            return s.getsockname()[0]
    except Exception:
        pass

    return None


def _get_external_ip_via_upnp() -> Optional[str]:
    """
    Get external IP address by querying the local router via UPnP.
    This is privacy-friendly as it only communicates with your local router.

    Returns:
        External IP address if successful, None otherwise.
    """
    if not MINIUPNPC_AVAILABLE:
        return None

    try:
        upnp = miniupnpc.UPnP()
        upnp.discoverdelay = 200  # milliseconds

        # Discover UPnP devices
        device_count = upnp.discover()
        if device_count == 0:
            return None

        # Select the Internet Gateway Device
        upnp.selectigd()

        # Get the external IP address
        external_ip = upnp.externalipaddress()

        # Validate the IP address and ensure it's not private
        if external_ip and not _is_private_ip(external_ip) and "." in external_ip:
            return external_ip

    except Exception:
        # UPnP might not be supported or enabled
        pass

    return None


def _is_private_ip(ip: str) -> bool:
    """Check if an IP address is private/local or special-use (non-geolocatable)."""
    if not ip:
        return True
    try:
        addr = ipaddress.ip_address(ip)
        # RFC 6598 shared address space (CGNAT) will be treated separately; don't mark as private here
        if addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_reserved or addr.is_multicast:
            return True
        # 0.0.0.0 and unspecified
        if addr.is_unspecified:
            return True
    except ValueError:
        return True
    return False


def _is_cgnat_ip(ip: str) -> bool:
    """Return True if IP is in carrier-grade NAT (100.64.0.0/10)."""
    try:
        addr = ipaddress.ip_address(ip)
        return addr in ipaddress.ip_network("100.64.0.0/10")
    except ValueError:
        return False


def _get_external_ip_via_socket() -> Optional[str]:
    """
    Determine which local IP the OS would use to route to external servers.

    Opens a UDP socket to well-known DNS servers (Google, Cloudflare, OpenDNS)
    without sending data, then reads the local interface IP. This typically
    returns a private/NAT IP (e.g. 192.168.x.x) which is filtered out. It
    only returns a result when the host has a directly-assigned public IP.

    Returns:
        A public IP if the local interface has one, None otherwise.
    """
    # Try multiple well-known servers to increase reliability
    servers = [
        ("8.8.8.8", 80),      # Google DNS
        ("1.1.1.1", 80),      # Cloudflare DNS
        ("208.67.222.222", 80), # OpenDNS
    ]

    for server_ip, port in servers:
        try:
            # Using context manager ensures socket is always closed
            with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
                # Connect to determine which local interface would be used
                s.connect((server_ip, port))
                detected_ip = s.getsockname()[0]

                # Return the first non-private IP we find
                if detected_ip and not _is_private_ip(detected_ip):
                    return detected_ip
        except Exception:
            continue

    return None


def _get_external_ip_automatically() -> Optional[str]:
    """
    Attempt to automatically determine the external IP address using
    privacy-friendly methods in order of preference:

    1. UPnP (query local router) - most privacy-friendly
    2. Socket connection (determine external interface) - minimal external contact

    Returns:
        External IP address if successful, None otherwise.
    """
    # Try UPnP first (most privacy-friendly)
    ip = _get_external_ip_via_upnp()
    if ip:
        return ip

    # Fallback to socket method
    ip = _get_external_ip_via_socket()
    if ip:
        return ip

    # Final fallback: single DNS query to OpenDNS (privacy-light)
    ip = _resolve_public_ip_via_opendns()
    if ip and not _is_private_ip(ip):
        debug_log(f"Public IP resolved via OpenDNS: {ip}", "location")
        return ip

    return None


def _get_database_path() -> Path:
    """Get the path where the GeoLite2 database should be stored."""
    base_dir = Path.home() / ".local" / "share" / "jarvis" / "geoip"
    base_dir.mkdir(parents=True, exist_ok=True)
    return base_dir / "GeoLite2-City.mmdb"


def _print_location_setup_instructions(db_path: Path) -> None:
    """Print user-friendly location setup instructions with proper formatting."""
    global _location_warning_shown

    # Only show warning once per session
    if _location_warning_shown:
        return

    _location_warning_shown = True

    print("  📍 Location features are not available")
    print()
    print("     To enable location-based features:")
    print("     1. 🌐 Register for a free MaxMind account:")
    print("        https://www.maxmind.com/en/geolite2/signup")
    print()
    print("     2. 📥 Download the GeoLite2 City database (MMDB format)")
    print()
    print("     3. 📂 Save the database file as:")
    print(f"        {db_path}")


def _download_geolite2_database() -> bool:
    """
    Download the GeoLite2 City database from MaxMind.
    Note: This requires registration for a license key since 2019.
    For now, we'll provide instructions for manual download.
    """
    try:
        db_path = _get_database_path()

        # Check if database already exists and is recent (less than 30 days old)
        if db_path.exists():
            age_days = (datetime.now(timezone.utc) - datetime.fromtimestamp(db_path.stat().st_mtime, tz=timezone.utc)).days
            if age_days < 30:
                debug_log("GeoLite2 database found and up to date", "location")
                return True

        debug_log(f"GeoLite2 database not found or outdated at: {db_path}", "location")
        _print_location_setup_instructions(db_path)

        return False

    except Exception as e:
        debug_log(f"Error checking database: {e}", "location")
        return False


def _resolve_public_ip_via_opendns(timeout: float = 1.5) -> Optional[str]:
    """Resolve true public IP via a single DNS query to OpenDNS (myip.opendns.com)."""
    try:
        resolver_ip = ("208.67.222.222", 53)
        tid = random.randint(0, 0xFFFF)
        header = tid.to_bytes(2, 'big') + b"\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00"
        labels = b"".join(len(part).to_bytes(1, 'big') + part.encode('ascii') for part in "myip.opendns.com".split('.')) + b"\x00"
        qtype_qclass = b"\x00\x01\x00\x01"
        packet = header + labels + qtype_qclass

        # Using context manager ensures socket is always closed
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
            s.settimeout(timeout)
            s.sendto(packet, resolver_ip)
            data, _ = s.recvfrom(512)

        if len(data) < 12 or data[0:2] != tid.to_bytes(2, 'big'):
            return None
        question_len = len(labels) + 4
        answer_start = 12 + question_len
        if len(data) < answer_start + 12:
            return None
        # Validate answer is an A record (RTYPE=1, RCLASS=1)
        rtype = int.from_bytes(data[answer_start + 2:answer_start + 4], 'big') if len(data) >= answer_start + 4 else 0
        rclass = int.from_bytes(data[answer_start + 4:answer_start + 6], 'big') if len(data) >= answer_start + 6 else 0
        if rtype != 1 or rclass != 1:
            return None
        rdlength = int.from_bytes(data[answer_start + 10:answer_start + 12], 'big') if len(data) >= answer_start + 12 else 0
        rdata_start = answer_start + 12
        rdata_end = rdata_start + rdlength
        if rdlength == 4 and len(data) >= rdata_end:
            ip_bytes = data[rdata_start:rdata_end]
            return '.'.join(str(b) for b in ip_bytes)
    except Exception:
        return None
    return None


def get_location_info(
    ip_address: Optional[str] = None,
    *,
    config_ip: Optional[str] = None,
    auto_detect: bool = True,
    resolve_cgnat_public_ip: bool = True,
    location_cache_minutes: int = 60,
) -> Dict[str, Any]:
    """Get location information for an IP address.

    Args:
        ip_address: Direct IP address to look up. If provided it is used as-is.
        config_ip: Manually configured public IP (takes precedence over auto-detect when ip_address is None).
        auto_detect: Attempt to discover an external IP via UPnP / socket heuristics if neither ip_address nor config_ip given.
        resolve_cgnat_public_ip: If True and a CGNAT (100.64.0.0/10) address is detected, attempt a single DNS query via OpenDNS to discover the true public IP (privacy-light).
        location_cache_minutes: TTL in minutes for cached location lookups persisted to disk.
    """
    if not GEOIP2_AVAILABLE:
        return {"error": "geoip2 library not available"}

    # Get IP address to lookup (prioritize parameter, then config, then auto-detect)
    if ip_address is None:
        if config_ip:
            ip_address = config_ip
        elif auto_detect:
            # Try automatic detection using privacy-friendly methods
            ip_address = _get_external_ip_automatically()
            if not ip_address:
                # Final fallback to local IP (won't work for geolocation)
                ip_address = _get_local_network_ip()
        else:
            # Fall back to local IP without auto-detection
            ip_address = _get_local_network_ip()

    if not ip_address:
        return {"error": "No IP address available. Try enabling auto-detection or configure 'location_ip_address' in your config."}

    # Mark CGNAT and optionally resolve public IP via OpenDNS if enabled in settings
    cgnat_flag = _is_cgnat_ip(ip_address)
    if cgnat_flag and resolve_cgnat_public_ip:
        now = datetime.now(timezone.utc)
        needs_resolve = False
        with _cache_lock:
            cache_entry = _cgnat_resolution_cache.get(ip_address)
            if cache_entry:
                ts, cached_public = cache_entry
                if now - ts < _CGNAT_RESOLUTION_TTL:
                    # Use cached result (even if None to avoid extra queries)
                    if cached_public and not _is_cgnat_ip(cached_public) and not _is_private_ip(cached_public):
                        debug_log(f"CGNAT IP {ip_address} used cached public {cached_public}", "location")
                        ip_address = cached_public
                else:
                    # Expired entry
                    _cgnat_resolution_cache.pop(ip_address, None)
                    needs_resolve = True
            else:
                needs_resolve = True
        if needs_resolve:
            resolved = _resolve_public_ip_via_opendns()
            with _cache_lock:
                _cgnat_resolution_cache[ip_address] = (now, resolved)
            # Persist CGNAT cache change
            _persist_disk_caches(location_cache_minutes)
            if resolved and not _is_cgnat_ip(resolved) and not _is_private_ip(resolved):
                debug_log(f"CGNAT IP {ip_address} resolved to public {resolved} via OpenDNS", "location")
                ip_address = resolved

    # Return cached location result if we already computed for this final ip_address
    if ip_address in _location_cache:
        cached = _location_cache[ip_address]
        # Negative results (errors) expire after location_cache_minutes so DB updates can take effect
        if "error" in cached:
            cached_at = cached.get("_cached_at")
            if cached_at and datetime.now(timezone.utc) - cached_at > timedelta(minutes=location_cache_minutes):
                with _cache_lock:
                    _location_cache.pop(ip_address, None)
            else:
                # Ensure we always include ip key even if older cache missing it
                if 'ip' not in cached:
                    cached['ip'] = ip_address
                result = cached.copy()
                result.pop("_cached_at", None)
                return result
        else:
            # Ensure we always include ip key even if older cache missing it
            if 'ip' not in cached:
                cached['ip'] = ip_address
            return cached.copy()

    # Check if database is available
    db_path = _get_database_path()
    if not db_path.exists():
        if not _download_geolite2_database():
            return {"error": "GeoLite2 database not available"}

    try:
        with geoip2.database.Reader(str(db_path)) as reader:
            response = reader.city(ip_address)

            location_info = {
                "ip": ip_address,
                "country": response.country.name,
                "country_code": response.country.iso_code,
                "region": response.subdivisions.most_specific.name,
                "region_code": response.subdivisions.most_specific.iso_code,
                "city": response.city.name,
                "latitude": float(response.location.latitude) if response.location.latitude else None,
                "longitude": float(response.location.longitude) if response.location.longitude else None,
                "timezone": response.location.time_zone,
                "accuracy_radius": response.location.accuracy_radius,
            }

            # Clean up None values and empty strings
            cleaned_info = {k: v for k, v in location_info.items() if v is not None and v != ""}
            debug_log(f"Location detected: {cleaned_info.get('city', 'Unknown city')}, {cleaned_info.get('country', 'Unknown country')}", "location")
            # Cache successful lookup
            _location_cache[ip_address] = cleaned_info.copy()
            _persist_disk_caches(location_cache_minutes)
            return cleaned_info

    except geoip2.errors.AddressNotFoundError:
        debug_log(f"IP address {ip_address} not found in database", "location")
        reason = "cgnat_not_found" if cgnat_flag else "not_found"
        advice = (
            "Detected CGNAT (100.64.0.0/10). Configure 'location_ip_address' with a real public IP or disable auto-detect."
            if cgnat_flag else
            "If this is CGNAT or a very new allocation, configure 'location_ip_address' manually."
        )
        result = {
            "error": f"IP address {ip_address} not found in database",
            "ip": ip_address,
            "reason": reason,
            "advice": advice,
        }
        # Cache negative result with TTL so it expires and retries after DB updates
        cached_result = result.copy()
        cached_result["_cached_at"] = datetime.now(timezone.utc)
        _location_cache[ip_address] = cached_result
        _persist_disk_caches(location_cache_minutes)
        return result
    except Exception as e:
        debug_log(f"Error looking up location: {e}", "location")
        result = {"error": f"Error looking up location: {e}", "ip": ip_address}
        cached_result = result.copy()
        cached_result["_cached_at"] = datetime.now(timezone.utc)
        _location_cache[ip_address] = cached_result
        _persist_disk_caches(location_cache_minutes)
        return result


def _format_location_context(location_info: Dict[str, Any]) -> str:
    """Format a location_info dict into a one-line human-readable context string."""
    if "error" in location_info:
        return "Location: Unknown"

    parts = []

    if location_info.get("city"):
        if location_info.get("region"):
            parts.append(f"{location_info['city']}, {location_info['region']}")
        else:
            parts.append(location_info["city"])
    elif location_info.get("region"):
        parts.append(location_info["region"])

    if location_info.get("country"):
        parts.append(location_info["country"])

    if location_info.get("timezone"):
        parts.append(f"({location_info['timezone']})")

    if parts:
        return f"Location: {', '.join(parts)}"
    return "Location: Unknown"


def get_location_context(
    *,
    config_ip: Optional[str] = None,
    auto_detect: bool = True,
    resolve_cgnat_public_ip: bool = True,
    location_cache_minutes: int = 60,
) -> str:
    """Generate a concise location context string using explicit parameters."""
    return _format_location_context(get_location_info(
        config_ip=config_ip,
        auto_detect=auto_detect,
        resolve_cgnat_public_ip=resolve_cgnat_public_ip,
        location_cache_minutes=location_cache_minutes,
    ))


def get_location_context_with_timezone(
    *,
    config_ip: Optional[str] = None,
    auto_detect: bool = True,
    resolve_cgnat_public_ip: bool = True,
    location_cache_minutes: int = 60,
) -> tuple[str, Optional[str]]:
    """Return the location context string and the IANA timezone (if known) in one lookup."""
    info = get_location_info(
        config_ip=config_ip,
        auto_detect=auto_detect,
        resolve_cgnat_public_ip=resolve_cgnat_public_ip,
        location_cache_minutes=location_cache_minutes,
    )
    tz_name = info.get("timezone") if isinstance(info, dict) else None
    return _format_location_context(info), tz_name


def is_location_available() -> bool:
    """Check if location detection is available and working."""
    if not GEOIP2_AVAILABLE:
        return False

    db_path = _get_database_path()
    return db_path.exists()


def setup_location_database() -> bool:
    """
    Setup the location database. This will check for the database
    and provide instructions if it's not available.

    Returns:
        True if database is available and ready, False otherwise.
    """
    if not GEOIP2_AVAILABLE:
        print("📦 Location library not installed")
        print()
        print("   To install the required geoip2 library:")
        print("   pip install geoip2")
        print()
        debug_log("geoip2 library not available", "location")
        return False

    return _download_geolite2_database()


def get_detailed_location_info(
    ip_address: Optional[str] = None,
    *,
    config_ip: Optional[str] = None,
    auto_detect: bool = True,
    resolve_cgnat_public_ip: bool = True,
    location_cache_minutes: int = 60,
) -> Dict[str, Any]:
    """Get detailed location information including coordinates and formatted address."""
    location_info = get_location_info(
        ip_address,
        config_ip=config_ip,
        auto_detect=auto_detect,
        resolve_cgnat_public_ip=resolve_cgnat_public_ip,
        location_cache_minutes=location_cache_minutes,
    )

    if "error" in location_info:
        return location_info

    # Add computed fields
    if location_info.get("latitude") and location_info.get("longitude"):
        location_info["coordinates"] = f"{location_info['latitude']}, {location_info['longitude']}"

    # Add formatted address
    address_parts = []
    for field in ["city", "region", "country"]:
        if location_info.get(field):
            address_parts.append(location_info[field])

    if address_parts:
        location_info["formatted_address"] = ", ".join(address_parts)

    return location_info


# For testing and debugging
if __name__ == "__main__":
    print("Testing location detection...")

    # Test local IP detection (privacy-focused)
    ip = _get_local_network_ip()
    print(f"Local IP: {ip}")

    # Test location lookup (will likely fail without public IP)
    location = get_location_info()
    print(f"Location info: {location}")

    # Test context generation
    context = get_location_context()
    print(f"Location context: {context}")

    # Test detailed info
    detailed = get_detailed_location_info()
    print(f"Detailed location: {detailed}")

    print("\nNote: For accurate location detection, configure 'location_ip_address' in your config")
    print("or provide an IP address explicitly to respect privacy.")
