"""Visitor intelligence helpers.

This module centralises everything needed to identify a visitor:

* extracting the real client IP behind proxies/CDNs,
* resolving geolocation and network details (country, city, ISP, ASN, ...),
* parsing the user agent into device / browser / OS,
* detecting known bots and crawlers.

All geolocation results are cached in Django's cache so the (optional) network
lookups only happen once per IP per :data:`settings.IP_GEOLOCATION_CACHE_SECONDS`.
"""

from __future__ import annotations

import hashlib
import ipaddress
import json
import logging
import re
from dataclasses import dataclass, field, asdict
from typing import Optional

from django.conf import settings
from django.core.cache import cache

logger = logging.getLogger(__name__)

CACHE_KEY_PREFIX = 'ipintel:v1:'


# ---------------------------------------------------------------------------
# Client IP extraction
# ---------------------------------------------------------------------------

def get_client_ip(request) -> str:
    """Return the best-guess client IP for ``request``.

    Header precedence is intentional: when the site sits behind Cloudflare or a
    reverse proxy, ``REMOTE_ADDR`` is the proxy, so the forwarded headers must
    win. ``CF-Connecting-IP`` is checked first because Cloudflare sets it to the
    true client and strips spoofed copies.
    """
    candidates = (
        request.META.get('HTTP_CF_CONNECTING_IP'),
        request.META.get('HTTP_X_REAL_IP'),
        request.META.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip() or None,
    )
    for value in candidates:
        if value:
            ip = _strip_port(value.strip())
            if ip:
                return ip

    return _strip_port(request.META.get('REMOTE_ADDR', '') or '')


def _strip_port(value: str) -> str:
    """Remove a trailing ``:port`` from an IPv4 address (keeps IPv6 intact)."""
    if not value:
        return ''
    if value.startswith('['):  # [2001:db8::1]:443
        return value.split(']')[0].lstrip('[')
    if value.count(':') == 1 and '.' in value:
        return value.split(':')[0]
    return value


def is_valid_ip(ip_address: str) -> bool:
    try:
        ipaddress.ip_address((ip_address or '').strip())
        return True
    except ValueError:
        return False


def is_private_ip(ip_address: str) -> bool:
    """Return True for loopback/private/link-local addresses."""
    try:
        ip = ipaddress.ip_address((ip_address or '').strip())
    except ValueError:
        return False
    return ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved


# ---------------------------------------------------------------------------
# Geolocation + network intelligence
# ---------------------------------------------------------------------------

@dataclass
class IPProfile:
    """Everything we know about an IP address."""

    ip: str = ''
    ip_version: int = 0
    country_code: str = ''
    country_name: str = ''
    region: str = ''
    city: str = ''
    postal_code: str = ''
    latitude: Optional[float] = None
    longitude: Optional[float] = None
    timezone: str = ''
    isp: str = ''
    org: str = ''
    asn: str = ''
    is_proxy: bool = False
    is_hosting: bool = False
    is_private: bool = False
    provider: str = ''

    def to_dict(self) -> dict:
        return asdict(self)

    @classmethod
    def from_dict(cls, data: dict) -> 'IPProfile':
        allowed = {f for f in cls.__dataclass_fields__}
        return cls(**{k: v for k, v in (data or {}).items() if k in allowed})


