"""
Xatra Data Loaders Module

This module provides functions to load geographical data from various sources
including GADM administrative boundaries, Natural Earth datasets, and Overpass API.

The loaders handle different GeoJSON formats and provide a unified interface
for accessing geographical data in the xatra system.
"""

from __future__ import annotations

import json
import os
from typing import Any, Dict, List, Optional, Tuple
from urllib import error as urllib_error
from urllib import parse as urllib_parse
from urllib import request as urllib_request

from .debug_utils import time_debug

# Get data directory from data_installer
try:
    from .data_installer import get_data_dir
    DATA_DIR = str(get_data_dir())
except ImportError:
    # Fallback to package directory if data_installer not available
    DATA_DIR = os.path.join(os.path.dirname(__file__), "data")

GADM_DIR = os.path.join(DATA_DIR, "gadm")
GADM_SIMPLIFIED_DIR = os.path.join(DATA_DIR, "gadm_simplified")
DISPUTED_DIR = os.path.join(DATA_DIR, "disputed_territories")
DISPUTED_MAPPING_JSON = os.path.join(DISPUTED_DIR, "disputed_mapping.json")
NE_RIVERS_FILE = os.path.join(DATA_DIR, "ne_10m_rivers.geojson")
OVERPASS_DIR = os.path.join(DATA_DIR, "rivers_overpass_india")
OVERPASS_API_URLS = [
    "https://overpass-api.de/api/interpreter",
    "https://overpass.kumi.systems/api/interpreter",
    "https://overpass.openstreetmap.ru/api/interpreter",
]

# Global file cache to avoid repeated disk reads
_file_cache: Dict[str, Any] = {}
_disputed_mapping_cache: Optional[Dict[str, Any]] = None
_active_simplify_tolerance: Optional[float] = None
_overpass_feature_collection_cache: Optional[Tuple[Tuple[str, ...], List[Dict[str, Any]]]] = None

OVERPASS_MERGED_FILE = os.path.join(OVERPASS_DIR, "_merged_features.json")
OVERPASS_MERGED_MANIFEST = os.path.join(OVERPASS_DIR, "_merged_features_manifest.json")

DEBUG_FILE_CACHE = False


def _normalize_simplify_tolerance(simplify_tolerance: Optional[float]) -> Optional[float]:
    """Normalize/validate simplification tolerance."""
    if simplify_tolerance is None:
        return None
    tol = float(simplify_tolerance)
    if tol <= 0:
        raise ValueError("simplify_tolerance must be > 0 when provided")
    return tol


def _format_simplify_tolerance(simplify_tolerance: float) -> str:
    """Format simplification tolerance for file-system-safe path segments."""
    tol = _normalize_simplify_tolerance(simplify_tolerance)
    # Keep representation compact but stable for CLI/config values.
    token = f"{tol:.12g}".rstrip("0").rstrip(".")
    return token.replace(".", "p")


def set_active_simplification_tolerance(simplify_tolerance: Optional[float]) -> None:
    """Set active GADM simplification tolerance used by loaders."""
    global _active_simplify_tolerance
    _active_simplify_tolerance = _normalize_simplify_tolerance(simplify_tolerance)


def get_active_simplification_tolerance() -> Optional[float]:
    """Get currently active GADM simplification tolerance."""
    return _active_simplify_tolerance


def _resolve_simplify_tolerance(simplify_tolerance: Optional[float]) -> Optional[float]:
    """Resolve explicit tolerance or fall back to active map/session tolerance."""
    if simplify_tolerance is not None:
        return _normalize_simplify_tolerance(simplify_tolerance)
    return _active_simplify_tolerance


