"""FastAPI scoring service v2 — fast in-memory POI cache + audience profiles + heatmap.

Endpoints:
  GET  /                       — Leaflet UI
  GET  /healthz
  GET  /api/sites              — all sites (parcels + ЖК)
  GET  /api/site/{id}          — full record
  GET  /api/districts          — district economics + admin polygons
  GET  /api/district-polygons  — admin districts as GeoJSON FeatureCollection
  GET  /api/macro              — mortgage rate, city avg POI, city median price
  GET  /api/audiences          — preset weight profiles
  GET  /api/district-velocity-trend/{district} — 12-month series
  POST /api/analyze            — score arbitrary point

Key speedup: when point falls inside Ekb bbox, reuse 9479 cached OSM POIs
instead of hitting Overpass (~30s → <100ms).
"""
from __future__ import annotations
import sqlite3, json, math, pathlib, sys, time
from typing import Optional
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from pydantic import BaseModel

ROOT = pathlib.Path(__file__).parent
DB = ROOT / "analysis.db"
STATIC = ROOT / "static"
CACHE_OSM = ROOT / "cache" / "overpass_raw.json"
ADMIN_DISTRICTS = ROOT / "cache" / "ekb_admin_districts.geojson"

app = FastAPI(title="GenDesign Parcel Scoring v2", version="0.2")

# ---------- Geometry helpers ----------

def hav(la1, lo1, la2, lo2):
    R = 6371000
    p1, p2 = math.radians(la1), math.radians(la2)
    dp = math.radians(la2 - la1); dl = math.radians(lo2 - lo1)
    a = math.sin(dp/2)**2 + math.cos(p1)*math.cos(p2)*math.sin(dl/2)**2
    return 2 * R * math.asin(math.sqrt(a))


def point_in_polygon(lat, lon, ring):
    """Ray casting. ring is list of [lon, lat]."""
    inside = False
    n = len(ring)
    j = n - 1
    for i in range(n):
        xi, yi = ring[i][0], ring[i][1]
        xj, yj = ring[j][0], ring[j][1]
        if ((yi > lat) != (yj > lat)) and (lon < (xj - xi) * (lat - yi) / (yj - yi + 1e-12) + xi):
            inside = not inside
        j = i
    return inside


def point_in_geometry(lat, lon, geom):
    if geom["type"] == "Polygon":
        rings = geom["coordinates"]
        if not point_in_polygon(lat, lon, rings[0]): return False
        for hole in rings[1:]:
            if point_in_polygon(lat, lon, hole): return False
        return True
    if geom["type"] == "MultiPolygon":
        return any(point_in_geometry(lat, lon, {"type":"Polygon","coordinates":poly})
                   for poly in geom["coordinates"])
    return False

# ---------- Pre-load OSM cache + bbox ----------

print("loading osm cache…")
_t = time.time()
_osm_data = json.loads(CACHE_OSM.read_text()) if CACHE_OSM.exists() else {}
# Flatten to list of (cat, lat, lon, name, tags_json)
_OSM_INDEX = []  # global flat list
for cat, elems in _osm_data.items():
    for el in elems:
        if el["type"] == "node":
            la, lo = el.get("lat"), el.get("lon")
        else:
            c = el.get("center") or {}; la, lo = c.get("lat"), c.get("lon")
        if la is None: continue
        tags = el.get("tags") or {}
        _OSM_INDEX.append((cat, la, lo, tags.get("name") or tags.get("operator") or "",
                          el["type"], el["id"]))
print(f"  loaded {len(_OSM_INDEX)} POIs in {time.time()-_t:.2f}s")

# Bbox of cache (for routing decisions)
_lats = [r[1] for r in _OSM_INDEX]
_lons = [r[2] for r in _OSM_INDEX]
EKB_BBOX = (min(_lats), min(_lons), max(_lats), max(_lons)) if _lats else (0,0,0,0)
print(f"  bbox: {EKB_BBOX}")

# Admin districts polygons
ADMIN_GEOM = json.loads(ADMIN_DISTRICTS.read_text()) if ADMIN_DISTRICTS.exists() else {"features":[]}
print(f"  admin districts: {len(ADMIN_GEOM.get('features',[]))}")


def find_admin_district(lat, lon):
    for f in ADMIN_GEOM.get("features", []):
        if point_in_geometry(lat, lon, f["geometry"]):
            return f["properties"]["name"]
    return None

# ---------- DB ----------

def _conn():
    c = sqlite3.connect(DB); c.row_factory = sqlite3.Row; return c

# ---------- Audience profiles ----------

AUDIENCES = {
    "balanced": {"label": "Сбалансированный",
                 "education": 0.18, "health": 0.10, "retail": 0.13,
                 "transit":   0.15, "leisure": 0.09, "economic": 0.30, "market": 0.05},
    "family":   {"label": "Семейный (садики, школы, парки)",
                 "education": 0.30, "health": 0.12, "retail": 0.10,
                 "transit":   0.10, "leisure": 0.13, "economic": 0.20, "market": 0.05},
    "premium":  {"label": "Премиум (цена, топ-локация)",
                 "education": 0.10, "health": 0.10, "retail": 0.10,
                 "transit":   0.10, "leisure": 0.15, "economic": 0.40, "market": 0.05},
    "economy":  {"label": "Эконом (транспорт, базовое)",
                 "education": 0.15, "health": 0.10, "retail": 0.20,
                 "transit":   0.25, "leisure": 0.05, "economic": 0.20, "market": 0.05},
    "investor": {"label": "Инвестор (скорость, ликвидность)",
                 "education": 0.10, "health": 0.08, "retail": 0.08,
                 "transit":   0.12, "leisure": 0.05, "economic": 0.45, "market": 0.12},
}

# ---------- Scoring ----------

# Эмпирические пороги score для ЕКБ: средний диапазон 15-30, max редко >40.
# Зеркало SCORE_THRESHOLDS из backend/app/api/v1/parcels.py — менять синхронно.
SCORE_THRESHOLDS: dict[str, float] = {"плохо": 5.0, "средне": 15.0, "хорошо": 25.0, "отлично": 40.0}

EDU = [("kindergarten", 300, 1000, 1.0), ("school", 400, 1500, 1.0), ("university", 1000, 5000, 0.3)]
HEALTH = [("pharmacy", 300, 1000, 1.0), ("clinic", 500, 2000, 1.0), ("hospital", 1500, 5000, 0.5)]
TRANSIT_M = [("metro", 1000, 3000, 1.0)]
TRANSIT_T = [("tram_stop", 400, 1500, 1.0)]
TRANSIT_B = [("bus_stop", 200, 800, 1.0)]
LEISURE = [("park", 500, 2000, 1.0), ("playground", 200, 700, 1.0), ("sports", 500, 2000, 0.7)]


def dist_score(d_m, ideal, mx):
    if d_m is None: return 0.0
    if d_m <= ideal: return 100.0
    if d_m >= mx: return 0.0
    return 100.0 * (mx - d_m) / (mx - ideal)


def comp_score(distances_by_cat, cats):
    tw = sum(c[3] for c in cats); s = 0
    for cat, ideal, mx, w in cats:
        d = distances_by_cat.get(cat)
        s += w * dist_score(d, ideal, mx)
    return s / tw if tw else 0


def in_bbox(lat, lon, padding=0.005):
    return (EKB_BBOX[0] - padding <= lat <= EKB_BBOX[2] + padding and
            EKB_BBOX[1] - padding <= lon <= EKB_BBOX[3] + padding)


def fetch_pois_local(lat, lon, radius_m=2500):
    """Fast: scan in-memory OSM cache, return dict cat -> list of (dist, lat, lon, name)."""
    by_cat = {}
    for cat, la, lo, name, *_ in _OSM_INDEX:
        d = hav(lat, lon, la, lo)
        if d <= radius_m:
            by_cat.setdefault(cat, []).append((d, la, lo, name))
    for cat in by_cat:
        by_cat[cat].sort()
    return by_cat


def fetch_pois_overpass(lat, lon, radius_m=2500):
    """Fallback: live Overpass query (slow, used outside Ekb bbox)."""
    import requests
    POI_QUERIES = [
        ("kindergarten",  '["amenity"="kindergarten"]'),
        ("school",        '["amenity"="school"]'),
        ("university",    '["amenity"~"^(university|college)$"]'),
        ("pharmacy",      '["amenity"="pharmacy"]'),
        ("clinic",        '["amenity"~"^(clinic|doctors)$"]'),
        ("hospital",      '["amenity"="hospital"]'),
        ("shop_big",      '["shop"~"^(mall|supermarket|department_store|hypermarket)$"]'),
        ("shop_med",      '["shop"~"^(convenience|grocery|bakery)$"]'),
        ("shop_small",    '["shop"~"^(kiosk|newsagent)$"]'),
        ("bus_stop",      '["highway"="bus_stop"]'),
        ("tram_stop",     '["railway"="tram_stop"]'),
        ("metro",         '["station"="subway"]'),
        ("park",          '["leisure"~"^(park|garden)$"]'),
        ("playground",    '["leisure"="playground"]'),
        ("sports",        '["leisure"~"^(sports_centre|fitness_centre|pitch)$"]'),
        ("cafe",          '["amenity"="cafe"]'),
        ("restaurant",    '["amenity"~"^(restaurant|fast_food)$"]'),
        ("fuel",          '["amenity"="fuel"]'),
        ("atm",           '["amenity"="atm"]'),
        ("post",          '["amenity"="post_office"]'),
        ("worship",       '["amenity"="place_of_worship"]'),
        ("library",       '["amenity"="library"]'),
        ("police",        '["amenity"="police"]'),
        ("fire",          '["amenity"="fire_station"]'),
        ("bank",          '["amenity"="bank"]'),
    ]
    parts = []
    for _, filt in POI_QUERIES:
        parts.append(f"node{filt}(around:{radius_m},{lat},{lon});")
        parts.append(f"way{filt}(around:{radius_m},{lat},{lon});")
    q = f"[out:json][timeout:60];({''.join(parts)});out center tags;"
    last = None
    by_cat = {}
    cat_lookup = {filt: cat for cat, filt in POI_QUERIES}
    for ep in ("https://overpass-api.de/api/interpreter",
               "https://overpass.kumi.systems/api/interpreter"):
        try:
            r = requests.post(ep, data={"data": q}, timeout=120,
                              headers={"User-Agent":"gendesign/1.0"})
            if not r.ok:
                last = f"{ep}: {r.status_code}"; continue
            for el in r.json().get("elements", []):
                tags = el.get("tags") or {}
                cat = _categorize_osm_tags(tags)
                if not cat: continue
                if el["type"] == "node":
                    la, lo = el.get("lat"), el.get("lon")
                else:
                    c = el.get("center") or {}; la, lo = c.get("lat"), c.get("lon")
                if la is None: continue
                d = hav(lat, lon, la, lo)
                by_cat.setdefault(cat, []).append((d, la, lo, tags.get("name") or ""))
            for c in by_cat: by_cat[c].sort()
            return by_cat
        except Exception as e:
            last = f"{ep}: {e}"
    raise HTTPException(502, f"Overpass failed: {last}")


