"""Johnson City Data Center / Bitcoin mine eligibility analysis.

NOTE: based on DRAFT ordinance text under consideration (Section 6.20.3.2),
not enacted law. Treat results as informational only.

Setback ordinance (verbatim, 6.20.3.2.I):
  "No Data Center facility shall be located within 200 feet of any residential
   use or district. This includes any zoning district that permits single family
   residences or dwellings, as well as RM-3, RM-4, and RM-5 districts.
   The measurement shall be made from the nearest property line or zoning line
   of the residential use or district, whichever is closer, to the nearest
   property line of the Data Center."

Also from 6.20.3.2.I (DC-specific yard setbacks inside the parcel):
  "The minimum front yard setback shall be sixty (60) feet. The minimum side
   and rear setbacks shall be fifty (50) feet."
These OVERRIDE the I-2 default yard setbacks for a Data Center facility.

Data Centers in I-2 are a SPECIAL EXCEPTION (BZA approval required) -- this
analysis filters on geometric eligibility only; passing the geometric gate is
necessary but not sufficient for a permit.

Interpretation:
  * Data Center is permitted only on I-2 (Heavy Industrial), as special exception.
  * Source of measurement = polygon boundary of EITHER a residential-permitting
    zoning district OR a parcel with a residential use; whichever is closer.
  * Target of measurement = property line of the Data Center parcel.
  * Buffering each source polygon by 200ft and testing parcel-boundary
    intersection captures this exactly (line-to-line distance via buffer set).

Three eligibility views per parcel:
  STRICT   : whole parcel sits >= 200ft from any residential line.
             (Treats the entire parcel as the "Data Center property line.")
  BUILDABLE: largest contiguous patch inside parcel AND inside I-2 zoning AND
             >= 200ft from any residential line. A developer could subdivide a
             compliant sub-parcel from this footprint. The patch itself is
             potentially developable land.
  SITE-ABLE: BUILDABLE patch eroded inward by the DC's own 50ft side/rear
             yard setback (6.20.3.2.I). What's left is the maximum BUILDING
             footprint achievable on the parcel. This is the metric that
             answers "how big a facility can you actually fit here."

Wargame: --exclude-gislinks removes those parcels from the residential
exclusion set (simulate a buyer demolishing the houses on those parcels).

Exploratory wider buffers (500/1000ft) are reported but labeled non-ordinance.
"""
from __future__ import annotations
import argparse
import sys
import time
from pathlib import Path

import geopandas as gpd
import pandas as pd
from shapely import make_valid, unary_union
from shapely.geometry import MultiPolygon, Polygon
from shapely.ops import polylabel

ROOT = Path(__file__).resolve().parent.parent
CACHE = ROOT / "cache"
OUT = ROOT / "out"
OUT.mkdir(exist_ok=True)

# Default min largest-contiguous-buildable-patch (sqft). Tunable via --min-sqft.
# Reference: small container mine ~1-5k sqft; warehouse mine ~20k+ sqft.
DEFAULT_MIN_USABLE_SQFT = 5000

# DC building yard setback inside the parcel (6.20.3.2.I): 60ft front / 50ft
# side & rear. We erode the buildable polygon by 50ft on all sides (developer
# will orient the building so the side abutting the road takes the front
# setback; the difference between 60 and 50 is small relative to parcel size).
DC_INTERNAL_SETBACK_FT = 50

# Strict-eligible parcels under this much I-2 acreage are filtered out (mostly
# right-of-way slivers in the source data that pass the clearance test
# trivially because they're a few square feet in size). Tunable via CLI.
DEFAULT_STRICT_MIN_I2_ACRES = 0.10  # ~4,356 sqft

# Minimum building width (ft). The siteable patch must contain an inscribed
# circle of this diameter so a real building can fit, not just a thin strip.
# 50ft default = a 50x50 building footprint can fit somewhere in the patch.
DEFAULT_MIN_BUILDING_WIDTH_FT = 50

# JC Subdivision Regs §4-4.1: every subdivided lot must have at least 50 ft
# of frontage on a public right-of-way (40 ft on cul-de-sac). We approximate
# road frontage by finding the portion of a parcel's boundary that does NOT
# lie along another parcel's boundary (roads are the gaps between parcels).
DEFAULT_MIN_FRONTAGE_FT = 50