def _get_gadm_file_path(
    iso3: str,
    level: int,
    simplify_tolerance: Optional[float] = None,
) -> str:
    """Get the GADM file path for an ISO3 and level, honoring simplification."""
    resolved = _resolve_simplify_tolerance(simplify_tolerance)
    if resolved is None:
        return os.path.join(GADM_DIR, f"gadm41_{iso3}_{level}.json")

    tol_token = _format_simplify_tolerance(resolved)
    return os.path.join(
        GADM_SIMPLIFIED_DIR,
        f"tol_{tol_token}",
        f"gadm41_{iso3}_{level}.json",
    )

@time_debug("Read JSON file")
def _read_json(path: str):
    """Read JSON file from disk with caching and optional orjson/pickle speedup.

    Args:
        path: Path to JSON file

    Returns:
        Parsed JSON data

    Raises:
        FileNotFoundError: If file doesn't exist
    """
    if path in _file_cache:
        if DEBUG_FILE_CACHE:
            print(f"DEBUG: Using memory-cached file: {path}")
        return _file_cache[path]

    if not os.path.exists(path):
        raise FileNotFoundError(f"Missing data file: {path}")

    # Try loading from binary pickle cache first (much faster than parsing JSON)
    pickle_cache_path = path + ".pickle"
    if os.path.exists(pickle_cache_path) and os.path.getmtime(pickle_cache_path) >= os.path.getmtime(path):
        try:
            import pickle
            if DEBUG_FILE_CACHE:
                print(f"DEBUG: Loading from pickle cache: {pickle_cache_path}")
            with open(pickle_cache_path, "rb") as f:
                data = pickle.load(f)
            _file_cache[path] = data
            return data
        except Exception:
            # Fall back to JSON if pickle fails
            pass

    if DEBUG_FILE_CACHE:
        print(f"DEBUG: Loading file from disk: {path}")

    # Try orjson speedup
    try:
        import orjson
        with open(path, "rb") as f:
            data = orjson.loads(f.read())
    except ImportError:
        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)

    # Cache in memory
    _file_cache[path] = data

    # Update on-disk binary cache for next time
    try:
        import pickle
        with open(pickle_cache_path, "wb") as f:
            pickle.dump(data, f, protocol=pickle.HIGHEST_PROTOCOL)
    except Exception:
        # Ignore cache write errors
        pass

    return data

@time_debug("Clear file cache")
def clear_file_cache():
    """Clear the file cache to free memory.
    
    This can be useful when working with very large datasets or when
    you want to ensure fresh data is loaded from disk.
    """
    global _file_cache
    _file_cache.clear()
    global _disputed_mapping_cache
    _disputed_mapping_cache = None
    global _overpass_feature_collection_cache
    _overpass_feature_collection_cache = None


@time_debug("Load disputed mapping")
def _load_disputed_mapping() -> Optional[Dict[str, Any]]:
    global _disputed_mapping_cache
    if _disputed_mapping_cache is not None:
        return _disputed_mapping_cache
    try:
        if os.path.exists(DISPUTED_MAPPING_JSON):
            _disputed_mapping_cache = _read_json(DISPUTED_MAPPING_JSON)
        else:
            _disputed_mapping_cache = None
    except Exception:
        _disputed_mapping_cache = None
    return _disputed_mapping_cache


def _compute_find_in_gadm_default(key: str) -> Optional[List[str]]:
    """Compute default find_in_gadm list from disputed mapping for a given GADM key.

    Selects countries that contain the gid_root at the exact level implied by the key.
    """
    parts = key.split('.')
    gid_root = parts[0]
    # Expected level equals number of dots in key
    desired_level = 0 if len(parts) == 1 else len(parts) - 1

    mapping = _load_disputed_mapping()
    if not mapping:
        return None
    entries = mapping.get(gid_root)
    if not entries:
        return None
    # Filter by level match
    countries: List[str] = []
    for e in entries:
        try:
            if int(e.get("level", -1)) == desired_level:
                c = str(e.get("file_country"))
                if c and c not in countries:
                    countries.append(c)
        except Exception:
            continue
    return countries or None