def _categorize_osm_tags(tags):
    a = tags.get("amenity"); s = tags.get("shop"); l = tags.get("leisure")
    h = tags.get("highway"); r = tags.get("railway"); st = tags.get("station")
    if a == "kindergarten": return "kindergarten"
    if a == "school": return "school"
    if a in ("university", "college"): return "university"
    if a == "pharmacy": return "pharmacy"
    if a in ("clinic","doctors"): return "clinic"
    if a == "hospital": return "hospital"
    if a == "cafe": return "cafe"
    if a in ("restaurant","fast_food"): return "restaurant"
    if a == "fuel": return "fuel"
    if a == "atm": return "atm"
    if a == "bank": return "bank"
    if a == "post_office": return "post"
    if a == "place_of_worship": return "worship"
    if a == "library": return "library"
    if a == "police": return "police"
    if a == "fire_station": return "fire"
    if a == "parking": return "parking"
    if l == "fitness_centre": return "gym"
    if a == "theatre": return "theater"
    if a == "cinema": return "cinema"
    if a == "marketplace": return "marketplace"
    if tags.get("tourism") == "museum": return "museum"
    if tags.get("tourism") in ("hotel","hostel","apartment"): return "hotel"
    if a in ("nightclub","bar","pub"): return "nightclub"
    if a == "car_wash": return "car_wash"
    if a in ("car_rental","taxi"): return "car_rental"
    if a == "veterinary": return "vet"
    if a in ("parcel_locker","post_box"): return "courier"
    if a == "dentist": return "dentist"
    if a == "childcare": return "childcare"
    if s in ("mall","supermarket","department_store","hypermarket"): return "shop_big"
    if s in ("convenience","grocery","bakery"): return "shop_med"
    if s in ("kiosk","newsagent"): return "shop_small"
    if h == "bus_stop": return "bus_stop"
    if r == "tram_stop": return "tram_stop"
    if st == "subway": return "metro"
    if l in ("park","garden"): return "park"
    if l == "playground": return "playground"
    if l in ("sports_centre","fitness_centre","pitch"): return "sports"
    return None


def assign_district(lat, lon, conn):
    """Two-tier: admin (PostGIS polygon) + objective-микрорайон (kNN voted)."""
    admin = find_admin_district(lat, lon)
    matched = conn.execute("""
        SELECT s.lat, s.lon, sd.district FROM sites s
          JOIN site_district sd USING (site_id)
          JOIN jk_objective_match m USING (site_id)""").fetchall()
    cands = sorted([(hav(lat, lon, m["lat"], m["lon"]), m["district"]) for m in matched])
    top = [c[1] for c in cands[:7] if c[0] <= 2500]
    from collections import Counter
    obj_district = Counter(top).most_common(1)[0][0] if top else None
    return admin, obj_district


def score_point(lat, lon, weights, what_if=None, remove_cats=None):
    """Run full scoring for a point. Returns dict ready for API.

    what_if: optional list of {cat, lat, lon, name?} — hypothetical POIs added
             before scoring (e.g., "what if there was a metro 500m away?")
    remove_cats: optional list of categories to suppress (proxy for "no metro")
    """
    t0 = time.time()
    if in_bbox(lat, lon):
        by_cat = fetch_pois_local(lat, lon)
        poi_source = "cache"
    else:
        by_cat = fetch_pois_overpass(lat, lon)
        poi_source = "overpass_live"
    # apply what-if mutations
    for w in (what_if or []):
        cat = w.get("cat")
        if not cat: continue
        d = hav(lat, lon, w["lat"], w["lon"])
        by_cat.setdefault(cat, []).append((d, w["lat"], w["lon"], w.get("name") or "Hypothetical"))
        by_cat[cat].sort()
    for cat in (remove_cats or []):
        by_cat[cat] = []
    t_poi = time.time() - t0

    # 2. Build features
    feats = {}
    cats = ["kindergarten","school","university","pharmacy","clinic","hospital",
            "shop_big","shop_med","shop_small","bus_stop","tram_stop","metro",
            "park","playground","sports",
            # Extended (informational; not in scoring weights)
            "cafe","restaurant","fuel","atm","bank","post","worship","library","police","fire",
            "parking","gym","theater","cinema","marketplace","museum","hotel",
            "nightclub","car_wash","car_rental","vet","courier","dentist","childcare"]
    nearest = {}
    for cat in cats:
        items = by_cat.get(cat, [])
        if items:
            nearest[cat] = items[0][0]
            feats[f"{cat}_nearest_m"] = items[0][0]
            feats[f"{cat}_count_500m"] = sum(1 for it in items if it[0] <= 500)
            feats[f"{cat}_count_1km"]  = sum(1 for it in items if it[0] <= 1000)
            feats[f"{cat}_top5"] = [
                {"name": it[3], "distance_m": round(it[0],1), "lat": it[1], "lon": it[2]}
                for it in items[:5]
            ]
        else:
            feats[f"{cat}_nearest_m"] = None
            feats[f"{cat}_count_500m"] = 0
            feats[f"{cat}_count_1km"] = 0
            feats[f"{cat}_top5"] = []

    edu = comp_score(nearest, EDU)
    health = comp_score(nearest, HEALTH)
    big = comp_score(nearest, [("shop_big", 500, 2000, 1.0)])
    med = comp_score(nearest, [("shop_med", 300, 1000, 1.0)])
    retail = max(big, 0.7 * med)
    tr_m = comp_score(nearest, TRANSIT_M)
    tr_t = comp_score(nearest, TRANSIT_T)
    tr_b = comp_score(nearest, TRANSIT_B)
    transit = max(tr_m, 0.85 * tr_t, 0.7 * tr_b)
    leisure = comp_score(nearest, LEISURE)

    # 3. District + economics
    with _conn() as c:
        admin, obj_district = assign_district(lat, lon, c)
        economics = None
        trend_factor = 1.0
        if obj_district:
            row = c.execute("SELECT * FROM district_economics WHERE district=?",
                            (obj_district,)).fetchone()
            economics = dict(row) if row else None
            if economics:
                trend_factor = economics.get("trend_factor") or 1.0

        # bounds for normalization
        prices = sorted([r[0] for r in c.execute(
            "SELECT real_median_price_m2 FROM district_economics "
            "WHERE real_median_price_m2 IS NOT NULL").fetchall()])
        pmin, pmax = (prices[0], prices[-1]) if prices else (100, 200)
        if pmax <= pmin: pmax = pmin + 1
        vels = sorted([r[0] for r in c.execute(
            "SELECT real_velocity_6mo FROM district_economics "
            "WHERE real_velocity_6mo IS NOT NULL").fetchall()])
        vmax = vels[int(len(vels) * 0.9)] if vels else 8

        all_jk = c.execute("SELECT lat, lon FROM sites WHERE kind='jk'").fetchall()
    n_jk_1km = sum(1 for r in all_jk if hav(lat, lon, r[0], r[1]) <= 1000)
    feats["jk_count_1km"] = n_jk_1km

    # 3b. Walkability — sum of POI weights × decay 1/(1+d/200) within 1.5km
    walk_w = {
        # daily-need POIs weighted higher
        "shop_med": 1.5, "shop_small": 1.0, "shop_big": 0.6,
        "kindergarten": 1.5, "school": 1.5,
        "pharmacy": 1.0, "clinic": 0.8, "hospital": 0.4,
        "cafe": 0.6, "restaurant": 0.4,
        "park": 1.2, "playground": 1.0, "sports": 0.5,
        "bus_stop": 0.8, "tram_stop": 1.0, "metro": 1.5,
        "atm": 0.4, "bank": 0.3, "post": 0.4, "library": 0.3,
    }
    walk_score_raw = 0
    for cat, w in walk_w.items():
        for d, *_ in by_cat.get(cat, [])[:10]:
            if d <= 1500:
                walk_score_raw += w / (1 + d/200)
    # normalize: 30 = "average" → 50, 60+ = "excellent" → 100
    walkability = min(100, walk_score_raw * 100 / 60)
    feats["walkability_score"] = round(walkability, 1)

    # 4. Economic score
    if economics:
        price = economics.get("real_median_price_m2")
        v_rec = economics.get("real_velocity_6mo")
        mts = economics.get("months_to_sellout")
        p_score = max(0, min(100, ((price or 0) - pmin) * 100 / (pmax - pmin))) if price else 0
        v_score_raw = max(0, min(100, (v_rec or 0) * 100 / vmax)) if v_rec else 0
        tf_clamped = max(0.7, min(2.0, trend_factor))
        v_score = v_score_raw * (0.5 + 0.5 * (tf_clamped / 2.0))
        liq = max(0, 100 - min(mts or 100, 24) * 100 / 24) if mts else 50
        econ = 0.50 * p_score + 0.25 * v_score + 0.25 * liq
    else:
        econ = 0; price = v_rec = None

    # 5. Market score — FIXED: don't penalize high sold_pct, treat as proven absorption
    density_score = max(0, 100 - n_jk_1km * 100 / 15)
    sold_pct = (economics or {}).get("real_sold_pct") or 50
    # Sold% 0..70 = linear up (proven absorption). 70..100 = plateau (saturated; no penalty needed).
    sat_score = min(100, sold_pct * 100 / 70)
    market = 0.5 * density_score + 0.5 * sat_score

    # 6. Aggregate
    comps = {"education": edu, "health": health, "retail": retail,
             "transit": transit, "leisure": leisure, "economic": econ, "market": market}
    weighted = sum(weights.get(k, 0) * v for k, v in comps.items())

    # 7. Rank vs all ЖК (using same weights)
    with _conn() as c:
        rows = c.execute("""SELECT s.site_id,
                                   (SELECT score_0_100 FROM scores sc WHERE sc.site_id=s.site_id AND sc.component='education'),
                                   (SELECT score_0_100 FROM scores sc WHERE sc.site_id=s.site_id AND sc.component='health'),
                                   (SELECT score_0_100 FROM scores sc WHERE sc.site_id=s.site_id AND sc.component='retail'),
                                   (SELECT score_0_100 FROM scores sc WHERE sc.site_id=s.site_id AND sc.component='transit'),
                                   (SELECT score_0_100 FROM scores sc WHERE sc.site_id=s.site_id AND sc.component='leisure'),
                                   (SELECT score_0_100 FROM scores sc WHERE sc.site_id=s.site_id AND sc.component='economic'),
                                   (SELECT score_0_100 FROM scores sc WHERE sc.site_id=s.site_id AND sc.component='market')
                              FROM sites s WHERE s.kind='jk'""").fetchall()
    weights_ord = ["education","health","retail","transit","leisure","economic","market"]
    all_w = []
    for r in rows:
        w = sum(weights.get(weights_ord[i], 0) * (r[i+1] or 0) for i in range(7))
        all_w.append(w)
    all_w.sort(reverse=True)
    rank = sum(1 for x in all_w if x > weighted) + 1
    n_jk = len(all_w)

    return {
        "scores": comps,
        "weighted": round(weighted, 2),
        "rank_overall": rank,
        "n_jk_compared": n_jk,
        "percentile": round(100 * (1 - (rank - 1) / (n_jk + 1)), 1),
        "weights": weights,
        "admin_district": admin,
        "district": obj_district,
        "economics": economics,
        "macro_factors": {"trend_factor": trend_factor,
                          "n_jk_1km": n_jk_1km, "city_pmin": pmin, "city_pmax": pmax,
                          "city_vmax": vmax},
        "features": feats,
        "_perf": {"poi_source": poi_source, "poi_ms": round(t_poi*1000, 1)},
    }