# Half-width of a typical road right-of-way. The road centerline file is a
# LineString through the middle of each road; a parcel that fronts the road
# has its boundary roughly ROW_HALF_WIDTH_FT from the centerline. We treat any
# parcel-boundary segment within this distance of a public road centerline as
# road frontage. Set generously (50ft) to catch wider feeder/collector ROWs
# that can be 80-100ft total (half-width 40-50ft).
ROW_HALF_WIDTH_FT = 50

# Road classes / drive types treated as non-public (excluded from frontage).
NONPUBLIC_ROAD_CLASSES = {"Private", "Retired"}

# Zoning districts that satisfy the ordinance trigger: "any zoning district
# that permits single family residences or dwellings, as well as RM-3, RM-4,
# and RM-5 districts."
#
# The "as well as RM-3/RM-4/RM-5" carve-in implies the preceding clause is
# narrow (SFR-permitting only) -- if "or dwellings" meant any dwelling, the
# RM-3/4/5 enumeration would be surplusage. So we include only districts that
# permit single-family residences, plus the explicit RM-3/4/5.
#
# MX and MX-1 EXCLUDE single-family detached dwellings by ordinance and are
# not named in the carve-in, so they are NOT residential districts here.
# (Any actual dwelling on MX/MX-1 land is still captured via the residential
# USE path -- LANDUSE codes and address-point joins.)
RESIDENTIAL_DISTRICT_ZONECLASSES = {
    # Residential districts (SFR permitted by-right)
    "R-1", "R-2", "R-2A", "R-2B", "R-2C", "R-3", "R-4", "R-5", "R-6",
    # Planned Residential (SFR permitted)
    "RP-2", "RP-3", "RP-4", "RP-5", "RP-6",
    # Residential-Office hybrid (SFR permitted)
    "RO-1", "RO-2",
    # Agricultural (farm SFR permitted)
    "A-1",
    # Manufactured home parks (explicitly named in ordinance carve-in)
    "RM-3", "RM-4", "RM-5",
}

# Parcel LANDUSE codes indicating an existing residential use.
# 11 Household Units; 12 Group Quarters; 13 Residential Hotels; 14 Mobile Home Parks.
RESIDENTIAL_LANDUSE_CODES = {"11", "12", "13", "14"}

# Parcel LANDUSE codes that disqualify a parcel from being a data-center site
# regardless of zoning -- linear infrastructure that physically can't host a
# building (active rail right-of-way is the main one). 41 = rail transportation.
NON_BUILDABLE_LANDUSE_CODES = {"41"}

# Owner-name patterns that indicate railroad-owned right-of-way even when
# LANDUSE is unset. Case-insensitive substring match on the OWNER field.
NON_BUILDABLE_OWNER_KEYWORDS = (
    "RAILROAD", "RAILWAY", " RY ", " RY,", " RY.", " R R ", " RR ",
    "CSX TRANSPORTATION", "NORFOLK SOUTHERN", " R/R",
)

# Address PLACETYPE values indicating a residence at that point.
RESIDENTIAL_PLACETYPES = {
    "SF RESIDENCE", "APARTMENT", "APARTMENTS", "MOBILE HOME",
    "CONDOMINIUM", "TOWNHOUSE", "DUPLEX", "PUBLIC HOUSING", "DORMITORY",
}

# Address PLACETYPE values that explicitly mark a parcel as currently vacant.
# When the address dataset (which is kept current for 911/utility purposes)
# marks a parcel vacant but its LANDUSE code is stale "household units" from
# the last assessment, we trust the address. Avoids treating long-cleared
# industrial lots as residential exclusions.
VACANT_PLACETYPES = {"VACANT LOT", "VACANT", "VACANT UNIT"}

# Buffers reported. 200 is the ordinance threshold; 500 / 1000 are exploratory.
SETBACKS_FT = [200, 500, 1000]
ORDINANCE_SETBACK_FT = 200


def stamp(msg: str, start: float | None = None) -> float:
    now = time.time()
    if start is None:
        print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
    else:
        print(f"[{time.strftime('%H:%M:%S')}] {msg} ({now - start:.1f}s)", flush=True)
    return now


def fix(geom):
    if geom is None or geom.is_empty:
        return geom
    if not geom.is_valid:
        return make_valid(geom)
    return geom