@time_debug("Load GADM data")
def gadm(
    key: str,
    find_in_gadm: Optional[List[str]] = None,
    simplify_tolerance: Optional[float] = None,
):
    """Load GADM administrative boundary as Territory.
    
    Args:
        key: GADM country code (e.g., "IND", "PAK")
        find_in_gadm: Optional list of country codes to search in if key is not found in its own file
        simplify_tolerance: Optional simplification tolerance; if None, uses active map/session setting
        
    Returns:
        Territory object
    """
    from .territory import Territory
    return Territory.from_gadm(
        key,
        find_in_gadm=find_in_gadm,
        simplify_tolerance=simplify_tolerance,
    )


def polygon(coords: List[List[float]], holes: Optional[List[List[List[float]]]] = None):
    """Create a custom polygon territory from coordinates.
    
    This allows creating arbitrary polygon shapes that can be used in
    set algebra operations (union |, difference -, intersection &) with other
    territories like GADM regions.
    
    Args:
        coords: List of [latitude, longitude] coordinate pairs defining the 
               exterior ring of the polygon. The polygon will be automatically
               closed if the first and last coordinates are not the same.
        holes: Optional list of coordinate rings defining holes in the polygon.
              Each hole is a list of [latitude, longitude] pairs.
        
    Returns:
        Territory object
        
    Example:
        >>> from xatra.loaders import polygon, gadm
        >>> 
        >>> # Simple triangle territory
        >>> triangle = polygon([[28, 77], [29, 78], [28, 79]])
        >>> 
        >>> # Use in set algebra with GADM regions
        >>> custom_region = gadm("IND.31") | polygon([[10, 75], [12, 76], [10, 77]])
        >>> india_clipped = gadm("IND") & polygon([[20, 70], [30, 70], [30, 90], [20, 90]])
        >>> 
        >>> # Rectangle covering a region
        >>> bounding_box = polygon([[25, 75], [25, 85], [15, 85], [15, 75]])
        >>> 
        >>> # Polygon with a hole (e.g., a lake)
        >>> region_with_hole = polygon(
        ...     [[20, 75], [25, 75], [25, 80], [20, 80]],
        ...     holes=[[[21, 76], [24, 76], [24, 79], [21, 79]]]
        ... )
    """
    from .territory import Territory
    return Territory.from_polygon(coords, holes)


@time_debug("Load Natural Earth feature")
def naturalearth(ne_id: str) -> Dict[str, Any]:
    """Return GeoJSON Feature for a Natural Earth feature id from a monolithic file.

    For this prototype, we support rivers from data/ne_10m_rivers.geojson by ne_id.
    
    Args:
        ne_id: Natural Earth feature ID
        
    Returns:
        GeoJSON Feature object
        
    Raises:
        FileNotFoundError: If Natural Earth file doesn't exist
        KeyError: If ne_id not found in the file
    """
    if not os.path.exists(NE_RIVERS_FILE):
        raise FileNotFoundError(f"Missing Natural Earth rivers file: {NE_RIVERS_FILE}")
    obj = _read_json(NE_RIVERS_FILE)
    if obj.get("type") != "FeatureCollection":
        raise ValueError("Expected FeatureCollection in Natural Earth rivers file")
    for feat in obj.get("features", []):
        props = feat.get("properties", {}) or {}
        if str(props.get("ne_id")) == str(ne_id):
            return feat
    raise KeyError(f"ne_id {ne_id} not found in {NE_RIVERS_FILE}")


