import datetime
import hashlib
import math

import requests
from django.core.cache import cache

# Dar es Salaam, Tanzania
DAR_ES_SALAAM_CENTER = {'lat': -6.7924, 'lng': 39.2083}
# Rough bounding box used to sanity-check pins actually land in the service area
DAR_ES_SALAAM_BOUNDS = {'south': -7.15, 'north': -6.60, 'west': 39.00, 'east': 39.55}

NOMINATIM_URL = 'https://nominatim.openstreetmap.org/search'
NOMINATIM_REVERSE_URL = 'https://nominatim.openstreetmap.org/reverse'
OSRM_URL = 'https://router.project-osrm.org/route/v1/driving'
OVERPASS_URL = 'https://overpass-api.de/api/interpreter'

# Required by Nominatim's usage policy (identifies the app making requests)
_HEADERS = {'User-Agent': 'PickerMarketApp/1.0 (contact: contact@pickkermarket.com)'}

UNPAVED_SURFACES = {'unpaved', 'dirt', 'gravel', 'ground', 'sand', 'grass', 'mud', 'earth', 'compacted', 'fine_gravel'}
PAVED_SURFACES = {'paved', 'asphalt', 'concrete', 'paving_stones', 'sett', 'cobblestone', 'concrete:plates'}


def haversine_km(lat1, lng1, lat2, lng2):
    radius = 6371.0
    phi1, phi2 = math.radians(lat1), math.radians(lat2)
    d_phi = math.radians(lat2 - lat1)
    d_lambda = math.radians(lng2 - lng1)
    a = math.sin(d_phi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(d_lambda / 2) ** 2
    return radius * 2 * math.asin(math.sqrt(a))


def geocode(query, limit=5):
    """Search for a place name/address, biased to Tanzania. Returns a list of
    {'label', 'lat', 'lng'} dicts. Nominatim (free, OSM-backed) stays the
    primary source; HERE Geocoding only ever gets asked when Nominatim
    comes back empty, since HERE tends to have better real-world address
    coverage for Tanzania than OSM data alone — a "smarter" second opinion,
    not a replacement."""
    try:
        resp = requests.get(
            NOMINATIM_URL,
            params={
                'q': query,
                'format': 'json',
                'countrycodes': 'tz',
                'limit': limit,
            },
            headers=_HEADERS,
            timeout=6,
        )
        resp.raise_for_status()
        results = resp.json()
    except (requests.RequestException, ValueError):
        results = []

    if results:
        return [
            {'label': r['display_name'], 'lat': float(r['lat']), 'lng': float(r['lon'])}
            for r in results
        ]

    from .here_api import here_geocode
    return here_geocode(query, limit=limit)


def _nominatim_label(data):
    """Nominatim's own display_name prepends whatever named feature (a
    shop, office, fuel station...) happens to sit nearest the point -- e.g.
    'TANESCO, Morogoro Road, ...' for a pin that's really just on Morogoro
    Road, confirmed via real testing against real Dar/Arusha/Mbeya points.
    A customer's GPS position is essentially never actually AT that
    business, just near it, so that's a misleading address label. When a
    real road name is present in the structured address (almost always,
    for anywhere with OSM road coverage), build the label from road +
    neighbourhood + city instead and skip the leading POI/business tag
    entirely -- only falls back to the raw display_name when no road is
    known at all (e.g. a genuinely remote point)."""
    address = data.get('address') or {}
    road = address.get('road')
    if not road:
        return data.get('display_name')
    parts = [road]
    sub = address.get('suburb') or address.get('neighbourhood') or address.get('quarter') or address.get('city_district')
    if sub and sub != road:
        parts.append(sub)
    city = address.get('city') or address.get('town') or address.get('village')
    if city and city not in parts:
        parts.append(city)
    parts.append(address.get('country') or 'Tanzania')
    return ', '.join(parts)


def _nominatim_reverse_raw(lat, lng):
    """The raw parsed Nominatim /reverse response for a point, or None if
    the request failed or found nothing — shared by both functions below
    so a point that needs cross-checking only ever costs one Nominatim
    call, not two."""
    try:
        resp = requests.get(
            NOMINATIM_REVERSE_URL,
            params={'format': 'json', 'lat': lat, 'lon': lng, 'addressdetails': 1},
            headers=_HEADERS,
            timeout=6,
        )
        resp.raise_for_status()
        data = resp.json()
        return data if data.get('display_name') else None
    except (requests.RequestException, ValueError):
        return None


def _nominatim_components(data):
    address = data.get('address') or {}
    return {
        'address': _nominatim_label(data),
        'city': address.get('city') or address.get('town') or address.get('village') or address.get('county') or '',
        'mtaa': address.get('suburb') or address.get('neighbourhood') or address.get('quarter') or address.get('city_district') or '',
        'region': address.get('state') or '',
    }


def reverse_geocode(lat, lng):
    """Address label for a lat/lng point — see reverse_geocode_components()
    for the full algorithm; this is a thin single-string wrapper around it
    for callers that only want the address line."""
    result = reverse_geocode_components(lat, lng)
    return result['address'] if result else None


def reverse_geocode_components(lat, lng):
    """Structured {'address', 'city', 'mtaa', 'region'} for a lat/lng point,
    or None if nothing could be resolved at all. Used to autofill separate
    "City"/"Mtaa"/region fields (e.g. the customer signup's "use my
    location" button, and market.views::region_search's resolve step) as
    well as the single-line address (reverse_geocode() above).

    HERE is queried first — real testing found its region/city field
    hierarchy more internally consistent for Tanzania addresses than
    Nominatim's. When HERE finds a genuine street-level match (see
    here_api.py's resultType filtering), that's used directly, with no
    second call — the common case stays fast.

    When HERE has nothing better than a coarse district/city fallback (no
    street tagged anywhere near the point in HERE's data) or has no result
    at all, Nominatim is checked too, specifically because OSM's road
    coverage and HERE's don't always agree on which roads are mapped in a
    given area — a real accuracy gain in exactly the case that needs it,
    without slowing down the already-precise case with a second network
    call it doesn't need. Whichever of the two actually has a real road
    name wins; if neither does, HERE's coarser result is preferred (its
    district/city breakdown is still the more consistent one), falling
    back to Nominatim's raw result only if HERE had nothing at all."""
    from .here_api import here_reverse_geocode_full
    here_result = here_reverse_geocode_full(lat, lng)
    here_precise = bool(here_result and here_result.get('precise'))
    if here_result is not None:
        here_result.pop('precise', None)
    if here_precise:
        return here_result

    nominatim_data = _nominatim_reverse_raw(lat, lng)
    if nominatim_data and (nominatim_data.get('address') or {}).get('road'):
        nominatim_result = _nominatim_components(nominatim_data)
        if here_result:
            # Keep HERE's own city/mtaa/region breakdown (more consistent
            # per real testing) but use Nominatim's road-based address line.
            here_result['address'] = nominatim_result['address']
            return here_result
        return nominatim_result

    if here_result:
        return here_result
    if nominatim_data:
        return _nominatim_components(nominatim_data)
    return None


def _describe_step(step):
    """Turns one OSRM maneuver into a short human-readable instruction —
    just enough for a picker glancing at their phone between stops, not a
    full turn-by-turn nav replacement."""
    maneuver = step.get('maneuver', {})
    m_type = maneuver.get('type', 'continue')
    modifier = maneuver.get('modifier', '')
    name = step.get('name') or ''
    distance_m = step.get('distance', 0)

    if m_type == 'depart':
        text = f'Head out{f" onto {name}" if name else ""}'
    elif m_type == 'arrive':
        text = 'Arrive at destination'
    elif m_type in ('turn', 'end of road', 'fork', 'merge'):
        direction = modifier.replace('_', ' ') if modifier else 'ahead'
        text = f'Turn {direction}{f" onto {name}" if name else ""}'
    elif m_type == 'roundabout':
        text = f'At the roundabout, take the exit{f" onto {name}" if name else ""}'
    else:
        text = f'Continue{f" onto {name}" if name else ""}'

    if distance_m and distance_m >= 30:
        text += f' ({round(distance_m)} m)' if distance_m < 1000 else f' ({round(distance_m / 1000, 1)} km)'
    return text


def _get_osrm_route(from_lat, from_lng, to_lat, to_lng, steps=False):
    """The free public OSRM demo server — always a plain car/driving profile,
    no traffic awareness, no truck restrictions. Kept as the primary,
    unchanged path for ordinary car routing."""
    url = f'{OSRM_URL}/{from_lng},{from_lat};{to_lng},{to_lat}'
    try:
        resp = requests.get(
            url,
            params={'overview': 'full', 'geometries': 'geojson', 'steps': 'true' if steps else 'false'},
            timeout=8,
        )
        resp.raise_for_status()
        data = resp.json()
    except (requests.RequestException, ValueError):
        return None

    if data.get('code') != 'Ok' or not data.get('routes'):
        return None

    route = data['routes'][0]
    result = {
        'distance_km': round(route['distance'] / 1000, 2),
        'duration_min': round(route['duration'] / 60, 1),
        'geometry': route['geometry'],
    }
    if steps:
        result['steps'] = [
            _describe_step(step)
            for leg in route.get('legs', [])
            for step in leg.get('steps', [])
        ]
    return result


def get_route(from_lat, from_lng, to_lat, to_lng, steps=False, transport_mode='car', truck_weight_kg=None):
    """Real driving route. Returns {'distance_km', 'duration_min',
    'geometry', 'steps' (if requested)} or None.

    OSRM (free, no API key) stays the primary engine for ordinary car
    routes — behavior here is unchanged from before. HERE only ever gets
    called when:
    - transport_mode='truck' — OSRM's public server has no truck profile,
      so a B2B delivery route was never actually respecting truck size
      limits before; HERE genuinely can.
    - OSRM's free public server fails/times out/rate-limits for a car
      route — HERE quietly picks up the request instead of the picker or
      customer seeing "no route available."
    If HERE_API_KEY isn't configured, the HERE call is a no-op (returns
    None immediately) and this behaves exactly as it always has."""
    if transport_mode == 'car':
        result = _get_osrm_route(from_lat, from_lng, to_lat, to_lng, steps=steps)
        if result:
            return result

    from .here_api import here_route
    return here_route(
        from_lat, from_lng, to_lat, to_lng,
        transport_mode=transport_mode, truck_weight_kg=truck_weight_kg, steps=steps,
    )


def _nearest_neighbor_order(origin_lat, origin_lng, waypoints):
    """Greedy stop ordering for get_multi_stop_route's 4+ waypoint case —
    straight-line distance only (no network calls), just to pick a sensible
    visiting order cheaply before routing it for real."""
    remaining = list(waypoints)
    order = []
    cur_lat, cur_lng = origin_lat, origin_lng
    while remaining:
        nearest = min(remaining, key=lambda w: haversine_km(cur_lat, cur_lng, w[0], w[1]))
        order.append(nearest)
        remaining.remove(nearest)
        cur_lat, cur_lng = nearest
    return tuple(order)


def get_multi_stop_route(origin_lat, origin_lng, waypoints, dest_lat, dest_lng, transport_mode='car', truck_weight_kg=None):
    """Real road route through any number of intermediate stops (e.g.
    markets/shops/warehouses a picker must visit before the final delivery
    — see the multi-stop pickup feature in market/views.py). waypoints is a
    list of (lat, lng) tuples — any length, uncapped.

    transport_mode/truck_weight_kg are passed straight through to every leg's
    get_route() call, so a B2B truck multi-stop trip stays on the truck
    routing profile for its whole journey, not just the final leg.

    Up to 2 waypoints, every visiting order is tried (at most 2! = 2 orders)
    as real routed legs, and whichever full trip is shortest wins — cheap
    enough to be exhaustive. Beyond that, trying every ordering stops being
    practical: it's factorial, and each ordering costs real network route
    calls — measured at ~13s for just 3 waypoints (6 orderings x 4 legs),
    unacceptable for a page load — so a nearest-neighbor greedy heuristic
    picks a single sensible order instead from 3 waypoints upward —
    starting from the picker's origin, repeatedly hopping to whichever
    remaining stop is straight-line closest — then routes that one order
    for real (measured at ~3s for 5 waypoints, ~5s for 8). Not guaranteed
    optimal for 3+ stops, but keeps the number of real route calls linear
    in the stop count instead of exploding. Returns the same shape as
    get_route() (distance_km/
    duration_min/geometry), with every leg's coordinates concatenated into
    one geometry so the whole trip draws as a single line on a map, plus a
    'legs' list (each leg's own distance_km/duration_min, in the winning
    visit order origin->...->dest) so callers can split "distance spent
    picking across stops" from "distance for the final delivery leg" instead
    of only seeing the combined total. None if no valid ordering could be
    routed at all (e.g. OSRM/HERE both down)."""
    if not waypoints:
        return get_route(origin_lat, origin_lng, dest_lat, dest_lng, transport_mode=transport_mode, truck_weight_kg=truck_weight_kg)

    from itertools import permutations

    if len(waypoints) <= 2:
        candidate_orders = permutations(waypoints)
    else:
        candidate_orders = [_nearest_neighbor_order(origin_lat, origin_lng, waypoints)]

    best = None
    for order in candidate_orders:
        points = [(origin_lat, origin_lng)] + list(order) + [(dest_lat, dest_lng)]
        legs = []
        for (a_lat, a_lng), (b_lat, b_lng) in zip(points, points[1:]):
            leg = get_route(a_lat, a_lng, b_lat, b_lng, transport_mode=transport_mode, truck_weight_kg=truck_weight_kg)
            if not leg:
                legs = None
                break
            legs.append(leg)
        if not legs:
            continue

        total_distance = sum(leg['distance_km'] for leg in legs)
        if best is None or total_distance < best['distance_km']:
            coordinates = []
            for leg in legs:
                if leg.get('geometry') and leg['geometry'].get('coordinates'):
                    coordinates.extend(leg['geometry']['coordinates'])
            best = {
                'distance_km': round(total_distance, 2),
                'duration_min': round(sum(leg['duration_min'] or 0 for leg in legs), 1),
                'geometry': {'type': 'LineString', 'coordinates': coordinates} if coordinates else None,
                'legs': [{'distance_km': round(leg['distance_km'], 2), 'duration_min': leg['duration_min']} for leg in legs],
            }
    return best


def split_pickup_delivery_distance(route):
    """Given a route from get_multi_stop_route(), splits its total into how
    far the picker travels collecting items across stops (every leg except
    the last) vs. the final leg to the customer. Returns (None, None) when
    there's nothing to split — a direct picker->customer route (no 'legs',
    or only one) — so callers know to fall back to showing the plain total."""
    legs = route.get('legs') if route else None
    if not legs or len(legs) < 2:
        return None, None
    pickup_distance_km = round(sum(leg['distance_km'] for leg in legs[:-1]), 2)
    delivery_distance_km = legs[-1]['distance_km']
    return pickup_distance_km, delivery_distance_km


def is_within_service_area(lat, lng):
    b = DAR_ES_SALAAM_BOUNDS
    return b['south'] <= lat <= b['north'] and b['west'] <= lng <= b['east']


def traffic_adjusted_duration(duration_min, when=None):
    """Free OSM tooling has no live traffic feed, so this is a time-of-day
    heuristic buffer, not real traffic data — label it as an estimate
    wherever it's shown."""
    if duration_min is None:
        return None
    now = when or (datetime.datetime.utcnow() + datetime.timedelta(hours=3))  # East Africa Time
    hour = now.hour
    if hour in (7, 8, 16, 17, 18):
        multiplier = 1.5
    elif hour in (9, 15, 19):
        multiplier = 1.2
    else:
        multiplier = 1.05
    return round(duration_min * multiplier, 1)


def get_route_surface_summary(route_geometry):
    """Queries the free Overpass API for road segments near the route and
    classifies how much of it is tagged paved vs unpaved in OpenStreetMap.
    This reads OSM's existing worldwide road data (already covers Tanzania in
    full) rather than requiring any separate data import. Returns None if the
    check can't be completed (Overpass is a shared public service and can be
    slow/unavailable)."""
    if not route_geometry or not route_geometry.get('coordinates'):
        return None

    coords = route_geometry['coordinates']
    step = max(1, len(coords) // 15)
    sampled = coords[::step]
    around_points = ','.join(f'{lat},{lng}' for lng, lat in sampled)
    query = f'[out:json][timeout:20];way(around:35,{around_points});out tags;'

    # Same route (same picker + destination) gets asked about repeatedly —
    # a customer re-opening the picker page, or the async check firing
    # again — so cache the (slow, rate-limited) Overpass answer for a while.
    cache_key = 'route_surface_' + hashlib.sha1(around_points.encode()).hexdigest()
    cached = cache.get(cache_key)
    if cached is not None:
        return cached

    try:
        resp = requests.post(OVERPASS_URL, data={'data': query}, headers=_HEADERS, timeout=8)
        resp.raise_for_status()
        data = resp.json()
    except (requests.RequestException, ValueError):
        return None

    tagged_surfaces = [
        el['tags'].get('surface', '').lower()
        for el in data.get('elements', [])
        if 'highway' in el.get('tags', {}) and el['tags'].get('surface')
    ]

    known_paved = sum(1 for s in tagged_surfaces if s in PAVED_SURFACES)
    known_unpaved = sum(1 for s in tagged_surfaces if s in UNPAVED_SURFACES)
    known_total = known_paved + known_unpaved

    if known_total == 0:
        result = {'paved_pct': None, 'label': 'Road surface data unavailable for this route', 'picker_ok': None}
    else:
        paved_pct = round(known_paved / known_total * 100)
        if paved_pct >= 80:
            label, picker_ok = 'Mostly paved roads', True
        elif paved_pct >= 40:
            label, picker_ok = 'Mixed paved/unpaved roads', True
        else:
            label, picker_ok = 'Mostly unpaved — rough for cars, fine for motorcycle/bicycle', False
        result = {'paved_pct': paved_pct, 'label': label, 'picker_ok': picker_ok}

    cache.set(cache_key, result, 600)
    return result


# ---------------------------------------------------------------------------
# Dar es Salaam area database & fuzzy matching
# ---------------------------------------------------------------------------
#
# The known-areas gazetteer itself (name -> lat/lng/aliases) lives in
# Supabase (pickker_known_areas, via market/known_areas.py) rather than as a
# hardcoded dict here — that makes it admin-editable for accuracy, and lets
# fuzzy_area_search permanently record any new area the AI fallback resolves
# instead of re-asking the AI for the same place on every future search.


def _levenshtein(s1, s2):
    """Basic Levenshtein distance — no external dependencies."""
    if len(s1) < len(s2):
        return _levenshtein(s2, s1)
    if len(s2) == 0:
        return len(s1)
    prev_row = list(range(len(s2) + 1))
    for i, c1 in enumerate(s1):
        curr_row = [i + 1]
        for j, c2 in enumerate(s2):
            cost = 0 if c1 == c2 else 1
            curr_row.append(min(
                curr_row[j] + 1,        # insert
                prev_row[j + 1] + 1,    # delete
                prev_row[j] + cost,     # replace
            ))
        prev_row = curr_row
    return prev_row[-1]


def fuzzy_match_area(query, max_distance=3):
    """Tries to match a user-typed area name against the known Dar es Salaam
    areas database (pickker_known_areas). Returns {'name': str, 'lat': float,
    'lng': float} for the best match, or None if nothing is close enough.

    Works by checking the canonical name and all aliases with Levenshtein
    distance. A match is accepted if distance <= max_distance and it's the
    closest one found.
    """
    from .known_areas import get_all_areas

    if not query or not query.strip():
        return None

    query_lower = query.strip().lower()
    areas = get_all_areas()

    # Exact match first (fast path) — canonical name or alias
    for area in areas:
        if query_lower == area['name'] or query_lower in [a.lower() for a in area.get('aliases') or []]:
            return {'name': area['name'].title(), 'lat': area['lat'], 'lng': area['lng']}

    # Fuzzy match
    best_match = None
    best_distance = max_distance + 1

    for area in areas:
        name = area['name']
        dist = _levenshtein(query_lower, name)
        if dist < best_distance:
            best_distance = dist
            best_match = {'name': name.title(), 'lat': area['lat'], 'lng': area['lng']}

        for alias in area.get('aliases') or []:
            dist = _levenshtein(query_lower, alias.lower())
            if dist < best_distance:
                best_distance = dist
                best_match = {'name': name.title(), 'lat': area['lat'], 'lng': area['lng']}

    if best_distance <= max_distance:
        return best_match
    return None


def get_all_area_names():
    """Returns a flat list of all known area names (for injecting into prompts)."""
    from .known_areas import get_all_areas

    return sorted(area['name'].title() for area in get_all_areas())

