"""
离线 GeoIP 查询 — 支持 MMDB (GeoLite2-City / GeoLite2-ASN) 和文本格式回退。

优先级：
  1. GeoLite2-City.mmdb  (MaxMind MMDB 格式，城市级精度)
  2. GeoLite2-ASN.mmdb    (可选，提供 ISP / 组织信息)
  3. geoip_data.txt       (文本格式回退，803 段覆盖 30 城市)

MMDB 查询使用 ``maxminddb`` 库，文本格式使用二分查找 O(log N)。
"""
import bisect
import logging
from pathlib import Path
from typing import Optional

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
#  Data structures
# ---------------------------------------------------------------------------

# MMDB readers (lazy-loaded, None if unavailable)
_mmdb_city: Optional[object] = None       # maxminddb.Reader for City DB
_mmdb_asn: Optional[object] = None        # maxminddb.Reader for ASN DB

# Text-format data (fallback)
# [(start_int, end_int, lat, lon, city, country), ...]
_GEO_DATA: list[tuple[int, int, float, float, str, str]] = []
_STARTS: list[int] = []
_RANGES: list[tuple[int, int, float, float, str, str]] = []


# ---------------------------------------------------------------------------
#  IP conversion
# ---------------------------------------------------------------------------

def ip_to_int(ip_str: str) -> Optional[int]:
    """Convert an IPv4 dotted-string to its 32-bit integer representation.

    Returns ``None`` if *ip_str* is not a valid IPv4 address.
    """
    parts = ip_str.strip().split('.')
    if len(parts) != 4:
        return None
    try:
        octets = [int(p) for p in parts]
        if any(o < 0 or o > 255 for o in octets):
            return None
        return (octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]
    except (ValueError, TypeError):
        return None


# ---------------------------------------------------------------------------
#  MMDB loading
# ---------------------------------------------------------------------------

def _load_mmdb() -> None:
    """Try to load MaxMind MMDB databases from ``data/`` directory.

    Looks for:
      - ``data/GeoLite2-City.mmdb``   (primary — city, country, lat/lon)
      - ``data/GeoLite2-ASN.mmdb``    (optional — ISP, organization, ASN)
    """
    global _mmdb_city, _mmdb_asn

    data_dir = Path(__file__).parent.parent / 'data'

    city_path = data_dir / 'GeoLite2-City.mmdb'
    asn_path = data_dir / 'GeoLite2-ASN.mmdb'

    if not city_path.exists():
        logger.info('[GEOIP] GeoLite2-City.mmdb not found at %s — will use text fallback', city_path)
        return

    try:
        import maxminddb
        _mmdb_city = maxminddb.open_database(str(city_path))
        logger.info('[GEOIP] loaded GeoLite2-City.mmdb (MMDB format)')

        if asn_path.exists():
            _mmdb_asn = maxminddb.open_database(str(asn_path))
            logger.info('[GEOIP] loaded GeoLite2-ASN.mmdb (ISP/ORG)')
    except Exception as e:
        logger.warning('[GEOIP] failed to open MMDB: %s', e)
        _mmdb_city = None
        _mmdb_asn = None


# ---------------------------------------------------------------------------
#  Text-format loading (fallback)
# ---------------------------------------------------------------------------

def _build_index() -> tuple[list[int], list[tuple[int, int, float, float, str, str]]]:
    if not _GEO_DATA:
        return [], []
    sorted_data = sorted(_GEO_DATA, key=lambda x: x[0])
    starts = [entry[0] for entry in sorted_data]
    return starts, sorted_data


def _load_data_file() -> None:
    """Load IP range data from ``data/geoip_data.txt`` (text fallback)."""
    data_path = Path(__file__).parent.parent / 'data' / 'geoip_data.txt'

    if not data_path.exists():
        logger.warning('[GEOIP] text data file not found: %s', data_path)
        return

    loaded: list[tuple[int, int, float, float, str, str]] = []
    skipped = 0

    with open(data_path, 'r', encoding='utf-8') as f:
        for raw in f:
            stripped = raw.strip()
            if not stripped or stripped.startswith('#'):
                continue
            parts = [p.strip() for p in stripped.split(',')]
            if len(parts) != 6:
                skipped += 1
                continue
            start_ip_str, end_ip_str, lat_str, lon_str, city, country = parts
            start_int = ip_to_int(start_ip_str)
            end_int = ip_to_int(end_ip_str)
            if start_int is None or end_int is None:
                skipped += 1
                continue
            try:
                lat = float(lat_str)
                lon = float(lon_str)
            except ValueError:
                skipped += 1
                continue
            loaded.append((start_int, end_int, lat, lon, city, country))

    global _GEO_DATA, _STARTS, _RANGES
    _GEO_DATA.clear()
    _GEO_DATA.extend(loaded)
    _STARTS, _RANGES = _build_index()

    logger.info('[GEOIP] text fallback: loaded %d ranges from %s (%d skipped)',
                len(loaded), data_path, skipped)


