"""HERE Technologies routing/geocoding — an intelligence and resilience
layer added ON TOP of the existing free OSM/OSRM/Nominatim stack, never a
replacement for it. Nothing here changes map tiles, Nominatim search, or
Overpass — those stay exactly as they were. HERE is only reached for:

1. Truck-aware B2B routing — the public OSRM server has no truck profile
   at all (it always routes as a car), so a B2B delivery's route/distance
   was never actually respecting truck weight limits before. HERE's
   Routing API v8 does.
2. An automatic fallback when the free public OSRM server fails, times
   out, or rate-limits (a real risk since it's a shared demo instance, not
   a production SLA) — the picker/customer gets a real route instead of
   "no route available."
3. Live-traffic-aware duration for car routes, which the free OSRM demo
   doesn't provide at all (durations from it are free-flow estimates).
4. Live traffic-flow map tiles and current weather, shown as optional
   overlays on top of the existing OSM base map — never replacing it.
5. Auto-location accuracy — HERE is now tried FIRST (not as a fallback)
   for reverse-geocoding a GPS/dragged-pin position (market.geo::
   reverse_geocode/reverse_geocode_components), since Nominatim's address
   naming for Tanzania is inconsistent enough to mislabel a real spot.
   Also the source for region_search()'s free-text region lookup — a
   region is resolved from HERE's structured address.state field rather
   than an admin typing region names into existence.

If HERE_API_KEY isn't set, every function below returns None immediately
and the app behaves exactly as it did before this module existed."""

import requests
from django.conf import settings

HERE_ROUTING_URL = 'https://router.hereapi.com/v8/routes'
HERE_GEOCODE_URL = 'https://geocode.search.hereapi.com/v1/geocode'
HERE_REVGEOCODE_URL = 'https://geocode.search.hereapi.com/v1/revgeocode'
HERE_TRAFFIC_TILE_URL = 'https://traffic.maps.hereapi.com/v3/flow/mc/{z}/{x}/{y}/png8'
HERE_WEATHER_URL = 'https://weather.hereapi.com/v3/report'

_TIMEOUT = 8

# HERE's "Flexible Polyline" decoding table — see
# https://github.com/heremaps/flexible-polyline for the spec this mirrors.
_DECODING_TABLE = [
    62, -1, -1, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, -1,
    -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
    21, 22, 23, 24, 25, -1, -1, -1, -1, 63, -1, 26, 27, 28, 29, 30, 31, 32, 33,
    34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51,
]


def _api_key():
    return getattr(settings, 'HERE_API_KEY', '') or None


def _decode_flexible_polyline(encoded):
    """Decodes a HERE flexible-polyline string into a list of [lat, lng]
    pairs. Returns None on any malformed input rather than raising, since
    this only ever backs a best-effort fallback."""
    def decode_char(c):
        idx = ord(c) - 45
        if idx < 0 or idx >= len(_DECODING_TABLE):
            return -1
        return _DECODING_TABLE[idx]

    def decode_unsigned_values(chars):
        result = []
        shift = 0
        value = 0
        for c in chars:
            d = decode_char(c)
            if d < 0:
                raise ValueError('invalid flexible-polyline character')
            value |= (d & 0x1F) << shift
            if d & 0x20:
                shift += 5
            else:
                result.append(value)
                value = 0
                shift = 0
        return result

    def to_signed(value):
        if value & 1:
            value = ~value
        return value >> 1

    try:
        values = decode_unsigned_values(encoded)
    except ValueError:
        return None
    if len(values) < 2:
        return None

    header_version = values[0]
    if header_version != 1:
        return None
    header = values[1]
    precision = header & 0xF
    third_dim = (header >> 4) & 0x7
    third_dim_precision = (header >> 7) & 0xF

    factor = 10 ** precision
    third_factor = 10 ** third_dim_precision

    idx = 2
    lat = 0
    lng = 0
    z = 0
    coords = []
    n = len(values)
    try:
        while idx < n:
            lat += to_signed(values[idx]); idx += 1
            lng += to_signed(values[idx]); idx += 1
            point = [lat / factor, lng / factor]
            if third_dim:
                z += to_signed(values[idx]); idx += 1
                point.append(z / third_factor)
            coords.append(point)
    except IndexError:
        return None
    return coords


def _describe_here_action(action):
    """Mirrors market.geo._describe_step's shape so HERE-sourced turn-by-
    turn instructions read identically to OSRM-sourced ones wherever
    they're shown — callers never need to know which engine answered."""
    instruction = action.get('instruction')
    if instruction:
        return instruction
    return str(action.get('action', 'continue')).replace('_', ' ').capitalize()