@time_debug("Load Overpass data")
def overpass(osm_id: str, osm_type: Optional[str] = None) -> Dict[str, Any]:
    """Return a GeoJSON Feature or FeatureCollection for an Overpass river by id.

    We search files under data/rivers_overpass_india whose filename contains the id.
    If the file is already GeoJSON (has type Feature/FeatureCollection), return it.
    If it's Overpass JSON (has 'elements'), convert to a LineString/MultiLineString Feature.
    
    Args:
        osm_id: OpenStreetMap ID to search for
        osm_type: Optional restriction for object type: "relation" or "way"
        
    Returns:
        GeoJSON Feature or FeatureCollection
        
    Raises:
        FileNotFoundError: If no matching file found
        ValueError: If data format is unsupported
    """
    osm_id_str = str(osm_id).strip()
    if not osm_id_str.isdigit():
        raise ValueError(f"Invalid OSM id '{osm_id}'. Expected digits only.")
    if osm_type is not None:
        osm_type = str(osm_type).strip().lower()
        if osm_type not in ("relation", "way"):
            raise ValueError("osm_type must be one of: 'relation', 'way', or None")

    path = _find_overpass_path_for_id(osm_id_str, osm_type=osm_type)
    if path is None:
        downloaded = _download_overpass_feature(osm_id_str, osm_type=osm_type)
        path = _save_overpass_feature(downloaded, osm_id_str)

    data = _read_json(path)

    # Self-heal stale auto-cached way-only files by retrying relation fetch.
    # This fixes cases where a relation id was previously mis-resolved as way(id).
    if osm_type != "way" and _is_stale_way_cache(path, data, osm_id_str):
        try:
            relation_query = f"""
[out:json][timeout:40];
relation({osm_id_str});
out tags;
way(r);
out geom tags;
""".strip()
            relation_data = _run_overpass_query(relation_query)
            relation_feature = _overpass_elements_to_feature(
                relation_data.get("elements", []),
                osm_id_str,
                prefer_relation=True,
            )
            if relation_feature is not None and relation_feature.get("properties", {}).get("_osm_type") == "relation":
                path = _save_overpass_feature(relation_feature, osm_id_str)
                data = relation_feature
        except Exception:
            # Keep existing cached data if refresh fails.
            pass
    t = data.get("type")
    if t in ("Feature", "FeatureCollection"):
        return data
    # Likely Overpass JSON; convert to GeoJSON by stitching ways
    elements = data.get("elements")
    if not isinstance(elements, list):
        raise ValueError("Unsupported overpass data format")
    # Build node id -> (lon, lat)
    nodes: Dict[int, Tuple[float, float]] = {}
    ways: List[Dict[str, Any]] = []
    for el in elements:
        if el.get("type") == "node":
            nodes[int(el["id"])] = (float(el["lon"]), float(el["lat"]))
        elif el.get("type") == "way":
            ways.append(el)
    # Convert each way to coordinates
    line_geoms: List[List[List[float]]] = []
    for way in ways:
        coords: List[List[float]] = []
        for nd in way.get("nodes", []):
            if int(nd) in nodes:
                lon, lat = nodes[int(nd)]
                coords.append([lon, lat])
        if len(coords) >= 2:
            line_geoms.append(coords)
    if not line_geoms:
        raise ValueError("Could not extract line geometries from overpass data")
    geometry: Dict[str, Any]
    if len(line_geoms) == 1:
        geometry = {"type": "LineString", "coordinates": line_geoms[0]}
    else:
        geometry = {"type": "MultiLineString", "coordinates": line_geoms}
    return {"type": "Feature", "properties": {"source": os.path.basename(path)}, "geometry": geometry}


def _is_stale_way_cache(path: str, data: Dict[str, Any], osm_id: str) -> bool:
    name = os.path.basename(path)
    if not name.startswith("overpass_way_"):
        return False
    if data.get("type") != "Feature":
        return False
    props = data.get("properties", {}) or {}
    return str(props.get("_id", osm_id)) == osm_id