# ---------------------------------------------------------------------------
#  MMDB lookup helpers
# ---------------------------------------------------------------------------

def _lookup_mmdb(ip_str: str) -> Optional[dict]:
    """Query MMDB database for an IP.  Returns rich result with ISP/ORG when
    ASN database is also available."""
    if _mmdb_city is None:
        return None

    try:
        city_result = _mmdb_city.get(ip_str)
        if city_result is None:
            return None

        # Extract location
        location = city_result.get('location', {})
        lat = location.get('latitude')
        lon = location.get('longitude')

        if lat is None or lon is None:
            return None

        # Extract names (English preferred)
        city = None
        city_names = city_result.get('city', {}).get('names', {})
        if city_names:
            city = city_names.get('en') or next(iter(city_names.values()), None)

        country = None
        country_info = city_result.get('country', {})
        country = country_info.get('iso_code') or ''
        country_names = country_info.get('names', {})
        country_name = country_names.get('en') or next(iter(country_names.values()), None) if country_names else None

        # Subdivisions
        subdivisions = city_result.get('subdivisions', [])
        region = None
        if subdivisions:
            sub_names = subdivisions[0].get('names', {})
            region = sub_names.get('en') or next(iter(sub_names.values()), None) if sub_names else None

        result = {
            'lat': round(float(lat), 4),
            'lon': round(float(lon), 4),
            'city': city or region or '',
            'country': country,
            'country_name': country_name or '',
            'region': region or '',
        }

        # Try ASN database for ISP/ORG
        if _mmdb_asn is not None:
            try:
                asn_result = _mmdb_asn.get(ip_str)
                if asn_result:
                    result['isp'] = asn_result.get('autonomous_system_organization', '')
                    result['asn'] = asn_result.get('autonomous_system_number', '')
            except Exception:
                pass

        return result

    except Exception as e:
        logger.debug('[GEOIP] MMDB lookup error for %s: %s', ip_str, e)
        return None


def _lookup_text(ip_str: str) -> Optional[dict]:
    """Query text-format database (fallback)."""
    target = ip_to_int(ip_str)
    if target is None:
        return None
    if not _STARTS:
        return None

    idx = bisect.bisect_right(_STARTS, target) - 1
    if idx < 0:
        return None

    start, end, lat, lon, city, country = _RANGES[idx]
    if start <= target <= end:
        return {
            'lat': lat,
            'lon': lon,
            'city': city,
            'country': country,
            'country_name': '',
            'region': '',
        }

    return None


# ---------------------------------------------------------------------------
#  Public API
# ---------------------------------------------------------------------------

def lookup(ip_str: str) -> Optional[dict]:
    """查询 IP 对应的地理位置。

    Tries MMDB first (if available), then falls back to text format.

    Parameters
    ----------
    ip_str:
        IPv4 address as a dotted string (e.g. ``'8.8.8.8'``).

    Returns
    -------
    dict or None
        Keys: ``lat``, ``lon``, ``city``, ``country`` (ISO code),
        ``country_name``, ``region``.
        When ASN database is available, also includes: ``isp``, ``asn``.
    """
    if not ip_str or ip_str in ('None', '', '+', '-'):
        return None

    # MMDB first
    if _mmdb_city is not None:
        result = _lookup_mmdb(ip_str)
        if result is not None:
            return result
        # MMDB miss — fall through to text

    # Text fallback
    return _lookup_text(ip_str)


def is_available() -> bool:
    """Return True if at least one GeoIP source is loaded."""
    return _mmdb_city is not None or bool(_STARTS)


def source_name() -> str:
    """Return human-readable name of the active GeoIP source."""
    if _mmdb_city is not None:
        name = 'GeoLite2-City.mmdb'
        if _mmdb_asn is not None:
            name += ' + GeoLite2-ASN.mmdb'
        return name
    if _STARTS:
        return 'geoip_data.txt (text fallback)'
    return '(none)'


# ---------------------------------------------------------------------------
#  Module-level initialisation
# ---------------------------------------------------------------------------

_load_mmdb()
if _mmdb_city is None:
    _load_data_file()

logger.info('[GEOIP] active source: %s', source_name())