# ---------- HTTP ----------

@app.get("/healthz")
def healthz():
    with _conn() as c:
        n_sites = c.execute("SELECT count(*) FROM sites").fetchone()[0]
        n_pois = c.execute("SELECT count(*) FROM pois").fetchone()[0]
        n_obj = c.execute("SELECT count(*) FROM objective_corp_month").fetchone()[0]
        n_lots = c.execute("SELECT count(*) FROM objective_lots").fetchone()[0]
    return {"status": "ok", "sites": n_sites, "pois_cached": n_pois,
            "objective_corp_month": n_obj, "objective_lots": n_lots,
            "osm_cache_pois": len(_OSM_INDEX), "ekb_bbox": EKB_BBOX,
            "admin_districts": len(ADMIN_GEOM.get("features", []))}


@app.get("/api/sites")
def sites():
    with _conn() as c:
        rows = c.execute("""
            SELECT s.site_id, s.kind, s.name, s.address, s.lat, s.lon,
                   s.obj_class, s.developer, s.flat_count, s.district,
                   sd.district AS obj_district,
                   st.weighted, st.rank_overall
              FROM sites s
              LEFT JOIN site_district sd USING (site_id)
              LEFT JOIN scores_total st USING (site_id)""").fetchall()
    return [dict(r) for r in rows]


@app.get("/api/jk-full/{site_id:path}")
def jk_full(site_id: str):
    """Full ЖК dossier: site row, scores, features, district economics,
    POI counts/nearest, monthly velocity, per-flat sample, prod photos URL,
    prod sale_graph data (if available via SSH tunnel).
    """
    with _conn() as c:
        s = c.execute("SELECT * FROM sites WHERE site_id=?", (site_id,)).fetchone()
        if not s: raise HTTPException(404, "site not found")
        s = dict(s)
        s["scores"] = {r["component"]: r["score_0_100"] for r in
                       c.execute("SELECT * FROM scores WHERE site_id=?", (site_id,)).fetchall()}
        st = c.execute("SELECT weighted,rank_overall,rank_district FROM scores_total WHERE site_id=?",
                       (site_id,)).fetchone()
        if st: s.update({"weighted": st["weighted"], "rank_overall": st["rank_overall"],
                         "rank_district": st["rank_district"]})

        # POIs near this site (15 categories with top-3 each)
        s["pois"] = {}
        for cat in ["kindergarten","school","university","pharmacy","clinic","hospital",
                    "shop_big","shop_med","shop_small","bus_stop","tram_stop","metro",
                    "park","playground","sports",
                    "cafe","restaurant","fuel","atm","bank","post","worship",
                    "library","police","fire",
                    "parking","gym","theater","cinema","marketplace","museum","hotel",
                    "nightclub","car_wash","car_rental","vet","courier","dentist","childcare"]:
            rows = c.execute("""SELECT name, distance_m, lat, lon FROM pois
                                 WHERE site_id=? AND category=?
                                 ORDER BY distance_m LIMIT 3""", (site_id, cat)).fetchall()
            nearest = c.execute("""SELECT MIN(distance_m), COUNT(CASE WHEN distance_m<=500 THEN 1 END),
                                          COUNT(CASE WHEN distance_m<=1000 THEN 1 END)
                                     FROM pois WHERE site_id=? AND category=?""",
                                (site_id, cat)).fetchone()
            s["pois"][cat] = {
                "nearest_m": nearest[0],
                "count_500m": nearest[1],
                "count_1km": nearest[2],
                "top3": [{"name": r["name"] or "—", "distance_m": round(r["distance_m"],1),
                          "lat": r["lat"], "lon": r["lon"]} for r in rows]
            }

        # District economics
        e = c.execute("""SELECT sd.district, sd.method, de.* FROM site_district sd
                          LEFT JOIN district_economics de USING (district)
                         WHERE sd.site_id=?""", (site_id,)).fetchone()
        s["economics"] = dict(e) if e else None

        # Match Objective project
        m = c.execute("SELECT project, score FROM jk_objective_match WHERE site_id=?",
                      (site_id,)).fetchone()
        s["objective_match"] = dict(m) if m else None
        project = m["project"] if m else None

        # Monthly registrations for this project (12 mo) + sample lots
        s["monthly_velocity"] = []
        s["lots_sample"] = []
        s["lots_summary"] = None
        if project:
            import datetime as dt
            today = dt.date.today().replace(day=1)
            months = []
            y, mn = today.year, today.month
            for _ in range(12):
                months.append(f"{y:04d}-{mn:02d}")
                mn -= 1
                if mn == 0: mn = 12; y -= 1
            months.reverse()
            rows = c.execute("""SELECT substr(register_date,1,7) AS m, COUNT(*) n
                                  FROM objective_lots
                                 WHERE project=? AND register_date IS NOT NULL
                                   AND register_date >= ?
                              GROUP BY 1""", (project, months[0])).fetchall()
            series = {r["m"]: r["n"] for r in rows}
            s["monthly_velocity"] = [{"month": m, "n": series.get(m, 0)} for m in months]

            # 10 currently in-sale lots with prices
            lots = c.execute("""SELECT lot_id, corpus, section, floor, lot_num, room_kind,
                                       rooms_obj, area_pd, price_per_m2, status, sold,
                                       finish_type, plan_date, readiness_pct
                                  FROM objective_lots
                                 WHERE project=?
                                   AND status IN ('в продаже','резерв')
                                   AND price_per_m2 > 0
                              ORDER BY price_per_m2 ASC
                                 LIMIT 10""", (project,)).fetchall()
            s["lots_sample"] = [dict(r) for r in lots]

            # Aggregate sample
            agg = c.execute("""SELECT COUNT(*) total,
                                      SUM(CASE WHEN sold='да' THEN 1 ELSE 0 END) sold_n,
                                      ROUND(AVG(CASE WHEN price_per_m2>0 THEN price_per_m2 END)/1000,1) avg_price_kp,
                                      ROUND(AVG(CASE WHEN area_pd>0 THEN area_pd END),1) avg_area,
                                      ROUND(MIN(CASE WHEN price_per_m2>0 THEN price_per_m2 END)/1000,1) min_price_kp,
                                      ROUND(MAX(CASE WHEN price_per_m2>0 THEN price_per_m2 END)/1000,1) max_price_kp,
                                      COUNT(DISTINCT corpus) n_corpus,
                                      COUNT(DISTINCT rooms_obj) n_room_types
                                 FROM objective_lots WHERE project=?""", (project,)).fetchone()
            s["lots_summary"] = dict(agg) if agg else None

        # OSM building polygons attached to this ЖК
        try:
            jk_fc = _load_jk_polygons()
            s["building_polygons"] = [
                f for f in jk_fc.get("features", [])
                if f["properties"].get("site_id") == site_id
            ]
        except Exception:
            s["building_polygons"] = []

    # Optional: photos + sale_graph from prod via SSH tunnel
    s["prod_extras"] = {"photos": [], "sale_graph": [], "sales_agg": []}
    obj_id = s.get("obj_id")
    if obj_id:
        try:
            import psycopg
            pg = psycopg.connect(host="127.0.0.1", port=15432, user="gendesign",
                                 password="2J2SBPMKuS998fiwhtQqDhMI",
                                 dbname="gendesign", connect_timeout=2)
            pcur = pg.cursor()
            pcur.execute("""SELECT photo_url, photo_dttm, period_dt, photo_name, ready_desc, thumb_path, hidden
                             FROM domrf_kn_photos
                             WHERE obj_id=%s AND COALESCE(hidden,false)=false
                             ORDER BY period_dt DESC NULLS LAST LIMIT 16""", (obj_id,))
            s["prod_extras"]["photos"] = [
                {"url": r[0], "photo_dttm": str(r[1]) if r[1] else None,
                 "period_dt": str(r[2]) if r[2] else None,
                 "name": r[3], "ready_desc": r[4], "thumb_path": r[5]}
                for r in pcur.fetchall()
            ]
            pcur.execute("""SELECT report_month, type, realised, contracted, area_sq, price_avg
                             FROM domrf_kn_sale_graph WHERE obj_id=%s
                             ORDER BY report_month""", (obj_id,))
            s["prod_extras"]["sale_graph"] = [
                {"report_month": str(r[0]) if r[0] else None,
                 "type": r[1], "realised": r[2], "contracted": r[3],
                 "area_sq": float(r[4]) if r[4] is not None else None,
                 "price_avg": float(r[5]) if r[5] is not None else None}
                for r in pcur.fetchall()
            ]
            pcur.execute("""SELECT type, name, total, realised, perc
                             FROM domrf_kn_sales_agg WHERE obj_id=%s
                             AND snapshot_date=(SELECT MAX(snapshot_date) FROM domrf_kn_sales_agg WHERE obj_id=%s)""",
                          (obj_id, obj_id))
            s["prod_extras"]["sales_agg"] = [
                {"type": r[0], "name": r[1], "total": r[2], "realised": r[3],
                 "perc": float(r[4]) if r[4] is not None else None}
                for r in pcur.fetchall()
            ]
            pg.close()
        except Exception as e:
            s["prod_extras"]["error"] = str(e)

    return s