def _find_overpass_path_for_id(osm_id: str, osm_type: Optional[str] = None) -> Optional[str]:
    if not os.path.isdir(OVERPASS_DIR):
        return None

    exact_candidates: List[str] = []
    loose_candidates: List[str] = []
    token = str(osm_id)

    for name in os.listdir(OVERPASS_DIR):
        if not name.endswith(".json"):
            continue
        if osm_type is not None and osm_type not in name.lower():
            continue
        full = os.path.join(OVERPASS_DIR, name)
        stem, _ = os.path.splitext(name)
        if stem.endswith(f"_{token}") or stem == token:
            exact_candidates.append(full)
        elif token in name:
            loose_candidates.append(full)

    candidates = sorted(exact_candidates) if exact_candidates else sorted(loose_candidates)
    if candidates and osm_type is None:
        # Prefer relation files over way files when both exist for the same id.
        candidates = sorted(
            candidates,
            key=lambda p: (
                0 if "relation" in os.path.basename(p) else 1,
                os.path.basename(p),
            ),
        )
    return candidates[0] if candidates else None


def _save_overpass_feature(feature: Dict[str, Any], osm_id: str) -> str:
    os.makedirs(OVERPASS_DIR, exist_ok=True)
    props = feature.get("properties", {}) or {}
    osm_type = str(props.get("_osm_type", "unknown"))
    filename = f"overpass_{osm_type}_{osm_id}.json"
    path = os.path.join(OVERPASS_DIR, filename)

    with open(path, "w", encoding="utf-8") as f:
        json.dump(feature, f, ensure_ascii=False)

    _file_cache[path] = feature

    # Invalidate merged overpass cache artifacts so next load refreshes them.
    global _overpass_feature_collection_cache
    _overpass_feature_collection_cache = None
    _file_cache.pop(OVERPASS_MERGED_FILE, None)
    _file_cache.pop(OVERPASS_MERGED_MANIFEST, None)
    for stale_path in (OVERPASS_MERGED_FILE, OVERPASS_MERGED_MANIFEST):
        try:
            if os.path.exists(stale_path):
                os.remove(stale_path)
        except Exception:
            pass

    return path


def _list_overpass_source_files() -> List[str]:
    if not os.path.isdir(OVERPASS_DIR):
        return []
    source_files: List[str] = []
    for filename in os.listdir(OVERPASS_DIR):
        if not filename.endswith(".json"):
            continue
        if filename == os.path.basename(OVERPASS_MERGED_FILE):
            continue
        source_files.append(filename)
    return sorted(source_files)


def _load_overpass_from_file(filename: str) -> List[Dict[str, Any]]:
    filepath = os.path.join(OVERPASS_DIR, filename)
    loaded_features: List[Dict[str, Any]] = []
    data = _read_json(filepath)

    if data.get("type") == "Feature":
        data.setdefault("properties", {})
        data["properties"]["_source"] = "overpass"
        data["properties"]["_filename"] = filename
        loaded_features.append(data)
    elif data.get("type") == "FeatureCollection":
        for feature in data.get("features", []):
            feature.setdefault("properties", {})
            feature["properties"]["_source"] = "overpass"
            feature["properties"]["_filename"] = filename
            loaded_features.append(feature)
    elif isinstance(data.get("elements"), list):
        guessed_id = "".join(ch for ch in filename if ch.isdigit())
        if guessed_id:
            converted = _overpass_elements_to_feature(
                data.get("elements", []),
                guessed_id,
                prefer_relation=True,
            )
            if converted is not None:
                converted.setdefault("properties", {})
                converted["properties"]["_source"] = "overpass"
                converted["properties"]["_filename"] = filename
                loaded_features.append(converted)
    return loaded_features