class IPGeolocationService:
    """Resolve geolocation and network details for an IP address.

    Resolution order:

    1. Django cache (24h by default).
    2. MaxMind GeoLite2 City database, if :data:`settings.GEOIP2_CITY_DB_PATH`
       is configured and the file exists.
    3. MaxMind GeoLite2 Country database (:data:`settings.GEOIP2_DB_PATH`).
    4. An optional HTTP provider (``ip-api.com`` by default). This is what gives
       city/ISP/ASN data when no local database is present.
    """

    def __init__(self, use_http_provider: Optional[bool] = None):
        self.cache_seconds = int(getattr(settings, 'IP_GEOLOCATION_CACHE_SECONDS', 86400))
        if use_http_provider is None:
            use_http_provider = bool(getattr(settings, 'IP_GEOLOCATION_API_ENABLED', True))
        self.use_http_provider = use_http_provider
        self._city_reader = None
        self._country_reader = None
        self._readers_ready = False

    # -- public API ---------------------------------------------------------

    def lookup(self, ip_address: str) -> IPProfile:
        ip_address = (ip_address or '').strip()
        profile = IPProfile(ip=ip_address)

        if not is_valid_ip(ip_address):
            profile.provider = 'invalid'
            return profile

        profile.ip_version = 6 if ':' in ip_address else 4
        if is_private_ip(ip_address):
            profile.is_private = True
            profile.provider = 'private'
            profile.country_code = getattr(settings, 'GEO_FALLBACK_COUNTRY', 'GB')
            profile.country_name = getattr(settings, 'GEO_FALLBACK_COUNTRY_NAME', 'United Kingdom')
            return profile

        cache_key = f'{CACHE_KEY_PREFIX}{ip_address}'
        cached = cache.get(cache_key)
        if cached:
            return IPProfile.from_dict(cached)

        profile = self._lookup_local_databases(ip_address, profile)

        # Only fall back to the network provider when we still have no country.
        if not profile.country_code and self.use_http_provider:
            profile = self._lookup_http_provider(ip_address, profile)

        if profile.country_code:
            try:
                cache.set(cache_key, profile.to_dict(), self.cache_seconds)
            except Exception:  # pragma: no cover - cache backend failure
                pass

        return profile

    # -- MaxMind ------------------------------------------------------------

    def _ensure_readers(self):
        if self._readers_ready:
            return
        self._readers_ready = True

        import os

        city_path = getattr(settings, 'GEOIP2_CITY_DB_PATH', '')
        country_path = getattr(settings, 'GEOIP2_DB_PATH', '')

        try:
            import geoip2.database  # noqa: F401  (import check)
        except Exception:  # geoip2 not installed
            return

        if city_path and os.path.exists(city_path):
            try:
                import geoip2.database

                self._city_reader = geoip2.database.Reader(city_path)
            except Exception as exc:
                logger.warning("Failed to open GeoIP2 city database: %s", exc)

        if country_path and os.path.exists(country_path):
            try:
                import geoip2.database

                self._country_reader = geoip2.database.Reader(country_path)
            except Exception as exc:
                logger.warning("Failed to open GeoIP2 country database: %s", exc)

    def _lookup_local_databases(self, ip_address: str, profile: IPProfile) -> IPProfile:
        self._ensure_readers()

        if self._city_reader is not None:
            try:
                resp = self._city_reader.city(ip_address)
                profile.country_code = resp.country.iso_code or ''
                profile.country_name = resp.country.name or ''
                profile.region = (resp.subdivisions.most_specific.name if resp.subdivisions else '') or ''
                profile.city = resp.city.name or ''
                profile.postal_code = resp.postal.code or ''
                profile.latitude = resp.location.latitude
                profile.longitude = resp.location.longitude
                profile.timezone = resp.location.time_zone or ''
                profile.provider = 'maxmind-city'
                return profile
            except Exception:
                pass

        if self._country_reader is not None:
            try:
                resp = self._country_reader.country(ip_address)
                profile.country_code = resp.country.iso_code or ''
                profile.country_name = resp.country.name or ''
                profile.provider = 'maxmind-country'
                return profile
            except Exception:
                pass

        return profile

    # -- HTTP provider ------------------------------------------------------

    def _lookup_http_provider(self, ip_address: str, profile: IPProfile) -> IPProfile:
        provider = getattr(settings, 'IP_GEOLOCATION_API_PROVIDER', 'ip-api')
        timeout = float(getattr(settings, 'IP_GEOLOCATION_API_TIMEOUT', 3.0))
        token = getattr(settings, 'IPINFO_TOKEN', '')

        try:
            if provider == 'ipinfo' and token:
                url = f'https://ipinfo.io/{ip_address}/json?token={token}'
                data = self._fetch_json(url, timeout)
                if data and not data.get('bogon'):
                    loc = (data.get('loc') or ',').split(',')
                    profile.country_code = (data.get('country') or '')[:2]
                    profile.region = data.get('region') or ''
                    profile.city = data.get('city') or ''
                    profile.postal_code = data.get('postal') or ''
                    if len(loc) == 2:
                        profile.latitude = _to_float(loc[0])
                        profile.longitude = _to_float(loc[1])
                    profile.timezone = data.get('timezone') or ''
                    org = data.get('org') or ''
                    profile.org = org
                    profile.isp = org.split(' ')[1] if org.startswith('AS') and ' ' in org else org
                    profile.asn = org.split(' ')[0] if org.startswith('AS') else ''
                    profile.provider = 'ipinfo'
            else:
                fields = 'status,message,country,countryCode,regionName,city,zip,lat,lon,timezone,isp,org,as,proxy,hosting'
                url = f'http://ip-api.com/json/{ip_address}?fields={fields}'
                data = self._fetch_json(url, timeout)
                if data and data.get('status') == 'success':
                    profile.country_code = (data.get('countryCode') or '')[:2]
                    profile.country_name = data.get('country') or ''
                    profile.region = data.get('regionName') or ''
                    profile.city = data.get('city') or ''
                    profile.postal_code = data.get('zip') or ''
                    profile.latitude = _to_float(data.get('lat'))
                    profile.longitude = _to_float(data.get('lon'))
                    profile.timezone = data.get('timezone') or ''
                    profile.isp = data.get('isp') or ''
                    profile.org = data.get('org') or ''
                    profile.asn = data.get('as') or ''
                    profile.is_proxy = bool(data.get('proxy'))
                    profile.is_hosting = bool(data.get('hosting'))
                    profile.provider = 'ip-api'
        except Exception as exc:
            logger.debug("IP geolocation HTTP provider failed for %s: %s", ip_address, exc)

        return profile

    @staticmethod
    def _fetch_json(url: str, timeout: float) -> Optional[dict]:
        # Local import keeps the module importable without requests installed.
        import requests

        resp = requests.get(url, timeout=timeout, headers={'User-Agent': 'Xinfinity-VisitorIntel/1.0'})
        if resp.status_code == 200:
            return resp.json()
        return None