@app.get("/api/site/{site_id:path}")
def site_detail(site_id: str):
    with _conn() as c:
        s = c.execute("SELECT * FROM sites WHERE site_id=?", (site_id,)).fetchone()
        if not s: raise HTTPException(404, "site not found")
        s = dict(s)
        s["scores"] = {r["component"]: r["score_0_100"] for r in
                       c.execute("SELECT * FROM scores WHERE site_id=?", (site_id,)).fetchall()}
        st = c.execute("SELECT weighted,rank_overall,rank_district FROM scores_total WHERE site_id=?",
                       (site_id,)).fetchone()
        if st: s.update({"weighted": st["weighted"], "rank_overall": st["rank_overall"],
                         "rank_district": st["rank_district"]})
        s["features"] = {r["feature"]: r["value"]
                         for r in c.execute("SELECT * FROM features WHERE site_id=?",
                                            (site_id,)).fetchall()}
        d = c.execute("""SELECT sd.district, de.* FROM site_district sd
                          LEFT JOIN district_economics de USING (district)
                         WHERE sd.site_id=?""", (site_id,)).fetchone()
        s["economics"] = dict(d) if d else None
    return s


@app.get("/api/districts")
def districts():
    with _conn() as c:
        rows = c.execute("SELECT * FROM district_economics ORDER BY real_median_price_m2 DESC").fetchall()
    return [dict(r) for r in rows]


@app.get("/api/district-polygons")
def district_polygons():
    """Return admin districts (8) as GeoJSON FeatureCollection.
    Properties enriched with ЖК count + average score per district."""
    with _conn() as c:
        rows = c.execute("""SELECT s.district, COUNT(*) n,
                                   ROUND(AVG(st.weighted), 1) avg_score
                              FROM sites s
                              LEFT JOIN scores_total st USING (site_id)
                             WHERE s.kind='jk' AND s.district IS NOT NULL
                          GROUP BY 1""").fetchall()
    enrich = {r["district"]: dict(r) for r in rows}
    fc = json.loads(ADMIN_DISTRICTS.read_text())
    for f in fc["features"]:
        nm = f["properties"]["name"]
        e = enrich.get(nm, {})
        f["properties"].update({"jk_count": e.get("n", 0), "avg_score": e.get("avg_score")})
    return fc


@app.get("/api/macro")
def macro():
    with _conn() as c:
        rows = c.execute("SELECT key,value,label,period FROM macro_context").fetchall()
    out = []
    for r in rows:
        d = dict(r)
        d["unit"] = "%" if d["key"] == "mortgage_rate_sverdl" else \
                    " тыс/м²" if d["key"] == "city_med_price_m2" else ""
        out.append(d)
    return out


@app.get("/api/audiences")
def audiences():
    return AUDIENCES


@app.get("/api/reverse-geocode")
def reverse_geocode(lat: float, lon: float):
    """Resolve human-readable address via Nominatim (rate-limited; cache locally)."""
    import requests
    cache_path = ROOT / "cache" / "geocode_cache.json"
    cache = {}
    if cache_path.exists():
        try: cache = json.loads(cache_path.read_text())
        except: cache = {}
    key = f"{lat:.5f},{lon:.5f}"
    if key in cache:
        return cache[key]
    try:
        r = requests.get("https://nominatim.openstreetmap.org/reverse",
                         params={"lat":lat,"lon":lon,"format":"json","accept-language":"ru","zoom":18},
                         headers={"User-Agent":"gendesign-research/0.2"}, timeout=10)
        r.raise_for_status()
        d = r.json()
        addr = d.get("address", {}) or {}
        out = {
            "display_name": d.get("display_name"),
            "road": addr.get("road"),
            "house_number": addr.get("house_number"),
            "suburb": addr.get("suburb") or addr.get("neighbourhood"),
            "city_district": addr.get("city_district"),
            "city": addr.get("city") or addr.get("town"),
        }
    except Exception as e:
        out = {"error": str(e), "display_name": None}
    cache[key] = out
    # Cache is best-effort — the volume may be mounted read-only (prod docker setup),
    # in which case we just skip persistence and rely on the per-process in-memory dict.
    try:
        cache_path.parent.mkdir(parents=True, exist_ok=True)
        cache_path.write_text(json.dumps(cache, ensure_ascii=False))
    except OSError:
        pass
    return out


@app.get("/api/district-heatmap")
def district_heatmap(metric: str = "score"):
    """Admin districts (8) coloured by chosen metric.

    metric ∈ {'score','price','velocity','sold_pct','jk_count'}
    """
    fc = json.loads(ADMIN_DISTRICTS.read_text())
    with _conn() as c:
        # Map admin district → aggregated metric
        rows = c.execute("""
            SELECT s.district AS admin, COUNT(*) AS jk_count,
                   AVG(st.weighted) AS avg_score
              FROM sites s LEFT JOIN scores_total st USING (site_id)
             WHERE s.kind='jk' AND s.district IS NOT NULL
          GROUP BY 1""").fetchall()
        admin_score = {r["admin"]: r["avg_score"] for r in rows}
        admin_count = {r["admin"]: r["jk_count"] for r in rows}
        # Objective metrics aggregated by admin via site_district mapping
        eco = c.execute("""
            SELECT s.district AS admin,
                   AVG(de.real_median_price_m2) AS price,
                   AVG(de.real_velocity_6mo) AS vel,
                   AVG(de.real_sold_pct) AS sold
              FROM sites s
              JOIN site_district sd USING (site_id)
              JOIN district_economics de USING (district)
             WHERE s.kind='jk' AND s.district IS NOT NULL
          GROUP BY 1""").fetchall()
        admin_eco = {r["admin"]: dict(r) for r in eco}

    metric_map = {
        "score":    lambda nm: admin_score.get(nm),
        "price":    lambda nm: (admin_eco.get(nm) or {}).get("price"),
        "velocity": lambda nm: (admin_eco.get(nm) or {}).get("vel"),
        "sold_pct": lambda nm: (admin_eco.get(nm) or {}).get("sold"),
        "jk_count": lambda nm: admin_count.get(nm),
    }
    fn = metric_map.get(metric, metric_map["score"])
    for f in fc["features"]:
        nm = f["properties"]["name"]
        f["properties"]["value"] = fn(nm)
        f["properties"]["jk_count"] = admin_count.get(nm, 0)
        f["properties"]["avg_score"] = admin_score.get(nm)
    return fc


@app.get("/api/developer/{developer_name:path}")
def developer_track_record(developer_name: str):
    """Aggregate developer's portfolio: ЖК count, avg score, avg price, total flats,
    velocity, sellout history. Pulled from local DB via name match.
    """
    with _conn() as c:
        rows = c.execute("""SELECT s.site_id, s.name, s.flat_count, s.obj_class,
                                   sd.district AS obj_district,
                                   st.weighted, st.rank_overall,
                                   m.project
                              FROM sites s
                              LEFT JOIN site_district sd USING (site_id)
                              LEFT JOIN scores_total st USING (site_id)
                              LEFT JOIN jk_objective_match m USING (site_id)
                             WHERE s.kind='jk' AND s.developer = ?""", (developer_name,)).fetchall()
        ojects = [dict(r) for r in rows]
        if not ojects:
            raise HTTPException(404, "developer not found")

        # Aggregate per-project lots stats
        projects = [o["project"] for o in ojects if o["project"]]
        if projects:
            placeholders = ",".join(["?"]*len(projects))
            agg = c.execute(f"""SELECT project, COUNT(*) total,
                                       SUM(CASE WHEN sold='да' THEN 1 ELSE 0 END) sold_n,
                                       AVG(CASE WHEN price_per_m2>0 THEN price_per_m2/1000 END) avg_price,
                                       AVG(CASE WHEN area_pd>0 THEN area_pd END) avg_area,
                                       MIN(register_date) first_deal,
                                       MAX(register_date) last_deal,
                                       COUNT(CASE WHEN register_date >= date('now','-6 months') THEN 1 END)/6.0 vel_6mo
                                  FROM objective_lots WHERE project IN ({placeholders})
                                  GROUP BY 1""", projects).fetchall()
            proj_data = {r["project"]: dict(r) for r in agg}
        else:
            proj_data = {}

    # roll up
    n_jk = len(ojects)
    avg_score = sum(o["weighted"] or 0 for o in ojects if o["weighted"]) / max(sum(1 for o in ojects if o["weighted"]), 1)
    total_flats = sum(o["flat_count"] or 0 for o in ojects)
    matched = [o for o in ojects if o["project"] and proj_data.get(o["project"])]
    # Dedup by project: multiple sites can match the same Objective project,
    # so sum lot stats over unique projects to avoid double-counting.
    matched_projects = sorted({o["project"] for o in matched})
    total_lots = sum(proj_data[p]["total"] for p in matched_projects)
    sold_lots = sum(proj_data[p]["sold_n"] for p in matched_projects)
    avg_price = (sum(proj_data[p]["avg_price"] or 0 for p in matched_projects) / len(matched_projects)) if matched_projects else None
    total_vel = sum(proj_data[p]["vel_6mo"] or 0 for p in matched_projects)

    portfolio = []
    for o in ojects:
        p = proj_data.get(o["project"]) if o["project"] else None
        portfolio.append({**o, "project_stats": p})
    portfolio.sort(key=lambda x: -(x["weighted"] or 0))

    return {
        "developer": developer_name,
        "summary": {
            "n_projects": n_jk,
            "n_matched_objective": len(matched),
            "avg_score": round(avg_score, 1),
            "best_score": max((o["weighted"] or 0) for o in ojects),
            "worst_score": min((o["weighted"] or 0) for o in ojects if o["weighted"]),
            "total_flats": total_flats,
            "total_lots_objective": total_lots,
            "sold_lots_objective": sold_lots,
            "sold_pct": round(100*sold_lots/total_lots, 1) if total_lots else None,
            "avg_price_m2_kr": round(avg_price, 1) if avg_price else None,
            "total_velocity_6mo": round(total_vel, 2),
            "districts": list({o["obj_district"] for o in ojects if o["obj_district"]}),
        },
        "portfolio": portfolio,
    }


class TEPInput(BaseModel):
    parcel_area_m2: float
    obj_class: Optional[str] = None       # 'комфорт' | 'бизнес' | 'эконом' | 'премиум'
    far: float = 1.5                      # floor-area ratio (общая GBA / parcel area)
    saleable_share: float = 0.65          # доля квартир в общей GBA (typical 0.55-0.75)
    avg_unit_area_m2: float = 45          # среднее по сделке
    district: Optional[str] = None         # для подстановки price/velocity
    construction_cost_per_m2: int = 75000  # ₽/м² GBA
    soft_cost_pct: float = 0.10            # проектные/маркетинг как доля от выручки
    discount_rate: float = 0.18            # годовая для NPV/IRR
    months_to_complete: int = 30