@time_debug("Load Overpass feature collection")
def load_all_overpass_features() -> List[Dict[str, Any]]:
    global _overpass_feature_collection_cache

    source_files = _list_overpass_source_files()
    if not source_files:
        _overpass_feature_collection_cache = (tuple(), [])
        return []

    source_signature = tuple(source_files)
    if _overpass_feature_collection_cache is not None and _overpass_feature_collection_cache[0] == source_signature:
        return _overpass_feature_collection_cache[1]

    can_use_merged_cache = os.path.exists(OVERPASS_MERGED_FILE) and os.path.exists(OVERPASS_MERGED_MANIFEST)
    if can_use_merged_cache:
        try:
            manifest = _read_json(OVERPASS_MERGED_MANIFEST)
            manifest_files = manifest.get("files", []) if isinstance(manifest, dict) else []
            if manifest_files == source_files:
                merged_obj = _read_json(OVERPASS_MERGED_FILE)
                features = merged_obj.get("features", []) if isinstance(merged_obj, dict) else []
                if isinstance(features, list):
                    _overpass_feature_collection_cache = (source_signature, features)
                    return features
        except Exception:
            pass

    merged_features: List[Dict[str, Any]] = []
    for filename in source_files:
        try:
            merged_features.extend(_load_overpass_from_file(filename))
        except Exception as e:
            print(f"Warning: Could not load overpass file {filename}: {e}")

    try:
        os.makedirs(OVERPASS_DIR, exist_ok=True)
        merged_obj = {"type": "FeatureCollection", "features": merged_features}
        manifest_obj = {"files": source_files}

        with open(OVERPASS_MERGED_FILE, "w", encoding="utf-8") as f:
            json.dump(merged_obj, f, ensure_ascii=False)
        with open(OVERPASS_MERGED_MANIFEST, "w", encoding="utf-8") as f:
            json.dump(manifest_obj, f, ensure_ascii=False)

        _file_cache[OVERPASS_MERGED_FILE] = merged_obj
        _file_cache[OVERPASS_MERGED_MANIFEST] = manifest_obj
    except Exception:
        pass

    _overpass_feature_collection_cache = (source_signature, merged_features)
    return merged_features


def _download_overpass_feature(osm_id: str, osm_type: Optional[str] = None) -> Dict[str, Any]:
    if osm_type in (None, "relation"):
        relation_query = f"""
[out:json][timeout:40];
relation({osm_id});
out tags;
way(r);
out geom tags;
""".strip()

        relation_data = _run_overpass_query(relation_query)
        relation_feature = _overpass_elements_to_feature(
            relation_data.get("elements", []),
            osm_id,
            prefer_relation=True,
        )
        if relation_feature is not None:
            relation_is_relation = relation_feature.get("properties", {}).get("_osm_type") == "relation"
            if relation_is_relation or osm_type is None:
                return relation_feature

    if osm_type in (None, "way"):
        way_query = f"""
[out:json][timeout:40];
way({osm_id});
out geom tags;
""".strip()

        way_data = _run_overpass_query(way_query)
        way_feature = _overpass_elements_to_feature(
            way_data.get("elements", []),
            osm_id,
            prefer_relation=False,
        )
        if way_feature is not None:
            way_is_way = way_feature.get("properties", {}).get("_osm_type") == "way"
            if way_is_way or osm_type is None:
                return way_feature

    if osm_type is None:
        raise FileNotFoundError(f"OSM id '{osm_id}' not found as relation or way via Overpass API")
    raise FileNotFoundError(f"OSM id '{osm_id}' not found as {osm_type} via Overpass API")


def _run_overpass_query(query: str) -> Dict[str, Any]:
    payload = urllib_parse.urlencode({"data": query}).encode("utf-8")
    errors: List[str] = []

    for endpoint in OVERPASS_API_URLS:
        req = urllib_request.Request(endpoint, data=payload, method="POST")
        req.add_header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")

        try:
            with urllib_request.urlopen(req, timeout=60) as resp:
                body = resp.read().decode("utf-8")
            try:
                return json.loads(body)
            except json.JSONDecodeError:
                errors.append(f"{endpoint}: invalid JSON response")
                continue
        except urllib_error.HTTPError as exc:
            error_body = ""
            try:
                error_body = exc.read().decode("utf-8")
            except Exception:
                error_body = ""
            errors.append(f"{endpoint}: HTTP {exc.code} {error_body[:120]}")
        except urllib_error.URLError as exc:
            errors.append(f"{endpoint}: {exc}")

    raise ConnectionError("All Overpass API endpoints failed: " + " | ".join(errors))