def _inscribed_radius(geom, tolerance: float = 5.0) -> float:
    """Largest inscribed circle radius (ft) across all polygon parts of geom."""
    if geom is None or geom.is_empty:
        return 0.0
    polys = []
    if isinstance(geom, Polygon):
        polys = [geom]
    elif isinstance(geom, MultiPolygon):
        polys = list(geom.geoms)
    else:
        try:
            for g in geom.geoms:
                if isinstance(g, Polygon):
                    polys.append(g)
                elif isinstance(g, MultiPolygon):
                    polys.extend(g.geoms)
        except AttributeError:
            return 0.0
    best = 0.0
    for p in polys:
        if p.is_empty or p.area < 1.0:
            continue
        try:
            pt = polylabel(p, tolerance=tolerance)
            r = p.exterior.distance(pt)
            if r > best:
                best = r
        except Exception:
            continue
    return best


def _write_gjs(gdf, path: Path) -> None:
    """Reproject a GeoDataFrame to EPSG:4326 and write as GeoJSON."""
    gdf = gdf.to_crs("EPSG:4326")
    if path.exists():
        path.unlink()
    gdf.to_file(path, driver="GeoJSON")
    print(f"  wrote {path.relative_to(ROOT)} ({len(gdf):,} features, {path.stat().st_size/1024:.1f} KB)")


def _max_polygon_area(geom) -> float:
    """Return area (sqft) of the single largest contiguous polygon piece."""
    if geom is None or geom.is_empty:
        return 0.0
    if isinstance(geom, Polygon):
        return geom.area
    if isinstance(geom, MultiPolygon):
        return max((g.area for g in geom.geoms), default=0.0)
    # GeometryCollection etc.
    try:
        polys = [g for g in geom.geoms if isinstance(g, (Polygon, MultiPolygon))]
        return max((_max_polygon_area(p) for p in polys), default=0.0)
    except AttributeError:
        return 0.0


def landuse_is_residential(landuse: str | None) -> bool:
    if landuse is None or pd.isna(landuse):
        return False
    prefix = str(landuse).split(" - ", 1)[0]
    codes = {c.strip() for c in prefix.split(":") if c.strip()}
    return bool(codes & RESIDENTIAL_LANDUSE_CODES)


def landuse_is_non_buildable(landuse: str | None) -> bool:
    if landuse is None or pd.isna(landuse):
        return False
    prefix = str(landuse).split(" - ", 1)[0]
    codes = {c.strip() for c in prefix.split(":") if c.strip()}
    return bool(codes & NON_BUILDABLE_LANDUSE_CODES)