@app.post("/api/tep-calc")
def tep_calc(payload: TEPInput):
    """Простой generative-TЭП от параметров участка.

    Берёт (по района) реальную median price/m² и velocity. Считает выручку,
    себестоимость, NPV/IRR в простой модели «равномерные продажи всё время стройки».
    """
    with _conn() as c:
        district = payload.district
        if district:
            row = c.execute("""SELECT real_median_price_m2, real_velocity_6mo
                                 FROM district_economics WHERE district=?""",
                             (district,)).fetchone()
        else:
            row = None
        if row:
            price_kr = row["real_median_price_m2"] or 130
            velocity_per_corp = row["real_velocity_6mo"] or 2.0
        else:
            price_kr = 130
            velocity_per_corp = 2.0

    gba = payload.parcel_area_m2 * payload.far          # total built m²
    saleable_m2 = gba * payload.saleable_share
    n_units = round(saleable_m2 / payload.avg_unit_area_m2)
    price_per_m2 = price_kr * 1000
    revenue = saleable_m2 * price_per_m2
    construction = gba * payload.construction_cost_per_m2
    soft = revenue * payload.soft_cost_pct
    profit = revenue - construction - soft

    # Sales velocity — single corpus assumption; cap at saleable
    months_to_sellout = n_units / max(velocity_per_corp, 0.1)
    cashflow_months = max(payload.months_to_complete, months_to_sellout)

    # NPV: monthly equal cashflows
    monthly_revenue = revenue / cashflow_months if cashflow_months else 0
    monthly_cost = construction / payload.months_to_complete
    r_m = (1 + payload.discount_rate) ** (1/12) - 1
    npv = -construction * 0.20  # initial 20% upfront
    for i in range(1, int(cashflow_months) + 1):
        cost_i = monthly_cost * (1 if i <= payload.months_to_complete else 0)
        rev_i = monthly_revenue
        npv += (rev_i - cost_i) / ((1 + r_m) ** i)
    # crude IRR via bisection on monthly
    def npv_at(r):
        s = -construction * 0.20
        rmm = (1+r)**(1/12) - 1
        for i in range(1, int(cashflow_months)+1):
            ci = monthly_cost * (1 if i <= payload.months_to_complete else 0)
            s += (monthly_revenue - ci) / ((1+rmm)**i)
        return s
    lo, hi = 0.0, 5.0
    irr = None
    if npv_at(lo) > 0 and npv_at(hi) < 0:
        for _ in range(40):
            mid = (lo+hi)/2
            v = npv_at(mid)
            if v > 0: lo = mid
            else: hi = mid
        irr = (lo+hi)/2

    return {
        "input": payload.dict(),
        "district_used": district,
        "assumptions": {"price_kr_per_m2": price_kr, "velocity_per_corp_6mo": velocity_per_corp},
        "outputs": {
            "gba_m2": round(gba, 1),
            "saleable_m2": round(saleable_m2, 1),
            "n_units": n_units,
            "revenue_total": round(revenue),
            "revenue_mln": round(revenue/1e6, 1),
            "construction_cost_total": round(construction),
            "construction_mln": round(construction/1e6, 1),
            "soft_cost_total": round(soft),
            "profit_total": round(profit),
            "profit_mln": round(profit/1e6, 1),
            "profit_margin_pct": round(100*profit/revenue, 1) if revenue else None,
            "months_to_sellout": round(months_to_sellout, 1),
            "cashflow_months": round(cashflow_months, 1),
            "npv": round(npv),
            "npv_mln": round(npv/1e6, 1),
            "irr_annual": round(irr*100, 1) if irr is not None else None,
        }
    }


@app.get("/api/price-distribution/{district}")
def price_distribution(district: str):
    """Boxplot data per ЖК class for a district."""
    with _conn() as c:
        rows = c.execute("""
            SELECT s.obj_class AS class_name, ol.price_per_m2/1000.0 AS price
              FROM objective_lots ol
              JOIN jk_objective_match m ON m.project = ol.project
              JOIN sites s USING (site_id)
             WHERE ol.district = ? AND ol.price_per_m2 > 0 AND s.obj_class IS NOT NULL""",
            (district,)).fetchall()
    by_class = {}
    for r in rows:
        by_class.setdefault(r["class_name"], []).append(r["price"])
    out = {}
    for k, vals in by_class.items():
        vals.sort()
        if not vals: continue
        n = len(vals)
        def q(p):
            # Linear-interpolated quantile at position p*(n-1) (avoids upward bias on small n).
            pos = p * (n - 1)
            lo = int(pos)
            hi = min(lo + 1, n - 1)
            frac = pos - lo
            return vals[lo] + (vals[hi] - vals[lo]) * frac
        out[k] = {"n": n, "min": vals[0], "p25": q(0.25), "p50": q(0.5),
                  "p75": q(0.75), "max": vals[-1]}
    return {"district": district, "by_class": out}


@app.get("/api/district-time-machine/{district}")
def district_time_machine(district: str):
    """12-month series of: deals_count, median_price, sold_volume, distinct_corpuses."""
    import datetime as dt
    today = dt.date.today().replace(day=1)
    months = []
    y, m = today.year, today.month
    for _ in range(12):
        months.append(f"{y:04d}-{m:02d}")
        m -= 1
        if m == 0: m = 12; y -= 1
    months.reverse()
    series = []
    import statistics
    with _conn() as c:
        for mo in months:
            row = c.execute("""
                SELECT COUNT(*) AS deals,
                       SUM(CASE WHEN area_pd>0 THEN area_pd END) AS volume_m2,
                       COUNT(DISTINCT project||'·'||corpus) AS corpuses
                  FROM objective_lots
                 WHERE district=? AND substr(register_date,1,7)=?
                """, (district, mo)).fetchone()
            # True median price (SQLite has no MEDIAN aggregate) — compute in Python.
            prices = [r["p"] for r in c.execute("""
                SELECT price_per_m2/1000.0 AS p
                  FROM objective_lots
                 WHERE district=? AND substr(register_date,1,7)=? AND price_per_m2>0
                """, (district, mo)).fetchall()]
            median_price = statistics.median(prices) if prices else None
            series.append({
                "month": mo,
                "deals": row["deals"] or 0,
                "median_price_kr": round(median_price, 1) if median_price is not None else None,
                "volume_m2": round(row["volume_m2"], 1) if row["volume_m2"] else 0,
                "active_corpuses": row["corpuses"] or 0,
            })
    return {"district": district, "series": series}


@app.get("/api/smart-suggestions")
def smart_suggestions(lat: float, lon: float, audience: str = "balanced",
                       radius_km: float = 3.0, top_n: int = 5):
    """Top-N ЖК within radius_km that score higher than this point under selected audience.
    Useful as 'nearby alternatives'. Only parcels with weighted score >= SCORE_THRESHOLDS['хорошо']
    (25.0) are surfaced — below that threshold the suggestion has no meaningful quality signal."""
    weights = AUDIENCES.get(audience, AUDIENCES["balanced"])
    weights = {k: v for k, v in weights.items() if k != "label"}
    min_weighted = SCORE_THRESHOLDS["хорошо"]
    with _conn() as c:
        rows = c.execute("""
            SELECT s.site_id, s.name, s.developer, s.obj_class, s.lat, s.lon,
                   sd.district AS obj_district,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='education') AS edu,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='health')    AS health,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='retail')    AS retail,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='transit')   AS transit,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='leisure')   AS leisure,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='economic')  AS economic,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='market')    AS market
              FROM sites s
              LEFT JOIN site_district sd USING (site_id)
             WHERE s.kind='jk'""").fetchall()
    out = []
    for r in rows:
        d = hav(lat, lon, r["lat"], r["lon"])
        if d > radius_km*1000: continue
        comps = {"education": r["edu"] or 0, "health": r["health"] or 0,
                 "retail": r["retail"] or 0, "transit": r["transit"] or 0,
                 "leisure": r["leisure"] or 0, "economic": r["economic"] or 0,
                 "market": r["market"] or 0}
        weighted = sum(weights.get(k,0)*v for k,v in comps.items())
        if weighted < min_weighted:
            continue
        out.append({
            "site_id": r["site_id"], "name": r["name"], "developer": r["developer"],
            "obj_class": r["obj_class"], "obj_district": r["obj_district"],
            "lat": r["lat"], "lon": r["lon"],
            "distance_m": round(d), "weighted": round(weighted, 1),
        })
    out.sort(key=lambda x: -x["weighted"])
    return {"audience": audience, "radius_km": radius_km, "results": out[:top_n]}


@app.get("/api/export/geojson")
def export_geojson(audience: str = "balanced", min_score: float = 0):
    """All ЖК as GeoJSON FeatureCollection with audience-weighted score in props."""
    weights = {k: v for k, v in AUDIENCES.get(audience, AUDIENCES["balanced"]).items() if k != "label"}
    with _conn() as c:
        rows = c.execute("""
            SELECT s.site_id, s.name, s.developer, s.obj_class, s.lat, s.lon,
                   sd.district,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='education') edu,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='health') health,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='retail') retail,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='transit') transit,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='leisure') leisure,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='economic') economic,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='market') market
              FROM sites s LEFT JOIN site_district sd USING (site_id)
             WHERE s.kind='jk'""").fetchall()
    feats = []
    for r in rows:
        comps = {"education":r["edu"] or 0, "health":r["health"] or 0,
                 "retail":r["retail"] or 0, "transit":r["transit"] or 0,
                 "leisure":r["leisure"] or 0, "economic":r["economic"] or 0,
                 "market":r["market"] or 0}
        w = sum(weights.get(k,0)*v for k,v in comps.items())
        if w < min_score: continue
        feats.append({"type":"Feature",
                      "geometry":{"type":"Point","coordinates":[r["lon"], r["lat"]]},
                      "properties":{"site_id":r["site_id"],"name":r["name"],
                                    "developer":r["developer"],"obj_class":r["obj_class"],
                                    "district":r["district"],
                                    "weighted_score":round(w,2),
                                    **{f"score_{k}": round(v,1) for k,v in comps.items()}}})
    return {"type":"FeatureCollection","features":feats,
            "_metadata":{"audience":audience,"weights":weights,"count":len(feats)}}