def _overpass_elements_to_feature(
    elements: List[Dict[str, Any]],
    osm_id: str,
    prefer_relation: bool,
) -> Optional[Dict[str, Any]]:
    if not isinstance(elements, list) or not elements:
        return None

    node_coords: Dict[int, Tuple[float, float]] = {}
    ways_by_id: Dict[int, Dict[str, Any]] = {}
    relations_by_id: Dict[int, Dict[str, Any]] = {}

    for el in elements:
        el_type = el.get("type")
        if el_type == "node":
            try:
                node_coords[int(el["id"])] = (float(el["lon"]), float(el["lat"]))
            except Exception:
                continue
        elif el_type == "way":
            try:
                ways_by_id[int(el["id"])] = el
            except Exception:
                continue
        elif el_type == "relation":
            try:
                relations_by_id[int(el["id"])] = el
            except Exception:
                continue

    target_id = int(osm_id)
    target_relation = relations_by_id.get(target_id)
    target_way = ways_by_id.get(target_id)

    selected_way_ids: List[int] = []
    tags: Dict[str, Any] = {}
    osm_type = "relation" if prefer_relation else "way"

    if prefer_relation and target_relation is not None:
        tags = target_relation.get("tags", {}) or {}
        for member in target_relation.get("members", []) or []:
            if member.get("type") == "way" and member.get("ref") is not None:
                try:
                    selected_way_ids.append(int(member["ref"]))
                except Exception:
                    continue
        # Some Overpass responses expose relation tags but return ways separately
        # with empty members; use all returned ways in that case.
        if not selected_way_ids:
            selected_way_ids = sorted(ways_by_id.keys())
        osm_type = "relation"
    elif target_way is not None:
        tags = target_way.get("tags", {}) or {}
        selected_way_ids.append(target_id)
        osm_type = "way"
    elif target_relation is not None:
        tags = target_relation.get("tags", {}) or {}
        for member in target_relation.get("members", []) or []:
            if member.get("type") == "way" and member.get("ref") is not None:
                try:
                    selected_way_ids.append(int(member["ref"]))
                except Exception:
                    continue
        osm_type = "relation"
    else:
        selected_way_ids = sorted(ways_by_id.keys())
        if not selected_way_ids:
            return None
        first_way = ways_by_id[selected_way_ids[0]]
        tags = first_way.get("tags", {}) or {}
        osm_type = "way"

    line_geoms: List[List[List[float]]] = []
    for way_id in selected_way_ids:
        way = ways_by_id.get(way_id)
        if not way:
            continue

        coords: List[List[float]] = []
        if isinstance(way.get("geometry"), list):
            for item in way["geometry"]:
                try:
                    coords.append([float(item["lon"]), float(item["lat"])])
                except Exception:
                    continue
        else:
            for node_id in way.get("nodes", []) or []:
                try:
                    nid = int(node_id)
                except Exception:
                    continue
                if nid in node_coords:
                    lon, lat = node_coords[nid]
                    coords.append([lon, lat])

        if len(coords) >= 2:
            line_geoms.append(coords)

    if not line_geoms:
        return None

    if len(line_geoms) == 1:
        geometry: Dict[str, Any] = {"type": "LineString", "coordinates": line_geoms[0]}
    else:
        geometry = {"type": "MultiLineString", "coordinates": line_geoms}

    properties: Dict[str, Any] = {
        "_source": "overpass",
        "_id": str(osm_id),
        "_osm_type": osm_type,
        "name": tags.get("name"),
        "name:en": tags.get("name:en"),
    }
    cleaned_properties = {k: v for k, v in properties.items() if v is not None}

    return {"type": "Feature", "properties": cleaned_properties, "geometry": geometry}


