#!/usr/bin/env python3
"""
Fetch building and (optionally) indoor room geometry from OpenStreetMap for a named place
and export GeoJSON suitable for 3D visualization.

Outputs:
- Buildings (as polygons / perimeters / wall tubes)
- Rooms (as polygons / perimeters / wall tubes)
- NEW: Room interiors (simple polygons of the floor area, i.e., footprint inset)

Key options you may care about:
  --geom {polygons,perimeters,walls}
  --clip-room-walls, --clip-mode {wall,footprint}, --room-gap
  --constrain-rooms-inside / --no-constrain-rooms-inside
  --room-interior-output, --room-interior-inset, --room-interior-color
"""

import argparse, json, sys, time, re, unicodedata
from typing import List, Tuple, Dict, Any, Iterable
from urllib.parse import urlencode, quote_plus
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError

from shapely.geometry import (
    LineString, Polygon, MultiPolygon, GeometryCollection, mapping
)
from shapely.geometry.base import BaseGeometry
from shapely.ops import unary_union
from shapely.geometry import CAP_STYLE, JOIN_STYLE
from pyproj import CRS, Transformer

DEFAULT_NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
DEFAULT_OVERPASS_MIRRORS = [
    "https://overpass-api.de/api/interpreter",
    "https://overpass.kumi.systems/api/interpreter",
    "https://overpass.openstreetmap.ru/api/interpreter",
    "https://overpass.openstreetmap.fr/api/interpreter",
]
DEFAULT_TIMEOUT = 30
DEFAULT_METERS_PER_LEVEL = 3.0

# ------------------------------ small utils ------------------------------

def slugify(v: str) -> str:
    v = unicodedata.normalize("NFKD", v).encode("ascii", "ignore").decode("ascii")
    v = re.sub(r"[^\w\s-]", "", v).strip().lower()
    return re.sub(r"[-\s]+", "-", v) or "output"

def _read_resp(resp):
    charset = resp.headers.get_content_charset() or "utf-8"
    txt = resp.read().decode(charset, errors="replace")
    try:
        return json.loads(txt), None
    except Exception as e:
        return None, f"Failed to decode JSON: {e}\n{txt[:2000]}"

def http_get_json(url, headers, data=None, method="GET", timeout=DEFAULT_TIMEOUT):
    req = Request(url, data=data, method=method, headers=headers)
    with urlopen(req, timeout=timeout) as resp:
        return _read_resp(resp)

def with_retries(func, *, max_retries, base_sleep, debug):
    def run(*args, **kwargs):
        last = None
        for attempt in range(max_retries):
            try:
                result, nonfatal = func(*args, **kwargs)
                if nonfatal: raise RuntimeError(nonfatal)
                return result
            except HTTPError as e:
                last = f"HTTP {e.code} {e.reason}"
                ra = int(e.headers.get("Retry-After", "0") or 0)
                sleep = max(ra, base_sleep * (2**attempt))
                if debug: print(f"[retry] {last}; sleeping {sleep:.1f}s", file=sys.stderr)
                time.sleep(sleep)
            except URLError as e:
                last = f"URL error: {e.reason}"
                sleep = base_sleep * (2**attempt)
                if debug: print(f"[retry] {last}; sleeping {sleep:.1f}s", file=sys.stderr)
                time.sleep(sleep)
            except Exception as e:
                last = str(e)
                sleep = base_sleep * (2**attempt)
                if debug: print(f"[retry] {last}; sleeping {sleep:.1f}s", file=sys.stderr)
                time.sleep(sleep)
        raise RuntimeError(last or "failed after retries")
    return run

def nominatim_search(query, email, url, max_retries, base_sleep, debug):
    params = {"q": query, "format": "jsonv2", "limit": 1, "addressdetails": 1, "polygon_geojson": 1}
    full = f"{url}?{urlencode(params, quote_via=quote_plus)}"
    headers = {"User-Agent": f"osm-building-polygons/2.5 (+{email})" if email else "osm-building-polygons/2.5",
               "Accept": "application/json"}
    getter = with_retries(lambda: http_get_json(full, headers=headers),
                          max_retries=max_retries, base_sleep=base_sleep, debug=debug)
    return getter()