@app.get("/api/export/csv")
def export_csv(audience: str = "balanced"):
    """Same data as flat CSV for Excel/Google Sheets."""
    import io, csv
    weights = {k: v for k, v in AUDIENCES.get(audience, AUDIENCES["balanced"]).items() if k != "label"}
    with _conn() as c:
        rows = c.execute("""
            SELECT s.site_id, s.name, s.developer, s.obj_class, s.lat, s.lon, s.flat_count,
                   sd.district,
                   de.real_median_price_m2, de.real_velocity_6mo, de.real_sold_pct,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='education') edu,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='health') health,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='retail') retail,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='transit') transit,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='leisure') leisure,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='economic') economic,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='market') market
              FROM sites s
              LEFT JOIN site_district sd USING (site_id)
              LEFT JOIN district_economics de USING (district)
             WHERE s.kind='jk'""").fetchall()
    buf = io.StringIO()
    buf.write("﻿")  # BOM for Excel
    w = csv.writer(buf, delimiter=";")
    cols = ["site_id","name","developer","obj_class","district","lat","lon","flat_count",
            "median_price_kr","velocity_6mo","sold_pct",
            "edu","health","retail","transit","leisure","economic","market","weighted"]
    w.writerow(cols)
    for r in rows:
        comps = {k: r[k] or 0 for k in ("edu","health","retail","transit","leisure","economic","market")}
        comp_full = {"education":comps["edu"],"health":comps["health"],"retail":comps["retail"],
                     "transit":comps["transit"],"leisure":comps["leisure"],
                     "economic":comps["economic"],"market":comps["market"]}
        weighted = sum(weights.get(k,0)*v for k,v in comp_full.items())
        w.writerow([r["site_id"],r["name"],r["developer"],r["obj_class"],r["district"],
                    r["lat"],r["lon"],r["flat_count"],
                    r["real_median_price_m2"], r["real_velocity_6mo"], r["real_sold_pct"],
                    *[comps[k] for k in ("edu","health","retail","transit","leisure","economic","market")],
                    round(weighted,2)])
    from fastapi.responses import Response
    return Response(content=buf.getvalue(), media_type="text/csv; charset=utf-8",
                     headers={"Content-Disposition": f'attachment; filename="gendesign_jk_{audience}.csv"'})


@app.get("/api/poi-vs-district/{site_id:path}")
def poi_vs_district(site_id: str):
    """Compare POI counts of one site vs district average."""
    with _conn() as c:
        s = c.execute("""SELECT s.lat, s.lon, sd.district FROM sites s
                          LEFT JOIN site_district sd USING (site_id)
                         WHERE s.site_id=?""", (site_id,)).fetchone()
        if not s: raise HTTPException(404)
        district = s["district"]
        if not district:
            return {"site_id": site_id, "district": None, "comparison": []}

        peers = [r["site_id"] for r in c.execute(
            "SELECT site_id FROM site_district WHERE district=? AND site_id!=?",
            (district, site_id)).fetchall()]
        if not peers:
            return {"site_id": site_id, "district": district, "comparison": []}

        cats = ["kindergarten","school","pharmacy","clinic","shop_big","shop_med",
                "bus_stop","tram_stop","park","playground","sports","cafe","restaurant",
                "atm","post"]
        out = []
        for cat in cats:
            mine = c.execute(
                "SELECT COUNT(*) FROM pois WHERE site_id=? AND category=? AND distance_m<=1000",
                (site_id, cat)).fetchone()[0]
            placeholders = ",".join("?"*len(peers))
            avg = c.execute(
                f"SELECT AVG(c) FROM (SELECT COUNT(*) c FROM pois WHERE site_id IN ({placeholders}) AND category=? AND distance_m<=1000 GROUP BY site_id)",
                (*peers, cat)).fetchone()[0] or 0
            out.append({"category": cat, "mine": mine, "district_avg": round(avg, 1),
                        "delta_pct": round(100*(mine-avg)/avg, 1) if avg else None})
    return {"site_id": site_id, "district": district, "comparison": out}


@app.get("/api/district-compare")
def district_compare(districts: str):
    """Compare 2-3 districts on key metrics. Body: 'A,B,C'."""
    names = [n.strip() for n in districts.split(",") if n.strip()][:5]
    out = []
    with _conn() as c:
        for name in names:
            r = c.execute("""SELECT * FROM district_economics WHERE district=?""", (name,)).fetchone()
            if r: out.append(dict(r))
    return {"districts": names, "data": out}


@app.get("/api/district-roi-ranking")
def district_roi_ranking(obj_class: str = "комфорт", far: float = 2.0,
                          parcel_area_m2: float = 3000):
    """For a hypothetical parcel size + class, rank ALL districts by IRR/margin.
    Useful for investor scouting — 'where to look for parcels'."""
    CLASSES = {
        "эконом":  {"price_baseline": 100, "construction": 60000,  "saleable": 0.65, "avg_unit": 38},
        "комфорт": {"price_baseline": 130, "construction": 75000,  "saleable": 0.62, "avg_unit": 48},
        "бизнес":  {"price_baseline": 200, "construction": 110000, "saleable": 0.55, "avg_unit": 65},
        "премиум": {"price_baseline": 320, "construction": 170000, "saleable": 0.50, "avg_unit": 95},
    }
    cfg = CLASSES.get(obj_class, CLASSES["комфорт"])
    out = []
    with _conn() as c:
        rows = c.execute("""SELECT district, real_median_price_m2, real_velocity_6mo,
                                    real_sold_pct, n_projects
                              FROM district_economics
                             WHERE real_median_price_m2 IS NOT NULL""").fetchall()
    for r in rows:
        # adjust class price by district base ratio
        ratio = (r["real_median_price_m2"] or 130) / 130
        price_kr = cfg["price_baseline"] * ratio
        gba = parcel_area_m2 * far
        saleable = gba * cfg["saleable"]
        n_units = round(saleable / cfg["avg_unit"])
        revenue = saleable * price_kr * 1000
        construction = gba * cfg["construction"]
        soft = revenue * 0.10
        profit = revenue - construction - soft
        margin = 100 * profit / revenue if revenue else 0
        # crude ROI-per-month: profit / (construction × months) × 12
        # assume sellout = n_units / district_velocity_per_corp (capped at 30 mo)
        v = r["real_velocity_6mo"] or 1.0
        months = max(18, min(60, n_units / max(v, 0.5)))
        roi_annual = (profit / construction) * 12 / months * 100 if construction else 0
        out.append({
            "district": r["district"],
            "median_price_kr": round(r["real_median_price_m2"], 1),
            "velocity_6mo": round(v, 2),
            "sold_pct": round(r["real_sold_pct"] or 0, 1),
            "n_projects": r["n_projects"],
            "price_kr_class": round(price_kr, 1),
            "n_units": n_units,
            "revenue_mln": round(revenue/1e6, 1),
            "profit_mln": round(profit/1e6, 1),
            "margin_pct": round(margin, 1),
            "months_to_sellout": round(months, 1),
            "roi_annual_pct": round(roi_annual, 1),
        })
    out.sort(key=lambda x: -x["roi_annual_pct"])
    return {"obj_class": obj_class, "far": far, "parcel_area_m2": parcel_area_m2,
            "results": out}


@app.get("/api/risk-score")
def risk_score(lat: float, lon: float):
    """Risk: distance to railways, highways, industry — closer = higher noise/dust."""
    import math
    risks = {
        "railway":  {"items": [], "ideal_m": 200, "max_m": 800, "weight": 0.30},
        "highway":  {"items": [], "ideal_m": 80,  "max_m": 400, "weight": 0.30},
        "industry": {"items": [], "ideal_m": 200, "max_m": 800, "weight": 0.25},
        "fuel":     {"items": [], "ideal_m": 80,  "max_m": 300, "weight": 0.05},
        "fire":     {"items": [], "ideal_m": 500, "max_m": 2000,"weight": 0.10},
    }
    # Use cached OSM raw — railway / highway data lives in osm_buildings_all only as buildings,
    # so for risk we sample fuel + fire from main cache (already there).
    for cat in ("fuel", "fire"):
        for el in _osm_data.get(cat, []):
            if el["type"] == "node":
                la, lo = el.get("lat"), el.get("lon")
            else:
                c = el.get("center") or {}; la, lo = c.get("lat"), c.get("lon")
            if la is None: continue
            d = hav(lat, lon, la, lo)
            if d <= risks[cat]["max_m"] * 2:
                risks[cat]["items"].append({"distance_m": round(d, 1)})

    # Railway/highway from OSM Overpass live — small bbox (1km around point)
    try:
        import requests
        bbox = (lat - 0.012, lon - 0.020, lat + 0.012, lon + 0.020)  # ~1.5km
        q = f'[out:json][timeout:30];(way["railway"]({bbox[0]},{bbox[1]},{bbox[2]},{bbox[3]});way["highway"~"^(motorway|trunk|primary|secondary)$"]({bbox[0]},{bbox[1]},{bbox[2]},{bbox[3]}););out geom;'
        r = requests.post("https://overpass-api.de/api/interpreter", data={"data": q},
                          timeout=40, headers={"User-Agent":"gendesign/0.7"})
        if r.ok:
            for el in r.json().get("elements", []):
                if not el.get("geometry"): continue
                cat = "railway" if el.get("tags",{}).get("railway") else "highway"
                # nearest point of way
                min_d = min(hav(lat, lon, p["lat"], p["lon"]) for p in el["geometry"])
                risks[cat]["items"].append({"distance_m": round(min_d, 1),
                                              "name": el.get("tags",{}).get("name","")})
    except Exception as e:
        risks["_overpass_err"] = str(e)[:120]

    # Score per category — closer to ideal = high penalty
    out = {"lat": lat, "lon": lon, "risks": {}}
    total_penalty = 0
    for cat, cfg in risks.items():
        if cat.startswith("_"): continue
        if not cfg["items"]:
            out["risks"][cat] = {"min_distance_m": None, "n_within_buffer": 0, "penalty_pct": 0, "weight": cfg["weight"]}
            continue
        nearest = min(cfg["items"], key=lambda x: x["distance_m"])
        d = nearest["distance_m"]
        if d <= cfg["ideal_m"]:
            penalty = cfg["weight"] * 100
        elif d >= cfg["max_m"]:
            penalty = 0
        else:
            penalty = cfg["weight"] * 100 * (cfg["max_m"] - d) / (cfg["max_m"] - cfg["ideal_m"])
        out["risks"][cat] = {
            "min_distance_m": d,
            "n_within_buffer": sum(1 for x in cfg["items"] if x["distance_m"] <= cfg["max_m"]),
            "penalty_pct": round(penalty, 1),
            "weight": cfg["weight"],
        }
        total_penalty += penalty
    out["total_penalty"] = round(total_penalty, 1)
    out["risk_score"] = round(max(0, 100 - total_penalty), 1)  # 100 = no risks
    return out