@time_debug("Load GADM-like data")
def load_gadm_like(
    key: str,
    find_in_gadm: Optional[List[str]] = None,
    simplify_tolerance: Optional[float] = None,
) -> Dict[str, Any]:
    """Load GADM geometry by key like 'IND' or 'IND.31' or deeper.

    - If key has no dot: open gadm41_<ISO>_0.json and return its FeatureCollection
      as a single unified geometry FeatureCollection (caller will union).
    - If key has dots: level = number of dots, open gadm41_<ISO>_<level>.json and
      filter features whose GID_<level> startswith the prefix (without any trailing underscore).
    Returns a FeatureCollection containing matching features.
    
    Args:
        key: GADM key (e.g., "IND", "IND.31", "IND.31.1")
        find_in_gadm: Optional list of country codes to search in if key is not found in its own file
        simplify_tolerance: Optional simplification tolerance; if None, uses active map/session setting
        
    Returns:
        GeoJSON FeatureCollection
        
    Raises:
        ValueError: If key format is invalid
        FileNotFoundError: If GADM file doesn't exist
    """
    if not key or len(key) < 3:
        raise ValueError("Invalid GADM key")
    parts = key.split('.')
    iso3 = parts[0]
    level = 0 if len(parts) == 1 else len(parts) - 1
    
    # Try to load from the key's own file first
    path = _get_gadm_file_path(iso3, level, simplify_tolerance=simplify_tolerance)
    if os.path.exists(path):
        fc = _read_json(path)
        if fc.get("type") != "FeatureCollection":
            raise ValueError(f"Expected FeatureCollection in {path}")
        if level == 0:
            return fc
        prefix = '.'.join(parts[:level+1])
        gid_key = f"GID_{level}"
        features = []
        for feat in fc.get("features", []):
            props = feat.get("properties", {}) or {}
            gid = str(props.get(gid_key, ""))
            # Use exact prefix matching with boundary check
            if gid.startswith(prefix) and (len(gid) == len(prefix) or gid[len(prefix)] in ['.', '_']):
                features.append(feat)
        return {"type": "FeatureCollection", "features": features}
    
    # If not found, search in provided or computed find_in_gadm
    if find_in_gadm is None:
        find_in_gadm = _compute_find_in_gadm_default(key)
    if find_in_gadm:
        for country_code in find_in_gadm:
            search_path = _get_gadm_file_path(country_code, level, simplify_tolerance=simplify_tolerance)
            if os.path.exists(search_path):
                fc = _read_json(search_path)
                if fc.get("type") != "FeatureCollection":
                    continue
                if level == 0:
                    # For level 0, return all features from the country file
                    return fc
                prefix = '.'.join(parts[:level+1])
                gid_key = f"GID_{level}"
                features = []
                for feat in fc.get("features", []):
                    props = feat.get("properties", {}) or {}
                    gid = str(props.get(gid_key, ""))
                    # Use exact prefix matching with boundary check
                    if gid.startswith(prefix) and (len(gid) == len(prefix) or gid[len(prefix)] in ['.', '_']):
                        features.append(feat)
                if features:  # If we found features, return them
                    return {"type": "FeatureCollection", "features": features}
    
    # If still not found, raise the original error
    resolved_tol = _resolve_simplify_tolerance(simplify_tolerance)
    if resolved_tol is not None:
        raise FileNotFoundError(
            f"GADM file not found: {path}. "
            f"Simplification tolerance {resolved_tol} is active; "
            f"generate simplified files first (xatra-simplify-data)."
        )
    raise FileNotFoundError(f"GADM file not found: {path}")


@time_debug("Load Natural Earth-like data")
def load_naturalearth_like(ne_id: str) -> Dict[str, Any]:
    """Load Natural Earth feature as GeoJSON Feature.
    
    For compatibility with Territory.from_naturalearth, return Feature.
    
    Args:
        ne_id: Natural Earth feature ID
        
    Returns:
        GeoJSON Feature object
    """
    return naturalearth(ne_id)