def here_route(from_lat, from_lng, to_lat, to_lng, transport_mode='car', truck_weight_kg=None, steps=False):
    """Same return shape as market.geo.get_route():
    {'distance_km', 'duration_min', 'geometry', 'steps'} or None.
    'geometry' is a GeoJSON-style LineString dict so callers (route-surface
    classification, map drawing) never need to branch on which engine
    produced it."""
    api_key = _api_key()
    if not api_key:
        return None

    return_fields = 'polyline,summary'
    if steps:
        return_fields += ',actions,instructions'

    params = {
        'transportMode': transport_mode,
        'origin': f'{from_lat},{from_lng}',
        'destination': f'{to_lat},{to_lng}',
        'return': return_fields,
        'apiKey': api_key,
    }
    if transport_mode == 'car':
        params['routingMode'] = 'fast'
    if transport_mode == 'truck' and truck_weight_kg:
        params['truck[grossWeight]'] = int(truck_weight_kg)

    try:
        resp = requests.get(HERE_ROUTING_URL, params=params, timeout=_TIMEOUT)
        resp.raise_for_status()
        data = resp.json()
    except (requests.RequestException, ValueError):
        return None

    routes = data.get('routes')
    if not routes or not routes[0].get('sections'):
        return None

    section = routes[0]['sections'][0]
    summary = section.get('summary', {})
    coords = _decode_flexible_polyline(section.get('polyline', ''))
    if not coords:
        return None

    result = {
        'distance_km': round(summary.get('length', 0) / 1000, 2),
        'duration_min': round(summary.get('duration', 0) / 60, 1),
        'geometry': {
            'type': 'LineString',
            # HERE decodes to [lat, lng] pairs; GeoJSON (and OSRM, which
            # every existing caller already expects) uses [lng, lat].
            'coordinates': [[c[1], c[0]] for c in coords],
        },
    }
    if steps:
        result['steps'] = [
            _describe_here_action(action)
            for section_actions in [section.get('actions', [])]
            for action in section_actions
        ]
    return result


def here_geocode(query, limit=5):
    """Same return shape as market.geo.geocode(): a list of
    {'label', 'lat', 'lng'} dicts. Only ever called as a fallback tier
    after Nominatim comes up empty."""
    api_key = _api_key()
    if not api_key:
        return []

    try:
        resp = requests.get(
            HERE_GEOCODE_URL,
            params={'q': query, 'in': 'countryCode:TZA', 'limit': limit, 'apiKey': api_key},
            timeout=_TIMEOUT,
        )
        resp.raise_for_status()
        data = resp.json()
    except (requests.RequestException, ValueError):
        return []

    return [
        {
            'label': item.get('title', query),
            'lat': item['position']['lat'],
            'lng': item['position']['lng'],
            # HERE's structured address always includes the admin-level
            # 'state' field when it can be resolved — for Tanzania that's
            # the region (mkoa) a place sits in. Used by market.views
            # ::region_search to let a region field be searched/resolved
            # instead of admin-typed. Empty string (not omitted) when HERE
            # itself has nothing, so callers never need a .get() default.
            'region': (item.get('address') or {}).get('state') or '',
        }
        for item in data.get('items', [])
        if item.get('position')
    ]


# resultType values HERE considers an actual address (a real street position),
# as opposed to 'place' (the nearest tagged business/POI), 'locality',
# 'administrativeArea', etc. Reverse-geocoding a GPS/dragged-pin position
# wants "what address is this," not "what business happens to sit closest to
# this pin" -- the latter is what item[0] often is (e.g. a fuel station or
# shop metres away), which is a real, confirmed mislabeling risk, not a
# hypothetical one.
_HERE_ADDRESS_RESULT_TYPES = {'houseNumber', 'street', 'intersection'}