@app.post("/api/tep-multiclass")
def tep_multiclass(payload: dict):
    """Compare TEP across 3 classes (econom/comfort/business) for same parcel."""
    base_district = payload.get("district")
    parcel_area = payload.get("parcel_area_m2", 2789)
    far = payload.get("far", 2.0)

    CLASSES = {
        "эконом":  {"price_kr": 100, "construction": 60000, "saleable": 0.65, "avg_unit": 38},
        "комфорт": {"price_kr": 130, "construction": 75000, "saleable": 0.62, "avg_unit": 48},
        "бизнес":  {"price_kr": 200, "construction": 110000, "saleable": 0.55, "avg_unit": 65},
        "премиум": {"price_kr": 320, "construction": 170000, "saleable": 0.50, "avg_unit": 95},
    }
    # If we have real district median, use class-relative multipliers
    if base_district:
        with _conn() as c:
            row = c.execute("SELECT real_median_price_m2 FROM district_economics WHERE district=?",
                            (base_district,)).fetchone()
            if row and row["real_median_price_m2"]:
                base = row["real_median_price_m2"]
                # adjust class prices proportionally to district base
                ratio = base / 130  # comfort baseline
                for cls in CLASSES.values():
                    cls["price_kr"] = round(cls["price_kr"] * ratio, 1)

    results = []
    for cls_name, cfg in CLASSES.items():
        gba = parcel_area * far
        saleable = gba * cfg["saleable"]
        n_units = round(saleable / cfg["avg_unit"])
        revenue = saleable * cfg["price_kr"] * 1000
        construction = gba * cfg["construction"]
        soft = revenue * 0.10
        profit = revenue - construction - soft
        margin = 100 * profit / revenue if revenue else 0
        results.append({
            "class": cls_name,
            "price_kr_per_m2": cfg["price_kr"],
            "construction_per_m2": cfg["construction"],
            "n_units": n_units,
            "revenue_mln": round(revenue/1e6, 1),
            "cost_mln": round(construction/1e6, 1),
            "profit_mln": round(profit/1e6, 1),
            "margin_pct": round(margin, 1),
        })
    results.sort(key=lambda x: -x["margin_pct"])
    return {"district": base_district, "parcel_area_m2": parcel_area, "far": far,
            "results": results, "best_class": results[0]["class"]}


@app.get("/api/insolation")
def insolation(lat: float, lon: float, radius_m: int = 100):
    """Approximate insolation analysis for a parcel point.

    Returns: density of OSM buildings around the point per octant (N/NE/E/SE/.../NW),
    so we can identify which sides have shadow risk (dense) vs open (sun).
    """
    # Use full OSM building centroids (61k+) cached at cache/osm_buildings_all.geojson
    fc_path = ROOT / "cache" / "osm_buildings_all.geojson"
    nearby = []
    if fc_path.exists():
        fc = json.loads(fc_path.read_text())
        import math
        for feat in fc.get("features", []):
            coords = feat.get("geometry", {}).get("coordinates")
            if not coords: continue
            clon, clat = coords[0], coords[1]
            d = hav(lat, lon, clat, clon)
            if d <= radius_m * 5:
                dlon = math.radians(clon - lon)
                la1, la2 = math.radians(lat), math.radians(clat)
                y = math.sin(dlon) * math.cos(la2)
                x = math.cos(la1)*math.sin(la2) - math.sin(la1)*math.cos(la2)*math.cos(dlon)
                bearing = (math.degrees(math.atan2(y, x)) + 360) % 360
                nearby.append({"distance_m": round(d), "bearing_deg": round(bearing, 1)})
    # Aggregate per octant + south exposure score
    octants = ["N","NE","E","SE","S","SW","W","NW"]
    counts = {o: 0 for o in octants}
    weighted_shadow_S = 0  # higher = more buildings to the South (BAD for insolation)
    for b in nearby:
        idx = int(((b["bearing_deg"] + 22.5) % 360) / 45)
        counts[octants[idx]] += 1
        # Building is south of parcel if bearing in (135°, 225°)
        if 135 < b["bearing_deg"] < 225:
            weighted_shadow_S += 1 / (1 + b["distance_m"]/50)
    # Insolation score: 100 = wide open south, 0 = dense south wall
    insolation_score = max(0, 100 - weighted_shadow_S * 10)
    return {
        "lat": lat, "lon": lon,
        "n_buildings_500m": len(nearby),
        "octants": counts,
        "insolation_score": round(insolation_score, 1),
        "shadow_pressure_S": round(weighted_shadow_S, 2),
        "nearby_sample": sorted(nearby, key=lambda x: x["distance_m"])[:8],
    }


@app.get("/api/poi-points")
def poi_points(category: str = "*"):
    """Returns flat array of [lat, lon, weight] for heatmap rendering.
    category in {*, education, health, retail, transit, leisure, cafe}."""
    GROUPS = {
        "education": ["kindergarten","school","university"],
        "health":    ["pharmacy","clinic","hospital"],
        "retail":    ["shop_big","shop_med","shop_small"],
        "transit":   ["bus_stop","tram_stop","metro"],
        "leisure":   ["park","playground","sports"],
        "cafe":      ["cafe","restaurant"],
    }
    target_cats = GROUPS.get(category)  # None means all
    pts = []
    seen = set()
    for cat, elems in _osm_data.items():
        if target_cats and cat not in target_cats: continue
        for el in elems:
            if el["type"] == "node":
                la, lo = el.get("lat"), el.get("lon")
            else:
                c = el.get("center") or {}; la, lo = c.get("lat"), c.get("lon")
            if la is None: continue
            key = (round(la, 5), round(lo, 5))
            if key in seen: continue
            seen.add(key)
            pts.append([la, lo, 0.5])
    return {"points": pts, "count": len(pts), "category": category}


@app.get("/api/leaderboard")
def leaderboard(audience: str = "balanced", limit: int = 30,
                obj_class: Optional[str] = None, district: Optional[str] = None,
                min_score: float = 0):
    """Top-N ЖК ranked by chosen audience profile.

    Audience profile re-weights the per-component scores already cached in `scores`.
    """
    weights = AUDIENCES.get(audience, AUDIENCES["balanced"])
    weights = {k: v for k, v in weights.items() if k != "label"}
    with _conn() as c:
        rows = c.execute("""
            SELECT s.site_id, s.name, s.developer, s.obj_class, s.flat_count,
                   s.lat, s.lon, sd.district AS obj_district,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='education') AS edu,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='health')    AS health,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='retail')    AS retail,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='transit')   AS transit,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='leisure')   AS leisure,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='economic')  AS economic,
                   (SELECT score_0_100 FROM scores WHERE site_id=s.site_id AND component='market')    AS market,
                   m.project,
                   (SELECT real_median_price_m2 FROM district_economics
                     WHERE district=sd.district) AS price,
                   (SELECT real_velocity_6mo FROM district_economics
                     WHERE district=sd.district) AS vel
              FROM sites s
              LEFT JOIN site_district sd USING (site_id)
              LEFT JOIN jk_objective_match m USING (site_id)
             WHERE s.kind='jk'
        """).fetchall()
    out = []
    for r in rows:
        if obj_class and r["obj_class"] != obj_class: continue
        if district and r["obj_district"] != district: continue
        comps = {"education": r["edu"] or 0, "health": r["health"] or 0,
                 "retail": r["retail"] or 0, "transit": r["transit"] or 0,
                 "leisure": r["leisure"] or 0, "economic": r["economic"] or 0,
                 "market": r["market"] or 0}
        weighted = sum(weights.get(k, 0) * v for k, v in comps.items())
        if weighted < min_score: continue
        out.append({
            "site_id": r["site_id"], "name": r["name"], "developer": r["developer"],
            "obj_class": r["obj_class"], "flat_count": r["flat_count"],
            "obj_district": r["obj_district"], "lat": r["lat"], "lon": r["lon"],
            "weighted": round(weighted, 2),
            "matched_objective": bool(r["project"]),
            "price_m2_kr": round(r["price"], 1) if r["price"] else None,
            "velocity_6mo": round(r["vel"], 2) if r["vel"] else None,
            "components": {k: round(v, 1) for k, v in comps.items()},
        })
    out.sort(key=lambda x: -x["weighted"])
    return {"audience": audience, "weights": weights, "total_matched": len(out),
            "results": out[:limit]}


@app.post("/api/bulk-analyze")
def bulk_analyze(payload: dict):
    """Analyze a batch of {cad,lat,lon} entries.
    Body: {points: [{cad?,lat,lon,name?}], audience: 'family'}
    """
    points = payload.get("points") or []
    audience = payload.get("audience") or "balanced"
    weights = AUDIENCES.get(audience, AUDIENCES["balanced"])
    weights = {k: v for k, v in weights.items() if k != "label"}
    results = []
    for p in points[:50]:  # cap 50
        try:
            res = score_point(p.get("lat"), p.get("lon"), weights)
            results.append({
                "cad": p.get("cad"), "name": p.get("name"),
                "lat": p.get("lat"), "lon": p.get("lon"),
                "weighted": res["weighted"], "rank": res["rank_overall"],
                "district": res["district"], "scores": res["scores"],
            })
        except Exception as e:
            results.append({"cad": p.get("cad"), "error": str(e)[:120]})
    results.sort(key=lambda x: -(x.get("weighted") or 0))
    return {"audience": audience, "n": len(results), "results": results}


@app.get("/api/jk-sellout/{site_id:path}")
def jk_sellout(site_id: str):
    """Months-to-sellout for a specific ЖК: current stock / 6mo velocity."""
    with _conn() as c:
        m = c.execute("SELECT project FROM jk_objective_match WHERE site_id=?",
                      (site_id,)).fetchone()
        if not m:
            return {"site_id": site_id, "matched": False}
        project = m["project"]
        agg = c.execute("""SELECT
            COUNT(CASE WHEN status IN ('в продаже','резерв') THEN 1 END) AS stock_lots,
            COUNT(CASE WHEN status IN ('в продаже','резерв') THEN 1 END)
              * AVG(CASE WHEN area_pd>0 THEN area_pd END) AS stock_m2,
            COUNT(CASE WHEN register_date >= date('now','-6 months') THEN 1 END)/6.0 AS vel_6mo,
            COUNT(*) AS total,
            SUM(CASE WHEN sold='да' THEN 1 ELSE 0 END) AS sold_n
          FROM objective_lots WHERE project=?""", (project,)).fetchone()
        d = dict(agg) if agg else {}
        d["site_id"] = site_id
        d["project"] = project
        d["matched"] = True
        d["months_to_sellout"] = (d["stock_lots"] / d["vel_6mo"]) if d.get("vel_6mo") and d["vel_6mo"] > 0 else None
        return d