def _to_float(value) -> Optional[float]:
    try:
        return float(value)
    except (TypeError, ValueError):
        return None


# ---------------------------------------------------------------------------
# User agent parsing
# ---------------------------------------------------------------------------

_BOT_PATTERN = re.compile(
    r'(bot|crawl|spider|slurp|bingpreview|facebookexternalhit|embedly|quora|pinterest|'
    r'whatsapp|telegrambot|discordbot|headlesschrome|phantomjs|python-requests|'
    r'curl|wget|go-http-client|scrapy|ahrefs|semrush|mj12bot|dotbot|petalbot|'
    r'bytespider|gptbot|ccbot|claudebot|anthropic|perplexity|yandex|baidu|sogou|'
    r'uptimerobot|pingdom|monitoring|healthcheck|nmap|masscan|zgrab)',
    re.IGNORECASE,
)

_MOBILE_PATTERN = re.compile(r'mobi|android(?!.*tablet)|iphone|ipod|windows phone|blackberry', re.IGNORECASE)
_TABLET_PATTERN = re.compile(r'ipad|tablet|kindle|silk|playbook|android(?!.*mobi)', re.IGNORECASE)

_BROWSERS = (
    ('Edg', 'Edge'),
    ('OPR', 'Opera'),
    ('Opera', 'Opera'),
    ('SamsungBrowser', 'Samsung Internet'),
    ('CriOS', 'Chrome'),
    ('Chrome', 'Chrome'),
    ('FxiOS', 'Firefox'),
    ('Firefox', 'Firefox'),
    ('MSIE', 'Internet Explorer'),
    ('Trident', 'Internet Explorer'),
    ('Safari', 'Safari'),
)

_OSES = (
    ('Windows NT 10', 'Windows 10/11'),
    ('Windows NT', 'Windows'),
    ('Android', 'Android'),
    ('iPhone OS', 'iOS'),
    ('iPad', 'iPadOS'),
    ('CPU OS', 'iOS'),
    ('Mac OS X', 'macOS'),
    ('CrOS', 'ChromeOS'),
    ('Linux', 'Linux'),
)


def is_bot(user_agent: str) -> bool:
    if not user_agent or len(user_agent) < 15:
        return True
    return bool(_BOT_PATTERN.search(user_agent))


def parse_user_agent(user_agent: str) -> dict:
    """Return ``device_type``, ``browser`` and ``os`` for a user agent."""
    ua = user_agent or ''
    if is_bot(ua):
        device_type = 'bot'
    elif _TABLET_PATTERN.search(ua):
        device_type = 'tablet'
    elif _MOBILE_PATTERN.search(ua):
        device_type = 'mobile'
    else:
        device_type = 'desktop'

    browser = ''
    for token, name in _BROWSERS:
        if token in ua:
            browser = name
            break

    os_name = ''
    for token, name in _OSES:
        if token in ua:
            os_name = name
            break

    return {'device_type': device_type, 'browser': browser, 'os': os_name}


def visitor_fingerprint(ip_address: str, user_agent: str, session_key: str = '') -> str:
    """Build a stable visitor key.

    Prefer the Django session key (accurate for humans). For bots / anonymous
    requests without a session, fall back to a hash of IP + user agent.
    """
    if session_key:
        raw = f'session:{session_key}'
    else:
        raw = f'fp:{ip_address}:{user_agent or ""}'
    return hashlib.sha256(raw.encode('utf-8', 'ignore')).hexdigest()