def here_reverse_geocode_full(lat, lng):
    """Structured reverse-geocode result for a lat/lng point:
    {'address', 'city', 'mtaa', 'region'} or None if HERE has nothing (no
    API key, request failure, or no match). 'region' is HERE's address.state
    field — Tanzania's region/mkoa level. here_reverse_geocode() below stays
    a thin single-string wrapper around this for existing callers.

    Requests several candidates (not just the single closest) and prefers
    the closest genuine street-level match over a same-distance POI/business
    match -- confirmed via real testing that HERE frequently resolves a GPS
    point to the nearest tagged business (e.g. 'TotalEnergies', a fuel
    station) at the exact same distance as a perfectly good street result,
    and the business name is a poor, misleading label for "where is this
    pin" (a customer's real position is never actually AT that business).
    Falls back to the single closest result of any type if nothing
    street-level is nearby at all, rather than returning nothing."""
    api_key = _api_key()
    if not api_key:
        return None

    try:
        resp = requests.get(
            HERE_REVGEOCODE_URL,
            params={'at': f'{lat},{lng}', 'limit': 5, 'apiKey': api_key},
            timeout=_TIMEOUT,
        )
        resp.raise_for_status()
        data = resp.json()
    except (requests.RequestException, ValueError):
        return None

    items = data.get('items') or []
    if not items:
        return None
    item = next((i for i in items if i.get('resultType') in _HERE_ADDRESS_RESULT_TYPES), None)
    address = (item or items[0]).get('address') or {}
    if item:
        # A genuine street/houseNumber/intersection match -- its own label
        # is already a clean address, not a business name.
        label = address.get('label') or item.get('title')
    else:
        # No street-level result anywhere in the candidates (confirmed via
        # real testing: a dense shopping-street point in Mbeya returned 20
        # nearby businesses and not one street/houseNumber match) -- build
        # from district/city/state only, deliberately skipping this closest
        # place's own business name rather than showing e.g. "Sam Fashion
        # Shop, Mbeya, Tanzania" as if that shop were the customer's address.
        parts = [p for p in [address.get('district'), address.get('city'), address.get('state')] if p]
        # de-duplicate while preserving order (district/city/state often repeat at this granularity)
        seen = set()
        parts = [p for p in parts if not (p in seen or seen.add(p))]
        if parts:
            label = ', '.join(parts + [address.get('countryName') or 'Tanzania'])
        else:
            # No district/city/state at all either (very rare) -- last
            # resort is still better than nothing, even if it's the
            # closest business's own name.
            label = address.get('label') or items[0].get('title')
    if not label:
        return None
    return {
        'address': label,
        'city': address.get('city') or address.get('district') or '',
        'mtaa': address.get('subdistrict') or address.get('district') or '',
        'region': address.get('state') or '',
        # True when a genuine street/houseNumber/intersection candidate was
        # found nearby; False when nothing but businesses were nearby and
        # 'address' is only a coarse district/city fallback. market/geo.py
        # uses this to decide whether it's worth also asking Nominatim for
        # a real street-level match before settling for the coarse one --
        # OSM's road coverage and HERE's don't always agree on which roads
        # are mapped, so checking both specifically in this low-confidence
        # case gets real extra accuracy without slowing down the common,
        # already-precise case with a second network call it doesn't need.
        'precise': bool(item),
    }


def here_reverse_geocode(lat, lng):
    """A single address label for a lat/lng point, or None. Only ever
    called as a fallback tier after Nominatim's own reverse-geocode fails
    or returns nothing."""
    result = here_reverse_geocode_full(lat, lng)
    return result['address'] if result else None


def here_traffic_tile(z, x, y):
    """Raw bytes of one HERE traffic-flow raster tile, for overlaying live
    congestion colors on top of the existing OSM base map. Returns
    (content_bytes, content_type) or None — callers should render nothing
    (not a broken image) when this is None, since a picker/customer without
    a HERE-backed traffic layer should just see the plain OSM map they
    already had, not an error."""
    api_key = _api_key()
    if not api_key:
        return None

    url = HERE_TRAFFIC_TILE_URL.format(z=z, x=x, y=y)
    try:
        resp = requests.get(url, params={'apiKey': api_key}, timeout=6)
        resp.raise_for_status()
    except requests.RequestException:
        return None
    return resp.content, resp.headers.get('content-type', 'image/png')


def here_weather(lat, lng):
    """Current conditions near a lat/lng, or None. Used for the small
    weather badge shown on delivery/tracking maps — genuinely useful
    context for a picker deciding how to pack/handle produce, not just
    decoration."""
    api_key = _api_key()
    if not api_key:
        return None

    try:
        resp = requests.get(
            HERE_WEATHER_URL,
            params={'products': 'observation', 'location': f'{lat},{lng}', 'apiKey': api_key},
            timeout=_TIMEOUT,
        )
        resp.raise_for_status()
        data = resp.json()
    except (requests.RequestException, ValueError):
        return None

    places = data.get('places') or []
    if not places:
        return None
    observations = places[0].get('observations') or []
    if not observations:
        return None
    obs = observations[0]
    try:
        return {
            'description': obs.get('description'),
            'sky_desc': obs.get('skyDesc'),
            'temperature_c': round(float(obs['temperature'])) if obs.get('temperature') is not None else None,
            'humidity_pct': obs.get('humidity'),
            'wind_speed_kmh': obs.get('windSpeed'),
        }
    except (TypeError, ValueError):
        return None