def owner_is_railroad(owner: str | None) -> bool:
    if owner is None or pd.isna(owner):
        return False
    s = f" {str(owner).upper()} "
    return any(kw in s for kw in NON_BUILDABLE_OWNER_KEYWORDS)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument(
        "--exclude-gislinks", nargs="*", default=[],
        help="GISLINKs of parcels to treat as NON-residential (wargame mode).",
    )
    ap.add_argument(
        "--drop-a1", action="store_true",
        help="Sensitivity test: do NOT count A-1 (Agricultural) as a residential district.",
    )
    ap.add_argument(
        "--min-sqft", type=float, default=DEFAULT_MIN_USABLE_SQFT,
        help=f"Min largest-contiguous BUILDABLE patch (sqft) to report. "
             f"Default {DEFAULT_MIN_USABLE_SQFT}.",
    )
    ap.add_argument(
        "--min-building-sqft", type=float, default=None,
        help="If set, also require SITE-ABLE area (buildable patch after DC's "
             "own 50ft side/rear setback) >= this many sqft. Use this to filter "
             "for parcels where an actual building of this size can fit.",
    )
    ap.add_argument(
        "--out-suffix", type=str, default="",
        help="Suffix appended to output filenames so sweeps don't clobber the "
             "canonical CSVs. Example: --out-suffix _scenarioA -> "
             "eligible_200ft_buildable_scenarioA.csv",
    )
    ap.add_argument(
        "--no-geojson", action="store_true",
        help="Skip GeoJSON outputs (write CSVs only). Default writes GeoJSON "
             "for map rendering, reprojected back to EPSG:4326 (lon/lat).",
    )
    ap.add_argument(
        "--min-width-ft", type=float, default=DEFAULT_MIN_BUILDING_WIDTH_FT,
        help=f"Minimum width (ft) of an inscribed building footprint in the "
             f"siteable patch. Default {DEFAULT_MIN_BUILDING_WIDTH_FT} ft. "
             f"Set to 0 to disable shape filtering.",
    )
    ap.add_argument(
        "--strict-min-i2-acres", type=float, default=DEFAULT_STRICT_MIN_I2_ACRES,
        help=f"Strict-eligible parcels must have at least this much I-2 acreage "
             f"(filters out ROW slivers). Default {DEFAULT_STRICT_MIN_I2_ACRES}.",
    )
    ap.add_argument(
        "--min-frontage-ft", type=float, default=DEFAULT_MIN_FRONTAGE_FT,
        help=f"Minimum public-road frontage (ft) per JC Subdivision Reg §4-4.1. "
             f"Default {DEFAULT_MIN_FRONTAGE_FT}. Set to 0 to disable.",
    )
    args = ap.parse_args()
    exclude_gislinks = set(args.exclude_gislinks)
    min_sqft = args.min_sqft
    min_building_sqft = args.min_building_sqft
    out_suffix = args.out_suffix
    write_geojson = not args.no_geojson
    min_width_ft = args.min_width_ft
    strict_min_i2_acres = args.strict_min_i2_acres
    min_radius_ft = min_width_ft / 2.0
    min_frontage_ft = args.min_frontage_ft

    res_district_classes = set(RESIDENTIAL_DISTRICT_ZONECLASSES)
    if args.drop_a1:
        res_district_classes -= {"A-1"}

    # Wipe stale per-setback outputs with the matching suffix so missing tiers
    # (e.g., strict=0 at 500ft) don't leave behind files from previous runs.
    for ft in SETBACKS_FT:
        for tier in ("strict", "buildable", "siteable"):
            for ext in (".csv", ".geojson"):
                p = OUT / f"eligible_{ft}ft_{tier}{out_suffix}{ext}"
                if p.exists():
                    p.unlink()

    t0 = stamp("loading cached layers")
    zoning = gpd.read_parquet(CACHE / "zoning.parquet")
    parcels = gpd.read_parquet(CACHE / "parcels.parquet")
    addresses = gpd.read_parquet(CACHE / "addresses.parquet")
    roads = gpd.read_parquet(CACHE / "roads.parquet")
    stamp(f"loaded zoning={len(zoning):,} parcels={len(parcels):,} "
          f"addresses={len(addresses):,} roads={len(roads):,}", t0)
    assert zoning.crs == parcels.crs == addresses.crs == roads.crs

    # Public roads only -- private drives and retired segments don't count
    # toward subdivision-eligible road frontage.
    public_road_mask = (
        ~roads["ROADCLASS_PW"].astype(str).isin(NONPUBLIC_ROAD_CLASSES)
        & roads["PRIVDRIVETYPE"].isna()
    )
    public_roads = roads[public_road_mask].copy()
    print(f"  public roads: {len(public_roads):,} segments of {len(roads):,}")

    t1 = stamp("fixing invalid geometries")
    zoning["geometry"] = zoning.geometry.apply(fix)
    parcels["geometry"] = parcels.geometry.apply(fix)
    parcels = parcels[parcels.geometry.notna() & ~parcels.geometry.is_empty].copy()
    stamp("done", t1)

    # ---- I-2 footprint ----
    t1 = stamp("building I-2 footprint")
    i2_zones = zoning[zoning["ZONECLASS"] == "I-2"].copy()
    i2_union = unary_union(i2_zones.geometry.values)
    print(f"  I-2 zones: {len(i2_zones)}; total acres: {i2_union.area/43560:.1f}")
    stamp("done", t1)

    # ---- Candidate parcels: any parcel intersecting any I-2 zone ----
    t1 = stamp("identifying parcels intersecting I-2")
    parcels_sindex = parcels.sindex
    cand_idx = sorted(parcels_sindex.query(i2_union, predicate="intersects"))
    candidates = parcels.iloc[cand_idx].copy()
    candidates["i2_geom"] = candidates.geometry.intersection(i2_union).apply(fix)
    candidates = candidates[~candidates["i2_geom"].is_empty].copy()
    candidates["parcel_sqft"] = candidates.geometry.area
    candidates["i2_sqft"] = candidates["i2_geom"].area
    candidates["parcel_acres"] = candidates["parcel_sqft"] / 43560.0
    candidates["i2_acres"] = candidates["i2_sqft"] / 43560.0
    n_intersect = len(candidates)
    print(f"  parcels intersecting I-2: {n_intersect:,}")

    # Drop parcels that are active rail right-of-way or other linear
    # infrastructure that can't host a building (regardless of geometric
    # eligibility). Identified by LANDUSE code 41 or railroad-style owner.
    nb_lu_mask = candidates["LANDUSE"].apply(landuse_is_non_buildable)
    nb_owner_mask = candidates["OWNER"].apply(owner_is_railroad)
    nb_mask = nb_lu_mask | nb_owner_mask
    if nb_mask.any():
        dropped = candidates[nb_mask][["GISLINK", "ADDRESS", "OWNER", "LANDUSE"]]
        print(f"  dropping {nb_mask.sum()} non-buildable infrastructure parcel(s):")
        for _, r in dropped.iterrows():
            print(f"    {r['GISLINK']!s:<22} {r['ADDRESS']!s:<30} {r['OWNER']!s:<32} {r['LANDUSE']!s}")
        candidates = candidates[~nb_mask].copy()
    print(f"  buildable-eligible candidates: {len(candidates):,}")
    stamp("done", t1)

    # ---- Road frontage per parent parcel (authoritative centerlines) ----
    # For each parcel, frontage = length of parcel boundary within
    # ROW_HALF_WIDTH_FT of any PUBLIC road centerline. The centerline file is
    # a LineString through the middle of each road; a parcel boundary roughly
    # at the ROW edge sits ~25-35 ft from the centerline.
    t1 = stamp("computing road frontage from centerlines")
    roads_sindex = public_roads.sindex
    road_geoms = public_roads.geometry.values
    road_frontage_geoms = []
    parent_frontage_ft_list = []
    for row in candidates.itertuples(index=False):
        pg = row.geometry
        nearby_ids = list(roads_sindex.query(pg.buffer(ROW_HALF_WIDTH_FT + 5)))
        if not nearby_ids:
            road_frontage_geoms.append(None)
            parent_frontage_ft_list.append(0.0)
            continue
        corridor = unary_union([road_geoms[i] for i in nearby_ids]).buffer(ROW_HALF_WIDTH_FT)
        front = pg.boundary.intersection(corridor)
        if front.is_empty:
            road_frontage_geoms.append(None)
            parent_frontage_ft_list.append(0.0)
        else:
            road_frontage_geoms.append(front)
            parent_frontage_ft_list.append(round(front.length, 1))
    candidates["road_frontage_geom"] = road_frontage_geoms
    candidates["parent_frontage_ft"] = parent_frontage_ft_list
    n_with = (candidates["parent_frontage_ft"] >= min_frontage_ft).sum()
    print(f"  parents with >= {int(min_frontage_ft)} ft public road frontage: "
          f"{n_with}/{len(candidates)}")
    stamp("done", t1)

    inv_cols = ["GISLINK", "ADDRESS", "OWNER", "OWNER2", "ZONECLASS",
                "LANDUSE", "PROPTYPE", "parcel_acres", "i2_acres"]
    candidates[[c for c in inv_cols if c in candidates.columns]] \
        .sort_values("i2_acres", ascending=False) \
        .to_csv(OUT / "i2_parcels.csv", index=False)
    print(f"  wrote out/i2_parcels.csv")

    if write_geojson:
        # Static layers (independent of setback): I-2 zoning polygons and the
        # full candidate-parcel set. Written once per run; suffix omitted so
        # the map can always rely on these names.
        _write_gjs(i2_zones[["ZONECLASS", "ZONEDESC", "geometry"]].copy(),
                   OUT / "i2_zones.geojson")
        parcels_layer = candidates[[c for c in inv_cols if c in candidates.columns] + ["geometry"]].copy()
        _write_gjs(parcels_layer, OUT / "i2_parcels.geojson")

    # ---- Residential exclusion universe ----
    t1 = stamp("building residential exclusion set")
    zc = zoning["ZONECLASS"].fillna("").astype(str)
    res_zone_mask = zc.isin(res_district_classes)
    res_zones = zoning[res_zone_mask].copy()
    print(f"  residential district polygons   : {len(res_zones):,}  "
          f"({sorted(res_zones['ZONECLASS'].unique())})")

    # Address points: classify into residential / vacant for ground-truth
    # signals that override stale LANDUSE codes.
    addr_pt = addresses["PLACETYPE"].astype(str).str.upper()
    res_addrs = addresses[addr_pt.isin(RESIDENTIAL_PLACETYPES)].copy()
    vacant_addrs = addresses[addr_pt.isin(VACANT_PLACETYPES)].copy()

    res_addr_in_parcels = gpd.sjoin(
        res_addrs[["geometry"]], parcels[["GISLINK", "geometry"]],
        how="inner", predicate="within",
    )
    res_addr_gislinks = set(res_addr_in_parcels["GISLINK"].dropna().unique())

    vac_addr_in_parcels = gpd.sjoin(
        vacant_addrs[["geometry"]], parcels[["GISLINK", "geometry"]],
        how="inner", predicate="within",
    )
    vac_addr_gislinks = set(vac_addr_in_parcels["GISLINK"].dropna().unique())

    # Override set: parcels whose address record explicitly says VACANT and has
    # no residential address point. These trump a stale residential LANDUSE.
    landuse_override_gislinks = vac_addr_gislinks - res_addr_gislinks

    # (B) Residential LANDUSE -- but exclude parcels overridden by addresses.
    res_landuse_mask = parcels["LANDUSE"].apply(landuse_is_residential) \
        & ~parcels["GISLINK"].isin(landuse_override_gislinks)
    res_landuse_parcels = parcels[res_landuse_mask].copy()
    n_overridden = (parcels["LANDUSE"].apply(landuse_is_residential)
                    & parcels["GISLINK"].isin(landuse_override_gislinks)).sum()
    print(f"  parcels w/ residential LANDUSE  : {len(res_landuse_parcels):,}  "
          f"(dropped {n_overridden} as VACANT per address record)")

    # (C) Parcels containing a residential address point.
    res_addr_parcels = parcels[parcels["GISLINK"].isin(res_addr_gislinks)].copy()
    print(f"  parcels containing a dwelling   : {len(res_addr_parcels):,}")

    if exclude_gislinks:
        before_l = len(res_landuse_parcels); before_a = len(res_addr_parcels)
        res_landuse_parcels = res_landuse_parcels[~res_landuse_parcels["GISLINK"].isin(exclude_gislinks)]
        res_addr_parcels = res_addr_parcels[~res_addr_parcels["GISLINK"].isin(exclude_gislinks)]
        print(f"  wargame excluded {len(exclude_gislinks)} GISLINKs "
              f"(LANDUSE {before_l}->{len(res_landuse_parcels)}, "
              f"addr {before_a}->{len(res_addr_parcels)})")

    res_geoms = (
        list(res_zones.geometry.values)
        + list(res_landuse_parcels.geometry.values)
        + list(res_addr_parcels.geometry.values)
    )
    print(f"  total residential geoms (pre-union): {len(res_geoms):,}")
    stamp("collected", t1)

    t1 = stamp("union of residential geoms")
    residential_union = unary_union(res_geoms)
    stamp("done", t1)

    with open(OUT / "residential_summary.txt", "w") as f:
        f.write(f"Residential districts in scope : {sorted(res_district_classes)}\n")
        f.write(f"Residential district polygons  : {len(res_zones):,}\n")
        f.write(f"Parcels w/ residential LANDUSE : {len(res_landuse_parcels):,}\n")
        f.write(f"Parcels w/ a dwelling address  : {len(res_addr_parcels):,}\n")
        f.write(f"Wargame-excluded GISLINKs      : {len(exclude_gislinks)}\n")
        f.write(f"Residential union area (acres) : {residential_union.area/43560:.1f}\n")

    # ---- For each setback compute STRICT and BUILDABLE eligibility ----
    for ft in SETBACKS_FT:
        t1 = stamp(f"buffering residential by {ft} ft")
        excl = residential_union.buffer(ft)
        excl_sindex = gpd.GeoSeries([excl], crs=parcels.crs).sindex
        stamp("done", t1)

        t1 = stamp(f"computing eligibility @ {ft} ft")
        strict_flags = []
        total_sqft = []
        max_contig_sqft = []
        max_siteable_sqft = []
        inscribed_diam = []
        patch_frontage_ft = []
        buildable_geoms = []
        siteable_geoms = []
        for row in candidates.itertuples(index=False):
            parcel_g = row.geometry
            i2g = row.i2_geom
            road_g = row.road_frontage_geom

            # Strict: entire parcel boundary >= ft from residential.
            hits = list(excl_sindex.query(parcel_g, predicate="intersects"))
            if not hits:
                strict_flags.append(True)
                total_sqft.append(i2g.area)
                max_contig_sqft.append(_max_polygon_area(i2g))
                buildable_geoms.append(i2g)
                eroded = fix(i2g.buffer(-DC_INTERNAL_SETBACK_FT))
                max_siteable_sqft.append(_max_polygon_area(eroded))
                siteable_geoms.append(eroded)
                inscribed_diam.append(2 * _inscribed_radius(eroded))
                # For strict (whole-parcel) the relevant frontage is the parent's.
                patch_frontage_ft.append(row.parent_frontage_ft)
                continue
            strict_flags.append(False)

            # Buildable remnant inside parcel AND inside I-2 AND >= ft from residential.
            remnant = fix(i2g.difference(excl))
            if remnant.is_empty:
                total_sqft.append(0.0)
                max_contig_sqft.append(0.0)
                max_siteable_sqft.append(0.0)
                buildable_geoms.append(remnant)
                siteable_geoms.append(remnant)
                inscribed_diam.append(0.0)
                patch_frontage_ft.append(0.0)
            else:
                total_sqft.append(remnant.area)
                max_contig_sqft.append(_max_polygon_area(remnant))
                buildable_geoms.append(remnant)
                eroded = fix(remnant.buffer(-DC_INTERNAL_SETBACK_FT))
                max_siteable_sqft.append(_max_polygon_area(eroded))
                siteable_geoms.append(eroded)
                inscribed_diam.append(2 * _inscribed_radius(eroded))
                # Patch frontage = length of patch boundary lying along parent
                # road frontage. Buffer the road geom slightly to handle small
                # numerical mismatches from the difference operation.
                if road_g is None or road_g.is_empty:
                    patch_frontage_ft.append(0.0)
                else:
                    # 5ft snap tolerance for numerical alignment between
                    # parent boundary and patch boundary.
                    front = remnant.boundary.intersection(road_g.buffer(5))
                    patch_frontage_ft.append(round(front.length, 1) if not front.is_empty else 0.0)
        stamp("done", t1)

        candidates[f"strict_{ft}"] = strict_flags
        candidates[f"buildable_sqft_{ft}"] = total_sqft
        candidates[f"max_contig_sqft_{ft}"] = max_contig_sqft
        candidates[f"max_siteable_sqft_{ft}"] = max_siteable_sqft
        candidates[f"inscribed_diam_ft_{ft}"] = [round(d, 1) for d in inscribed_diam]
        candidates[f"patch_frontage_ft_{ft}"] = patch_frontage_ft
        candidates[f"buildable_acres_{ft}"] = [a / 43560.0 for a in total_sqft]
        candidates[f"max_contig_acres_{ft}"] = [a / 43560.0 for a in max_contig_sqft]
        candidates[f"max_siteable_acres_{ft}"] = [a / 43560.0 for a in max_siteable_sqft]
        candidates[f"buildable_pct_of_i2_{ft}"] = (
            candidates[f"buildable_sqft_{ft}"] / candidates["i2_sqft"] * 100
        ).round(1)
        candidates[f"_buildable_geom_{ft}"] = buildable_geoms
        candidates[f"_siteable_geom_{ft}"] = siteable_geoms

        # Strict eligibility output. Filters: parcel passes clearance, parcel
        # has meaningful I-2 acreage (no ROW slivers), siteable patch has
        # inscribed-circle diameter >= min_width_ft, AND the PARENT parcel
        # has >= min_frontage_ft of public road frontage so the parcel could
        # legally subdivide if needed (JC Subdivision Reg §4-4.1).
        strict_mask = candidates[f"strict_{ft}"] \
            & (candidates["i2_acres"] >= strict_min_i2_acres) \
            & (candidates[f"inscribed_diam_ft_{ft}"] >= min_width_ft) \
            & (candidates["parent_frontage_ft"] >= min_frontage_ft)
        strict = candidates[strict_mask].copy()
        s_cols = ["GISLINK", "ADDRESS", "OWNER", "ZONECLASS", "LANDUSE",
                  "parcel_acres", "i2_acres",
                  f"max_siteable_acres_{ft}", f"inscribed_diam_ft_{ft}",
                  "parent_frontage_ft", f"patch_frontage_ft_{ft}"]
        out_strict = OUT / f"eligible_{ft}ft_strict{out_suffix}.csv"
        strict[[c for c in s_cols if c in strict.columns]] \
            .sort_values("i2_acres", ascending=False) \
            .to_csv(out_strict, index=False)

        # Buildable filter: largest contiguous patch >= min_sqft, inscribed
        # building circle diameter >= min_width_ft (so a real building fits),
        # PARENT parcel has >= min_frontage_ft of public road frontage. We
        # filter on PARENT frontage (not patch frontage) because the 50ft
        # subdivision rule applies to the sub-parcel boundary the developer
        # draws -- and the developer can draw lot lines to include road-
        # adjacent land outside the compliant patch, as long as the data
        # center building itself sits inside the patch.
        build_mask = (candidates[f"max_contig_sqft_{ft}"] >= min_sqft) \
            & (candidates[f"inscribed_diam_ft_{ft}"] >= min_width_ft) \
            & (candidates["parent_frontage_ft"] >= min_frontage_ft)
        if min_building_sqft is not None:
            build_mask &= candidates[f"max_siteable_sqft_{ft}"] >= min_building_sqft
        buildable = candidates[build_mask].copy()
        b_cols = ["GISLINK", "ADDRESS", "OWNER", "ZONECLASS", "LANDUSE",
                  "parcel_acres", "i2_acres",
                  f"max_contig_acres_{ft}", f"max_siteable_acres_{ft}",
                  f"inscribed_diam_ft_{ft}",
                  "parent_frontage_ft", f"patch_frontage_ft_{ft}",
                  f"buildable_acres_{ft}", f"buildable_pct_of_i2_{ft}"]
        out_build = OUT / f"eligible_{ft}ft_buildable{out_suffix}.csv"
        buildable[[c for c in b_cols if c in buildable.columns]] \
            .sort_values(f"max_siteable_acres_{ft}", ascending=False) \
            .to_csv(out_build, index=False)

        flag = "ORDINANCE" if ft == ORDINANCE_SETBACK_FT else "exploratory"
        extra = f", siteable >= {int(min_building_sqft):,}" if min_building_sqft else ""
        print(f"  setback {ft}ft ({flag}): "
              f"strict={len(strict):,}  "
              f"buildable(contig >= {int(min_sqft):,} sqft, "
              f"width >= {int(min_width_ft)}ft, frontage >= {int(min_frontage_ft)}ft{extra})={len(buildable):,}")

        if write_geojson:
            attr_cols = ["GISLINK", "ADDRESS", "OWNER", "ZONECLASS", "LANDUSE",
                         "parcel_acres", "i2_acres",
                         f"max_contig_acres_{ft}", f"max_siteable_acres_{ft}",
                         f"inscribed_diam_ft_{ft}",
                         "parent_frontage_ft", f"patch_frontage_ft_{ft}",
                         f"buildable_acres_{ft}", f"buildable_pct_of_i2_{ft}"]
            attr_cols = [c for c in attr_cols if c in candidates.columns]

            # Strict-eligible parcels (whole parcel polygons)
            if len(strict):
                strict_layer = gpd.GeoDataFrame(
                    strict[attr_cols], geometry=strict["geometry"], crs=candidates.crs,
                )
                _write_gjs(strict_layer, OUT / f"eligible_{ft}ft_strict{out_suffix}.geojson")

            # Buildable patches: the actual sub-parcel polygons. One feature per
            # eligible parcel; geometry is the patch (post-buffer-subtraction).
            if len(buildable):
                build_layer = gpd.GeoDataFrame(
                    buildable[attr_cols],
                    geometry=buildable[f"_buildable_geom_{ft}"].values,
                    crs=candidates.crs,
                )
                build_layer = build_layer[~build_layer.geometry.is_empty]
                _write_gjs(build_layer, OUT / f"eligible_{ft}ft_buildable{out_suffix}.geojson")

                # Site-able patches (buildable eroded by DC's 50ft internal yard)
                site_layer = gpd.GeoDataFrame(
                    buildable[attr_cols],
                    geometry=buildable[f"_siteable_geom_{ft}"].values,
                    crs=candidates.crs,
                )
                site_layer = site_layer[~site_layer.geometry.is_empty]
                _write_gjs(site_layer, OUT / f"eligible_{ft}ft_siteable{out_suffix}.geojson")

    return 0


if __name__ == "__main__":
    sys.exit(main())