@app.get("/api/nearby-jk-velocity")
def nearby_jk_velocity(lat: float, lon: float, radius_m: int = 1500):
    """Per-complex velocity for ЖК within radius_m of point.

    Returns list with: site_id, name, distance_m, velocity_6mo (deals/month),
    n_lots_district, sold_pct_district, weighted_score.
    Useful for direct-competitor analysis.
    """
    with _conn() as c:
        sites = c.execute("""SELECT s.site_id, s.name, s.lat, s.lon, s.developer, s.obj_class,
                                    sd.district AS obj_district,
                                    st.weighted, st.rank_overall,
                                    m.project
                              FROM sites s
                              LEFT JOIN site_district sd USING (site_id)
                              LEFT JOIN scores_total st USING (site_id)
                              LEFT JOIN jk_objective_match m USING (site_id)
                             WHERE s.kind='jk'""").fetchall()
        # per-project velocity from objective_lots
        proj_vel = {r["project"]: (r["v"], r["sold"], r["nlots"]) for r in c.execute("""
            SELECT project,
                   COUNT(CASE WHEN register_date >= date('now','-6 months') THEN 1 END)/6.0 AS v,
                   ROUND(100.0 * SUM(CASE WHEN sold='да' THEN 1 ELSE 0 END) / COUNT(*), 1) AS sold,
                   COUNT(*) AS nlots
              FROM objective_lots
             WHERE project IS NOT NULL
             GROUP BY 1""").fetchall()}
    out = []
    for s in sites:
        d = hav(lat, lon, s["lat"], s["lon"])
        if d <= radius_m:
            vel, sold, nlots = proj_vel.get(s["project"], (None, None, None))
            out.append({
                "site_id": s["site_id"], "name": s["name"], "lat": s["lat"], "lon": s["lon"],
                "distance_m": round(d, 1), "developer": s["developer"], "obj_class": s["obj_class"],
                "obj_district": s["obj_district"], "weighted": s["weighted"], "rank": s["rank_overall"],
                "project_in_objective": s["project"],
                "velocity_6mo": vel, "sold_pct": sold, "n_lots": nlots,
            })
    out.sort(key=lambda x: x["distance_m"])
    return out


@app.post("/api/fetch-polygon/{cad}")
def fetch_polygon(cad: str):
    """Pull parcel polygon from Rosreestr via rosreestr2coord lib (bypasses NSPD WAF).

    Caches to cache/parcel_polygons/<cad>.geojson and returns the FeatureCollection.
    Workaround for NSPD WAF — the lib uses a different upstream that still works.
    """
    try:
        from rosreestr2coord.parser import Area
        import shapely.geometry as sg
        import pyproj
    except ImportError as e:
        raise HTTPException(500, f"Missing dependency: {e}. Install rosreestr2coord shapely pyproj.")

    a = Area(cad, area_type=1, with_log=False, use_cache=False, timeout=20)
    if not a.feature:
        raise HTTPException(404, f"No polygon found for {cad}")

    feature = dict(a.feature)
    # rosreestr2coord returns WGS84 already despite metadata
    feature["geometry"]["crs"] = {"type":"name","properties":{"name":"EPSG:4326"}}
    poly = sg.shape(feature["geometry"])
    centroid = poly.centroid
    geod = pyproj.Geod(ellps="WGS84")
    area_m2 = abs(geod.geometry_area_perimeter(poly)[0])

    fc = {
        "type":"FeatureCollection",
        "features":[feature],
        "_centroid": {"lat": centroid.y, "lon": centroid.x},
        "_area_m2": round(area_m2, 1),
        "_source": "rosreestr2coord",
        "_props": feature["properties"].get("options", {}),
    }
    fname = (ROOT / "cache" / "parcel_polygons" / f"{cad.replace(':','_')}.geojson")
    try:
        fname.parent.mkdir(parents=True, exist_ok=True)
        fname.write_text(json.dumps(fc, ensure_ascii=False, indent=2))
    except OSError:
        pass  # cache mount is RO in prod — fc is still returned to caller
    return fc


@app.post("/api/upload-polygon")
def upload_polygon(payload: dict):
    """Manually upload parcel polygon (e.g., copied from NSPD).

    Body: {cad: '66:41:0204016:10', geojson: {...}}
    Stored at cache/parcel_polygons/<cad>.geojson; subsequent /api/analyze
    will use polygon centroid AND min-distance from polygon edges.
    """
    cad = (payload.get("cad") or "").strip()
    gj = payload.get("geojson")
    if not cad or not gj:
        raise HTTPException(400, "cad and geojson required")
    fname = (ROOT / "cache" / "parcel_polygons" / f"{cad.replace(':','_')}.geojson")
    try:
        fname.parent.mkdir(parents=True, exist_ok=True)
        fname.write_text(json.dumps(gj, ensure_ascii=False))
    except OSError as e:
        raise HTTPException(503, f"cache mount is read-only: {e}")
    return {"saved": str(fname.relative_to(ROOT)), "size": fname.stat().st_size}


@app.get("/api/parcel-polygon/{cad}")
def get_polygon(cad: str):
    fname = (ROOT / "cache" / "parcel_polygons" / f"{cad.replace(':','_')}.geojson")
    if not fname.exists():
        raise HTTPException(404, f"no polygon cached for {cad}")
    return json.loads(fname.read_text())


# Static cache for jk polygons FeatureCollection
_JK_POLYGONS = None
def _load_jk_polygons():
    global _JK_POLYGONS
    if _JK_POLYGONS is None:
        path = ROOT / "cache" / "jk_polygons.geojson"
        if path.exists():
            _JK_POLYGONS = json.loads(path.read_text())
            # enrich features with site weighted+name from local DB
            with _conn() as c:
                site_meta = {r["site_id"]: dict(r) for r in c.execute(
                    """SELECT s.site_id, s.name, s.developer, s.obj_class, s.district,
                              sd.district AS obj_district, st.weighted, st.rank_overall
                         FROM sites s
                         LEFT JOIN site_district sd USING (site_id)
                         LEFT JOIN scores_total st USING (site_id)
                         WHERE s.kind='jk'""").fetchall()}
            for f in _JK_POLYGONS["features"]:
                meta = site_meta.get(f["properties"]["site_id"], {})
                f["properties"].update(meta)
        else:
            _JK_POLYGONS = {"type":"FeatureCollection","features":[]}
    return _JK_POLYGONS

@app.get("/api/jk-polygons")
def jk_polygons():
    """Building footprint polygons matched to ЖК via spatial join (within 80m).
    Returns ~314 buildings for 111 of 380 ЖК (29% coverage).
    Properties enriched with site name, developer, score, rank."""
    return _load_jk_polygons()


@app.get("/api/district-velocity-trend/{district}")
def district_velocity_trend(district: str):
    """Monthly registrations count for a district over last 12 months.
    Source: objective_lots.register_date.
    """
    with _conn() as c:
        rows = c.execute("""
            SELECT substr(register_date, 1, 7) AS m, COUNT(*) n
              FROM objective_lots
             WHERE district = ?
               AND register_date IS NOT NULL
               AND register_date >= date('now', '-12 months')
          GROUP BY 1 ORDER BY 1""", (district,)).fetchall()
    # Fill empty months
    import datetime as dt
    today = dt.date.today().replace(day=1)
    months = []
    y, m = today.year, today.month
    for _ in range(12):
        months.append(f"{y:04d}-{m:02d}")
        m -= 1
        if m == 0: m = 12; y -= 1
    months.reverse()
    series = {r["m"]: r["n"] for r in rows}
    return [{"month": m, "n": series.get(m, 0)} for m in months]


class WhatIfPOI(BaseModel):
    cat: str
    lat: float
    lon: float
    name: Optional[str] = None


class AnalyzeIn(BaseModel):
    cad: Optional[str] = None
    lat: Optional[float] = None
    lon: Optional[float] = None
    name: Optional[str] = None
    audience: Optional[str] = "balanced"
    what_if: Optional[list] = None       # [{cat, lat, lon, name?}]
    remove_cats: Optional[list] = None   # ["metro"]


@app.post("/api/analyze")
def analyze(payload: AnalyzeIn):
    lat, lon = payload.lat, payload.lon

    # If only cad given, try cached polygon
    if (lat is None or lon is None) and payload.cad:
        fname = (ROOT / "cache" / "parcel_polygons" /
                 f"{payload.cad.replace(':','_')}.geojson")
        if fname.exists():
            gj = json.loads(fname.read_text())
            geom = gj.get("geometry") or (gj.get("features",[{}])[0].get("geometry")
                                          if gj.get("type")=="FeatureCollection" else None)
            if geom and geom.get("type") in ("Polygon","MultiPolygon"):
                pts = geom["coordinates"][0] if geom["type"]=="Polygon" else geom["coordinates"][0][0]
                lon = sum(p[0] for p in pts) / len(pts)
                lat = sum(p[1] for p in pts) / len(pts)

    if lat is None or lon is None:
        raise HTTPException(400, "lat+lon required (or upload polygon for cad)")

    weights = AUDIENCES.get(payload.audience or "balanced", AUDIENCES["balanced"])
    weights = {k: v for k, v in weights.items() if k != "label"}
    result = score_point(lat, lon, weights,
                          what_if=payload.what_if,
                          remove_cats=payload.remove_cats)
    result["input"] = {"cad": payload.cad, "lat": lat, "lon": lon,
                       "name": payload.name, "audience": payload.audience or "balanced",
                       "what_if": payload.what_if, "remove_cats": payload.remove_cats}
    # Include cached polygon if present
    if payload.cad:
        fname = (ROOT / "cache" / "parcel_polygons" /
                 f"{payload.cad.replace(':','_')}.geojson")
        if fname.exists():
            try: result["polygon"] = json.loads(fname.read_text())
            except: pass
    return result


# Service Worker must be served from root scope so it can intercept all tile requests
@app.get("/sw.js")
def sw():
    sw_path = STATIC / "sw.js"
    if not sw_path.exists():
        raise HTTPException(404)
    return FileResponse(sw_path, media_type="application/javascript",
                        headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"})


# Static UI (mount last)
if STATIC.exists():
    app.mount("/", StaticFiles(directory=STATIC, html=True), name="static")