def overpass_query(query, email, mirrors, override_url, max_retries, base_sleep, debug):
    headers = {"User-Agent": f"osm-building-polygons/2.5 (+{email})" if email else "osm-building-polygons/2.5",
               "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}
    data = urlencode({"data": query}).encode("utf-8")
    mirror_list = [override_url] if override_url else mirrors[:]
    last = None
    for i, url in enumerate(mirror_list):
        if debug: print(f"[i] Overpass mirror {i+1}/{len(mirror_list)}: {url}", file=sys.stderr)
        def post(): return http_get_json(url, headers=headers, data=data, method="POST")
        try:
            runner = with_retries(post, max_retries=max_retries, base_sleep=base_sleep, debug=debug)
            return runner()
        except Exception as e:
            last = str(e)
            if debug: print(f"[!] Mirror failed: {url} -> {last}", file=sys.stderr)
            time.sleep(0.75)
            continue
    raise RuntimeError(last or "all Overpass mirrors failed")

# ------------------------------ geometry helpers ------------------------------

def to_lonlat_list(geom): return [[pt["lon"], pt["lat"]] for pt in geom]
def is_closed(c): return c and c[0] == c[-1]
def close_ring(c): return c if is_closed(c) else c + [c[0]]

def ring_signed_area(r):
    area = 0.0
    for i in range(len(r)-1):
        x1,y1=r[i]; x2,y2=r[i+1]
        area += (x1*y2 - x2*y1)
    return 0.5*area
def orient_ccw(r): return r if ring_signed_area(r) > 0 else list(reversed(r))
def orient_cw(r):  return r if ring_signed_area(r) < 0 else list(reversed(r))

def _local_metric_crs_for(lon, lat):
    zone = int((lon+180.0)//6)+1
    epsg = (32600 if lat>=0 else 32700)+zone
    return CRS.from_epsg(epsg)

def _project_coords(coords, tr: Transformer):
    xs, ys = tr.transform([c[0] for c in coords], [c[1] for c in coords])
    return [[xs[i], ys[i]] for i in range(len(xs))]

def _unproject_coords(coords, tr: Transformer):
    lons, lats = tr.transform([c[0] for c in coords], [c[1] for c in coords])
    return [[lons[i], lats[i]] for i in range(len(lons))]

def ring_to_wall_tube_geom(ring, thickness, miter_limit, join_style="miter"):
    ring = close_ring(ring)
    lon_c = sum(p[0] for p in ring[:-1]) / max(1,(len(ring)-1))
    lat_c = sum(p[1] for p in ring[:-1]) / max(1,(len(ring)-1))
    crs_geog = CRS.from_epsg(4326)
    crs_metric = _local_metric_crs_for(lon_c, lat_c)
    fwd = Transformer.from_crs(crs_geog, crs_metric, always_xy=True)
    inv = Transformer.from_crs(crs_metric, crs_geog, always_xy=True)
    ring_xy = _project_coords(ring, fwd)
    line = LineString(ring_xy)
    js = {"miter": JOIN_STYLE.mitre, "round": JOIN_STYLE.round, "bevel": JOIN_STYLE.bevel}.get(join_style, JOIN_STYLE.mitre)
    tube = line.buffer(thickness/2.0, cap_style=CAP_STYLE.flat, join_style=js, mitre_limit=max(1.0,float(miter_limit)))
    return tube, inv, fwd

def tube_geom_to_lonlat_polygons(geom: BaseGeometry, inv: Transformer):
    if geom.is_empty: return []
    gj = mapping(geom)
    parts = [gj["coordinates"]] if gj["type"]=="Polygon" else (gj["coordinates"] if gj["type"]=="MultiPolygon" else [])
    res = []
    def unproj(r): return _unproject_coords(list(r), inv)
    for part in parts:
        outer = orient_ccw(close_ring(unproj(part[0])))
        holes = [orient_cw(close_ring(unproj(h))) for h in part[1:]]
        res.append([outer]+holes)
    return res

def generic_poly_geom_to_lonlat(geom: BaseGeometry, inv: Transformer):
    """Polygon/MultiPolygon -> list of [outer, holes...] in lon/lat with correct winding."""
    return tube_geom_to_lonlat_polygons(geom, inv)

# ------------------------------ OSM → GeoJSON + enrichment ------------------------------

def point_in_ring(pt, ring):
    x,y=pt; inside=False
    for i in range(len(ring)-1):
        x1,y1=ring[i]; x2,y2=ring[i+1]
        if ((y1>y)!=(y2>y)) and (x < (x2-x1)*(y-y1)/(y2-y1+1e-15)+x1): inside=not inside
    return inside

def stitch_rings(ways):
    segs=[c[:] for c in ways if len(c)>=2]; rings=[]; used=[False]*len(segs)
    def key(p): return (round(p[0],7),round(p[1],7))
    sm, em = {}, {}
    for i,s in enumerate(segs):
        sm.setdefault(key(s[0]), []).append(i)
        em.setdefault(key(s[-1]), []).append(i)
    for i in range(len(segs)):
        if used[i]: continue
        chain = segs[i][:]; used[i]=True
        changed=True
        while changed:
            changed=False
            ek=key(chain[-1])
            for j in sm.get(ek, []):
                if used[j]: continue
                chain.extend(segs[j][1:]); used[j]=True; changed=True; break
            if changed: continue
            sk=key(chain[0])
            for j in em.get(sk, []):
                if used[j]: continue
                chain = segs[j][:-1] + chain; used[j]=True; changed=True; break
        chain = close_ring(chain)
        if len(chain)>=4: rings.append(chain)
    return rings

def build_polygons_from_relation(members):
    outers=[to_lonlat_list(m["geometry"]) for m in members if m.get("type")=="way" and m.get("role")=="outer" and m.get("geometry")]
    inners=[to_lonlat_list(m["geometry"]) for m in members if m.get("type")=="way" and m.get("role")=="inner" and m.get("geometry")]
    outer_rings=stitch_rings(outers); inner_rings=stitch_rings(inners)
    polys=[]
    for outer in outer_rings:
        holes=[inner for inner in inner_rings if inner and outer and point_in_ring(inner[0], outer)]
        polys.append([outer]+holes)
    if not polys and inner_rings: polys=[[r] for r in inner_rings]
    return polys

def parse_numeric(v):
    try: return float(str(v).strip())
    except Exception: return None

def enrich_height_props(tags):
    props={}
    levels = tags.get("building:levels") or tags.get("levels")
    min_level = tags.get("building:min_level") or tags.get("min_level")
    height_tag = tags.get("height"); min_height_tag = tags.get("min_height")
    def to_int(x):
        try: return int(float(str(x)))
        except Exception: return None
    lvl=to_int(levels); min_lvl=to_int(min_level)
    h=parse_numeric(height_tag); mh=parse_numeric(min_height_tag)
    src="none"
    if h is not None and h>=0: props["height"]=h; src="height"
    elif lvl is not None and lvl>0: props["height"]=round(lvl*DEFAULT_METERS_PER_LEVEL,3); src="levels"
    if mh is not None and mh>=0: props["min_height"]=mh; src = src if src!="none" else "height"
    elif min_lvl is not None and min_lvl>0: props["min_height"]=round(min_lvl*DEFAULT_METERS_PER_LEVEL,3); src = src if src!="none" else "levels"
    if lvl is not None: props["levels"]=lvl
    if min_lvl is not None: props["min_level"]=min_lvl
    props["height_source"]=src
    return props

def enrich_room_props(tags, default_room_height):
    props={}
    lvl_tag = tags.get("level") or tags.get("level:ref")
    if isinstance(lvl_tag,str) and ";" in lvl_tag: lvl_tag=lvl_tag.split(";")[0]
    lvl=parse_numeric(lvl_tag)
    if lvl is not None and abs(lvl-int(lvl))<1e-9: props["level"]=int(lvl)
    elif lvl is not None: props["level"]=lvl
    h=parse_numeric(tags.get("height")); mh=parse_numeric(tags.get("min_height"))
    if h is not None and h>=0:
        props["height"]=h
        props["min_height"]= mh if (mh is not None and mh>=0) else (round(lvl*DEFAULT_METERS_PER_LEVEL,3) if lvl is not None else 0.0)
    else:
        if lvl is not None:
            base=lvl*DEFAULT_METERS_PER_LEVEL; props["min_height"]=round(base,3); props["height"]=round(base+float(default_room_height),3)
        else:
            props["min_height"]=0.0; props["height"]=float(default_room_height)
    return props

def feature_iter_rings(feat):
    geom=feat.get("geometry") or {}; t=geom.get("type")
    if t=="Polygon":
        coords=geom.get("coordinates") or []
        if coords:
            yield coords[0],"outer"
            for inner in coords[1:]: yield inner,"inner"
    elif t=="MultiPolygon":
        for poly in geom.get("coordinates") or []:
            if not poly: continue
            yield poly[0],"outer"
            for inner in poly[1:]: yield inner,"inner"

def polygons_to_perimeter_lines(fc):
    out=[]
    for feat in fc.get("features", []):
        base=dict(feat.get("properties") or {})
        for ring, role in feature_iter_rings(feat):
            ring=close_ring(ring)
            out.append({"type":"Feature","properties":{**base,"ring_role":role},
                        "geometry":{"type":"LineString","coordinates":ring}})
    return {"type":"FeatureCollection","features":out}

def polygons_to_wall_tubes(fc, thickness, miter_limit, join_style):
    out=[]
    for feat in fc.get("features", []):
        base=dict(feat.get("properties") or {})
        geom=feat.get("geometry", {}); t=geom.get("type")
        if t=="Polygon":
            for i, ring in enumerate(geom.get("coordinates") or []):
                role="outer" if i==0 else "inner"
                tube, inv, _ = ring_to_wall_tube_geom(ring, thickness, miter_limit, join_style)
                for coords in tube_geom_to_lonlat_polygons(tube, inv):
                    out.append({"type":"Feature","properties":{**base,"ring_role":role},
                                "geometry":{"type":"Polygon","coordinates":coords}})
        elif t=="MultiPolygon":
            for poly in geom.get("coordinates") or []:
                for i, ring in enumerate(poly):
                    role="outer" if i==0 else "inner"
                    tube, inv, _ = ring_to_wall_tube_geom(ring, thickness, miter_limit, join_style)
                    for coords in tube_geom_to_lonlat_polygons(tube, inv):
                        out.append({"type":"Feature","properties":{**base,"ring_role":role},
                                    "geometry":{"type":"Polygon","coordinates":coords}})
    return {"type":"FeatureCollection","features":out}

def osm_to_geojson(osm):
    elems=osm.get("elements", [])
    nodes={e["id"]:e for e in elems if e["type"]=="node"}
    ways=[e for e in elems if e["type"]=="way"]
    rels=[e for e in elems if e["type"]=="relation"]
    feats=[]
    for w in ways:
        geom=w.get("geometry")
        if not geom:
            ids=w.get("nodes") or []; coords=[]
            for nid in ids:
                n=nodes.get(nid); 
                if n: coords.append([n["lon"], n["lat"]])
            if not coords: continue
        else:
            coords=to_lonlat_list(geom)
        ring=close_ring(coords)
        geometry={"type":"Polygon","coordinates":[ring]} if len(ring)>=4 else {"type":"LineString","coordinates":coords}
        props={"@id":f"way/{w['id']}"}; props.update(w.get("tags", {}))
        feats.append({"type":"Feature","properties":props,"geometry":geometry})
    for r in rels:
        members=r.get("members", [])
        polys=build_polygons_from_relation(members)
        if polys:
            geom={"type":"MultiPolygon","coordinates":[[ring for ring in p] for p in polys]}
        else:
            parts=[]
            for m in members:
                g=m.get("geometry")
                if g:
                    ring=close_ring(to_lonlat_list(g))
                    if len(ring)>=4: parts.append([ring])
            geom={"type":"MultiPolygon","coordinates":parts} if parts else None
        if geom:
            props={"@id":f"relation/{r['id']}"}; props.update(r.get("tags", {}))
            feats.append({"type":"Feature","properties":props,"geometry":geom})
    if not feats:
        for n in nodes.values():
            props={"@id":f"node/{n['id']}"}; props.update(n.get("tags", {}))
            feats.append({"type":"Feature","properties":props,"geometry":{"type":"Point","coordinates":[n["lon"], n["lat"]]}})
    return {"type":"FeatureCollection","features":feats}

def filter_building_features(fc):
    out=[]
    for f in fc.get("features", []):
        gtype=f.get("geometry", {}).get("type")
        tags=f.get("properties", {})
        if gtype in ("Polygon","MultiPolygon") and ("building" in tags):
            en=dict(tags); en.update(enrich_height_props(tags))
            out.append({"type":"Feature","properties":en,"geometry":f["geometry"]})
    return {"type":"FeatureCollection","features":out}

def filter_room_features(fc, default_room_height):
    out=[]
    for f in fc.get("features", []):
        gtype=f.get("geometry", {}).get("type")
        tags=f.get("properties", {})
        if gtype in ("Polygon","MultiPolygon") and (tags.get("indoor")=="room" or "room" in tags):
            en=dict(tags); en.update(enrich_room_props(tags, default_room_height))
            out.append({"type":"Feature","properties":en,"geometry":f["geometry"]})
    return {"type":"FeatureCollection","features":out}

def collect_outer_rings(fc):
    rings=[]
    for f in fc.get("features", []):
        g=f.get("geometry", {}); t=g.get("type")
        if t=="Polygon":
            cs=g.get("coordinates") or []
            if cs: rings.append(close_ring(cs[0]))
        elif t=="MultiPolygon":
            for poly in g.get("coordinates") or []:
                if poly: rings.append(close_ring(poly[0]))
    return rings

def apply_fill_color(fc, color_hex):
    if not isinstance(color_hex,str) or not re.fullmatch(r"#?[0-9A-Fa-f]{6}", color_hex or ""): return fc
    if not color_hex.startswith("#"): color_hex="#"+color_hex
    out=[]
    for f in fc.get("features", []):
        p=dict(f.get("properties") or {}); p["fill-color"]=color_hex
        out.append({"type":"Feature","properties":p,"geometry":f["geometry"]})
    return {"type":"FeatureCollection","features":out}

def polygons_transform(fc, mode, thickness, miter_limit, join_style):
    if mode=="polygons": return fc
    if mode=="perimeters": return polygons_to_perimeter_lines(fc)
    if mode=="walls": return polygons_to_wall_tubes(fc, thickness, miter_limit, join_style)
    return fc

# -------------- exclusion + interior mask builders (metric CRS of room) --------------

def build_exclusion_union_for_room(room_fwd: Transformer, building_outer_rings, tb, gap, miter_limit, join_style, clip_mode):
    parts=[]; js={"miter": JOIN_STYLE.mitre, "round": JOIN_STYLE.round, "bevel": JOIN_STYLE.bevel}.get(join_style, JOIN_STYLE.mitre)
    half=max(0.0,tb)/2.0; d=half+max(0.0,float(gap))
    for ring in building_outer_rings:
        ring_xy=_project_coords(close_ring(ring), room_fwd)
        boundary=LineString(ring_xy)
        if clip_mode=="wall":
            tube=boundary.buffer(half, cap_style=CAP_STYLE.flat, join_style=js, mitre_limit=max(1.0,float(miter_limit)))
            if d>half: tube=tube.buffer(d-half, join_style=js)
            if not tube.is_empty: parts.append(tube)
        else:  # footprint
            fp=Polygon(ring_xy); 
            if not fp.is_valid: fp=fp.buffer(0)
            if fp.is_empty: continue
            band=boundary.buffer(d, cap_style=CAP_STYLE.flat, join_style=js, mitre_limit=max(1.0,float(miter_limit)))
            band_in=band.intersection(fp)
            if not band_in.is_empty: parts.append(band_in)
    return unary_union(parts) if parts else GeometryCollection()

def build_interior_mask_for_room(room_fwd: Transformer, building_outer_rings, tb, gap):
    parts=[]
    inset = (max(0.0,tb)/2.0) + max(0.0,float(gap))
    for ring in building_outer_rings:
        ring_xy=_project_coords(close_ring(ring), room_fwd)
        fp=Polygon(ring_xy)
        if not fp.is_valid: fp=fp.buffer(0)
        if fp.is_empty: continue
        inner=fp.buffer(-inset)
        if not inner.is_empty: parts.append(inner)
    return unary_union(parts) if parts else GeometryCollection()

# ------------------------------ NEW: room interiors ------------------------------

def rooms_to_interiors(rooms_src_fc: Dict[str, Any],
                       building_outer_rings: List[List[List[float]]],
                       room_inset: float,
                       building_thickness: float,
                       room_gap: float,
                       constrain_inside: bool) -> Dict[str, Any]:
    """
    Create interior polygons for rooms by buffering each room footprint inward by `room_inset`.
    Result is guaranteed to lie inside the building inset area when `constrain_inside` is True.
    """
    out_feats = []

    for feat in rooms_src_fc.get("features", []):
        props = dict(feat.get("properties") or {})
        geom = feat.get("geometry") or {}
        gtype = geom.get("type")
        if gtype not in ("Polygon", "MultiPolygon"):
            continue

        # Build a local CRS from this room's centroid
        # (use the outer of first polygon we see to estimate centroid)
        sample_ring = None
        if gtype == "Polygon":
            cs = geom.get("coordinates") or []
            if cs: sample_ring = cs[0]
        else:
            mps = geom.get("coordinates") or []
            if mps and mps[0]: sample_ring = mps[0][0]
        if not sample_ring:
            continue

        ring = close_ring(sample_ring)
        lon_c = sum(p[0] for p in ring[:-1]) / max(1,(len(ring)-1))
        lat_c = sum(p[1] for p in ring[:-1]) / max(1,(len(ring)-1))
        crs_geog = CRS.from_epsg(4326)
        crs_metric = _local_metric_crs_for(lon_c, lat_c)
        fwd = Transformer.from_crs(crs_geog, crs_metric, always_xy=True)
        inv = Transformer.from_crs(crs_metric, crs_geog, always_xy=True)

        # Build the room polygon(s) in metric CRS
        def to_metric_poly(outer: List[List[float]], holes: List[List[List[float]]]):
            outer_xy = _project_coords(close_ring(outer), fwd)
            holes_xy = [_project_coords(close_ring(h), fwd) for h in holes]
            poly = Polygon(outer_xy, holes_xy)
            if not poly.is_valid:
                poly = poly.buffer(0)
            return poly

        polys_metric = []
        if gtype == "Polygon":
            coords = geom["coordinates"]
            outer = coords[0]; holes = coords[1:] if len(coords) > 1 else []
            p = to_metric_poly(outer, holes)
            if not p.is_empty:
                polys_metric.append(p)
        else:  # MultiPolygon
            for part in geom["coordinates"]:
                if not part: continue
                outer = part[0]; holes = part[1:] if len(part) > 1 else []
                p = to_metric_poly(outer, holes)
                if not p.is_empty:
                    polys_metric.append(p)

        if not polys_metric:
            continue

        union_fp = unary_union(polys_metric) if len(polys_metric) > 1 else polys_metric[0]

        # Inset inward by room_inset
        inset = max(0.0, float(room_inset))
        interior_geom = union_fp.buffer(-inset)

        # Optionally constrain interiors to the building inset area
        if constrain_inside and building_outer_rings:
            interior_mask = build_interior_mask_for_room(fwd, building_outer_rings, building_thickness, room_gap)
            if not interior_mask.is_empty:
                interior_geom = interior_geom.intersection(interior_mask)

        if interior_geom.is_empty:
            continue

        # Emit polygons in lon/lat with proper winding
        for poly_coords in generic_poly_geom_to_lonlat(interior_geom, inv):
            out_feats.append({
                "type": "Feature",
                "properties": props,
                "geometry": {"type": "Polygon", "coordinates": poly_coords},
            })

    return {"type": "FeatureCollection", "features": out_feats}

# ------------------------------ CLI ------------------------------

def main():
    ap=argparse.ArgumentParser(description="Fetch OSM building & room geometry and write GeoJSON.")
    ap.add_argument("place")
    ap.add_argument("-o","--output")
    ap.add_argument("--buildings-output")
    ap.add_argument("--rooms-output")
    ap.add_argument("--room-interior-output", help="Output path for room interior polygons (default: <slug>-rooms-interiors.geojson)")
    ap.add_argument("--default-room-height", type=float, default=3.0)
    ap.add_argument("--email", default="")
    ap.add_argument("--debug", action="store_true")
    ap.add_argument("--overpass-url", default="")
    ap.add_argument("--nominatim-url", default=DEFAULT_NOMINATIM_URL)
    ap.add_argument("--max-retries", type=int, default=5)
    ap.add_argument("--base-sleep", type=float, default=1.0)
    ap.add_argument("--mode", choices=["buildings","outline"], default="buildings")
    ap.add_argument("--geom", choices=["polygons","perimeters","walls"], default="polygons")

    ap.add_argument("--wall-thickness", type=float, default=0.6)
    ap.add_argument("--room-wall-thickness", type=float, default=None)
    ap.add_argument("--miter-limit", type=float, default=4.0)
    ap.add_argument("--join", choices=["miter","round","bevel"], default="miter")

    ap.add_argument("--clip-room-walls", action="store_true")
    ap.add_argument("--clip-mode", choices=["wall","footprint"], default="wall")
    ap.add_argument("--room-gap", type=float, default=0.15)

    ap.add_argument("--building-color", default="#8B4513")
    ap.add_argument("--room-color", default="#2E64FE")
    ap.add_argument("--room-interior-color", default="#87A9FF")

    # default: constrain inside; allow disabling
    ap.set_defaults(constrain_rooms_inside=True)
    ap.add_argument("--no-constrain-rooms-inside", dest="constrain_rooms_inside", action="store_false",
                    help="Do not crop room walls/interiors to the inset building footprint")

    ap.add_argument("--room-interior-inset", type=float,
                    help="Meters to inset room interiors. Default = half of room wall thickness")

    args=ap.parse_args()

    slug=slugify(args.place)
    out_path=args.output
    buildings_out=args.buildings_output or f"{slug}-buildings.geojson"
    rooms_out=args.rooms_output or f"{slug}-rooms.geojson"
    interiors_out=args.room_interior_output or f"{slug}-rooms-interiors.geojson"

    try:
        if args.debug: print(f"[i] Searching Nominatim: {args.place}", file=sys.stderr)
        nom=nominatim_search(args.place, args.email, args.nominatim_url, args.max_retries, args.base_sleep, args.debug)

        bbox=None; best=None
        if nom:
            best=nom[0]
            try:
                bb=best.get("boundingbox")
                if bb and len(bb)==4:
                    south,north,west,east=bb
                    bbox=(float(south), float(west), float(north), float(east))
            except Exception: bbox=None

        if not nom:
            if args.debug: print("[!] Nominatim empty; Overpass fallback by name", file=sys.stderr)
            name_q = f"""
[out:json][timeout:25];
(
  way["building"]["name"={json.dumps(args.place)}];
  relation["building"]["name"={json.dumps(args.place)}];
  way["amenity"="mall"]["name"={json.dumps(args.place)}];
  relation["amenity"="mall"]["name"={json.dumps(args.place)}];
);
(._;>;);
out body;"""
            site_osm=overpass_query(name_q, args.email, DEFAULT_OVERPASS_MIRRORS, args.overpass_url.strip(),
                                    args.max_retries, args.base_sleep, args.debug)
        else:
            osm_type=best.get("osm_type",""); osm_id=int(best.get("osm_id",0))
            if args.debug: print(f"[i] Nominatim best match: {osm_type} {osm_id}", file=sys.stderr)
            def build_overpass_query_from_element(osm_type, osm_id):
                if osm_type.lower().startswith("w"): t="way"
                elif osm_type.lower().startswith("r"): t="relation"
                elif osm_type.lower().startswith("n"): t="node"
                else: t=osm_type
                if t=="node": return ""
                return f"[out:json][timeout:25];\n{t}({osm_id});\n(._;>;);\nout body;"
            over_q=build_overpass_query_from_element(osm_type, osm_id)
            if not over_q and bbox:
                over_q = f"""
[out:json][timeout:25];
(
  way["building"]({bbox[0]},{bbox[1]},{bbox[2]},{bbox[3]});
  relation["building"]({bbox[0]},{bbox[1]},{bbox[2]},{bbox[3]});
);
(._;>;);
out body;"""
            site_osm=overpass_query(over_q or name_q, args.email, DEFAULT_OVERPASS_MIRRORS, args.overpass_url.strip(),
                                    args.max_retries, args.base_sleep, args.debug)

        def extract_site_polygons(osm):
            elems=osm.get("elements", []); ways=[e for e in elems if e["type"]=="way"]; rels=[e for e in elems if e["type"]=="relation"]
            rings=[]
            for r in rels:
                polys=build_polygons_from_relation(r.get("members", []))
                for p in polys:
                    if p: rings.append(p[0])
            if not rings:
                for w in ways:
                    g=w.get("geometry"); 
                    if not g: continue
                    ring=close_ring(to_lonlat_list(g))
                    if len(ring)>=4: rings.append(ring)
            return rings

        site_rings=extract_site_polygons(site_osm)

        if site_rings:
            if args.debug: print(f"[i] Using {len(site_rings)} outline ring(s)", file=sys.stderr)
            def rings_to_overpass(rings, selector_pairs):
                parts=[]
                for ring in rings:
                    latlon=[f"{lat} {lon}" for lon,lat in ring]; poly=" ".join(latlon)
                    for sel in selector_pairs: parts.append(f'{sel}(poly:"{poly}");')
                return "\n  ".join(parts)
            b_sel=['way["building"]','relation["building"]']
            r_sel=['way["indoor"="room"]','relation["indoor"="room"]','way["room"]','relation["room"]']
            buildings_query=f"[out:json][timeout:60];\n(\n  {rings_to_overpass(site_rings,b_sel)}\n);\n(._;>;);\nout body;"
            rooms_query=f"[out:json][timeout:60];\n(\n  {rings_to_overpass(site_rings,r_sel)}\n);\n(._;>;);\nout body;"
        elif bbox:
            if args.debug: print("[i] Using bbox fallback", file=sys.stderr)
            def bbox_to_overpass(sel, bb):
                south,west,north,east = bb
                return f'  way{sel}({south},{west},{north},{east});\n  relation{sel}({south},{west},{north},{east});'
            buildings_query=f"[out:json][timeout:60];\n(\n{bbox_to_overpass('[\"building\"]', bbox)}\n);\n(._;>;);\nout body;"
            rooms_query=f"[out:json][timeout:60];\n(\n{bbox_to_overpass('[\"indoor\"=\"room\"]', bbox)}\n{bbox_to_overpass('[\"room\"]', bbox)}\n);\n(._;>;);\nout body;"
        else:
            raise RuntimeError("No site geometry or bbox available")

        buildings_osm=overpass_query(buildings_query, args.email, DEFAULT_OVERPASS_MIRRORS, args.overpass_url.strip(),
                                     args.max_retries, args.base_sleep, args.debug)
        rooms_osm=overpass_query(rooms_query, args.email, DEFAULT_OVERPASS_MIRRORS, args.overpass_url.strip(),
                                 args.max_retries, args.base_sleep, args.debug)

        buildings_src = filter_building_features(osm_to_geojson(buildings_osm))
        rooms_src     = filter_room_features(osm_to_geojson(rooms_osm), args.default_room_height)

        building_thickness=float(args.wall_thickness)
        room_thickness=float(args.room_wall_thickness) if args.room_wall_thickness is not None else building_thickness

        # ---- buildings
        if args.geom=="walls": buildings_fc = polygons_to_wall_tubes(buildings_src, building_thickness, args.miter_limit, args.join)
        elif args.geom=="perimeters": buildings_fc = polygons_to_perimeter_lines(buildings_src)
        else: buildings_fc = buildings_src
        buildings_fc = apply_fill_color(buildings_fc, args.building_color)

        # ---- rooms (walls/perimeters/polygons)
        if args.geom=="walls":
            building_outer_rings = collect_outer_rings(buildings_src)
            out_feats=[]
            for feat in rooms_src.get("features", []):
                base=dict(feat.get("properties") or {})
                geom=feat.get("geometry", {}); t=geom.get("type")
                rings=[]
                if t=="Polygon":
                    rings=[(geom.get("coordinates")[0], "outer")] + [(r,"inner") for r in (geom.get("coordinates")[1:] or [])]
                elif t=="MultiPolygon":
                    for poly in geom.get("coordinates") or []:
                        if not poly: continue
                        rings.append((poly[0],"outer")); rings += [(r,"inner") for r in poly[1:]]
                for ring, role in rings:
                    room_tube, room_inv, room_fwd = ring_to_wall_tube_geom(ring, room_thickness, args.miter_limit, args.join)
                    geom_work = room_tube
                    if args.clip_room_walls and building_outer_rings:
                        exclusion = build_exclusion_union_for_room(room_fwd, building_outer_rings, building_thickness,
                                                                   args.room_gap, args.miter_limit, args.join, args.clip_mode)
                        if not exclusion.is_empty:
                            geom_work = geom_work.difference(exclusion)
                    if args.constrain_rooms_inside and building_outer_rings:
                        interior_mask = build_interior_mask_for_room(room_fwd, building_outer_rings, building_thickness, args.room_gap)
                        if not interior_mask.is_empty:
                            geom_work = geom_work.intersection(interior_mask)
                    for coords in tube_geom_to_lonlat_polygons(geom_work, room_inv):
                        out_feats.append({"type":"Feature","properties":{**base,"ring_role":role},
                                          "geometry":{"type":"Polygon","coordinates":coords}})
            rooms_fc = {"type":"FeatureCollection","features":out_feats}
        elif args.geom=="perimeters":
            rooms_fc = polygons_to_perimeter_lines(rooms_src)
        else:
            rooms_fc = rooms_src
        rooms_fc = apply_fill_color(rooms_fc, args.room_color)

        # ---- NEW: room interiors (always from room footprints)
        building_outer_rings_for_interiors = collect_outer_rings(buildings_src)
        default_inset = (room_thickness / 2.0)
        room_inset = float(args.room_interior_inset) if args.room_interior_inset is not None else default_inset
        interiors_fc = rooms_to_interiors(
            rooms_src,
            building_outer_rings_for_interiors,
            room_inset,
            building_thickness,
            args.room_gap,
            args.constrain_rooms_inside
        )
        interiors_fc = apply_fill_color(interiors_fc, args.room_interior_color)

        # write files
        with open(buildings_out,"w",encoding="utf-8") as f: json.dump(buildings_fc,f,ensure_ascii=False,indent=2)
        with open(rooms_out,"w",encoding="utf-8") as f: json.dump(rooms_fc,f,ensure_ascii=False,indent=2)
        with open(interiors_out,"w",encoding="utf-8") as f: json.dump(interiors_fc,f,ensure_ascii=False,indent=2)
        print(buildings_out); print(rooms_out); print(interiors_out)

        if out_path:
            merged={"type":"FeatureCollection","features":buildings_fc["features"]+rooms_fc["features"]+interiors_fc["features"]}
            with open(out_path,"w",encoding="utf-8") as f: json.dump(merged,f,ensure_ascii=False,indent=2)
            print(out_path)

    except Exception as e:
        print(f"[!] Error: {e}", file=sys.stderr); sys.exit(1)

if __name__ == "__main__":
    main()
