#!/usr/bin/env python3
"""Build Wisp's reference basemap: an OSM extract of Columbus, packed as WBM1.

    tools/build_basemap.py                       # defaults: Columbus metro -> tools/fixtures/basemap_columbus.wbm (harness fixture; the app uses tiles, see build_tiles.py)
    tools/build_basemap.py --report-only         # counts from cache, pack nothing
    tools/build_basemap.py --min-lat 39.90 ...   # any other box

THE FORMAT IS FROZEN by project_memory/world_contract.md 3.3g.3 and this file implements it; it
does not amend it. Little-endian throughout, no compression, no strings, no nesting:

    HEADER   magic  4s  "WBM1"   | minLat minLon maxLat maxLon  Float32 degrees | count UInt32
    BODY     count x { kind UInt8 (0=street 1=water) | n UInt16 >= 2 | n x (lat f32, lon f32) }

WHAT THIS PRODUCES IS REFERENCE ONLY (3.3g.1): no code may read this geometry to decide where a
spot is or what ground is worth. Float32 is deliberate -- ~0.5 m at this latitude, below street
width and below GPS error, and unfit for anything measured, which is the point.

THE HEADER BBOX IS THE COVERAGE WE ACTUALLY GOT, never the box we asked for (3.3g.4). The chart
uses it to tell *no data here* from *nothing here*. So: every tile must succeed or this script
refuses to write, and geometry is CLIPPED to the box rather than allowed to trail outside it --
an optimistic bbox becomes a lie on the player's screen.

DATA (c) OpenStreetMap contributors, ODbL. See the ODBL_NOTICE block below: shipping this file to
users carries an attribution obligation, and discharging it is Phill's ruling, not this script's.
"""

from __future__ import annotations

import argparse
import json
import math
import os
import struct
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from collections import Counter

# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------

# Downtown Columbus sits at ~39.96 N, -83.00 W. This covers the metro generously enough that a
# walk cannot run off the edge; the box is an argument, not a constant, so a later ruling can
# move it without editing three places.
DEF_MIN_LAT, DEF_MAX_LAT = 39.85, 40.10
DEF_MIN_LON, DEF_MAX_LON = -83.15, -82.85

MAGIC = b"WBM1"
KIND_STREET, KIND_WATER = 0, 1
MAX_N = 0xFFFF  # n is a UInt16, so a longer way is SPLIT at a shared vertex, never truncated

# What we ASK Overpass for. Deliberately a superset of what we keep, so the keep/drop decision is
# made against real counts at pack time and can be revised for free off the cache.
QUERY_HIGHWAY = (
    "motorway|motorway_link|trunk|trunk_link|primary|primary_link|secondary|secondary_link|"
    "tertiary|tertiary_link|unclassified|residential|living_street|pedestrian|footway|path|"
    "steps|cycleway|track|service"
)

# What we KEEP as kind=street. Rationale, since 3.3g.5 makes every line ink competing with the
# trace: Wisp is walked, so footway/path/steps/pedestrian/cycleway are the most useful lines on
# the map and stay. `service` (parking aisles, driveways) and `track` (farm/forest) are dropped --
# they are the bulk of the byte count and add almost nothing a walker navigates by. Link ramps
# stay because a freeway with no ramps reads as broken geometry rather than as a freeway.
DEFAULT_STREET_KINDS = (
    "motorway,motorway_link,trunk,trunk_link,primary,primary_link,secondary,secondary_link,"
    "tertiary,tertiary_link,unclassified,residential,living_street,pedestrian,footway,path,"
    "steps,cycleway"
)

# Water: linear waterways plus areal water. Ditches and drains are dropped as clutter at this
# scale; the Scioto and Olentangy are the point of this layer.
DEFAULT_WATERWAY_KINDS = "river,stream,canal,riverbank"

DEFAULT_MAX_BYTES = 8 * 1024 * 1024

ENDPOINTS = (
    "https://overpass-api.de/api/interpreter",
    "https://overpass.kumi.systems/api/interpreter",
)

ODBL_NOTICE = """\
ODbL OBLIGATION -- STATED, NOT DECIDED (this script adds no UI and writes no Swift)
  The geometry in basemap.wbm is derived from OpenStreetMap, licensed ODbL 1.0. Shipping it to
  users requires: (1) crediting "(c) OpenStreetMap contributors" somewhere a user can reach, and
  (2) noting the ODbL licence. App Store convention discharges this in an in-app credits/about
  readout -- for Wisp that is an instrument_panel.md surface, so WHERE it goes and WHAT it says
  is the seat's and Phill's ruling. A derived-and-published database also carries a share-alike
  term; a filtered extract shipped as a reference layer is the ordinary "produced work" case,
  which attribution covers.\
"""

# Known ground truth for the geometry check. Structure passing proves the bytes are well-formed;
# it cannot prove the map is of the right city. A lat/lon swap yields a valid file of the Indian
# Ocean, so we check a named street against where that street actually is.
GEOMETRY_PROBES = (
    # (OSM name substring, axis, expected value, tolerance in degrees)
    ("High Street", "lon", -83.0007, 0.010),   # N/S High St runs north-south through downtown
    ("Broad Street", "lat", 39.9612, 0.010),   # E/W Broad St runs east-west through downtown
)
WATER_PROBES = ("Scioto", "Olentangy")


# ---------------------------------------------------------------------------
# Float32 helpers -- the header bbox must not be a hair tighter than the data
# ---------------------------------------------------------------------------

def f32(x: float) -> float:
    return struct.unpack("<f", struct.pack("<f", x))[0]


def _f32_step(x: float, toward_pos: bool) -> float:
    v = f32(x)
    if v == 0.0:
        return f32(math.copysign(struct.unpack("<f", struct.pack("<I", 1))[0], 1 if toward_pos else -1))
    bits = struct.unpack("<I", struct.pack("<f", v))[0]
    negative = bool(bits & 0x8000_0000)
    # magnitude up moves away from zero; for a negative float that is toward -inf
    bits += 1 if (toward_pos != negative) else -1
    return struct.unpack("<f", struct.pack("<I", bits))[0]


def f32_out_lo(x: float) -> float:
    """Largest float32 <= x, nudged one ulp down so clipped vertices land strictly inside."""
    return _f32_step(x, toward_pos=False)


def f32_out_hi(x: float) -> float:
    return _f32_step(x, toward_pos=True)


# ---------------------------------------------------------------------------
# Fetch
# ---------------------------------------------------------------------------

def overpass_query(s: float, w: float, n: float, e: float) -> str:
    bbox = f"{s:.6f},{w:.6f},{n:.6f},{e:.6f}"
    return f"""[out:json][timeout:180];
(
  way["highway"~"^({QUERY_HIGHWAY})$"]({bbox});
  way["waterway"]({bbox});
  way["natural"="water"]({bbox});
  way["landuse"~"^(reservoir|basin)$"]({bbox});
);
out geom;
"""


def fetch_tile(query: str, cache_path: str, sleep: float, tries: int = 4) -> dict:
    if os.path.exists(cache_path) and os.path.getsize(cache_path) > 0:
        with open(cache_path) as fh:
            return json.load(fh)

    last = None
    for attempt in range(tries):
        endpoint = ENDPOINTS[attempt % len(ENDPOINTS)]
        try:
            req = urllib.request.Request(
                endpoint,
                data=urllib.parse.urlencode({"data": query}).encode(),
                headers={"User-Agent": "wisp-basemap-builder/1 (+contact: hello@claphamdigital.com)"},
            )
            with urllib.request.urlopen(req, timeout=300) as resp:
                raw = resp.read()
            payload = json.loads(raw)
            tmp = cache_path + ".part"
            with open(tmp, "wb") as fh:
                fh.write(raw)
            os.replace(tmp, cache_path)
            time.sleep(sleep)  # be polite; Overpass rate-limits greedy clients
            return payload
        except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
            last = exc
            back = sleep * (2 ** attempt) + 5
            print(f"    retry {attempt + 1}/{tries} after {type(exc).__name__}: {exc} (sleeping {back:.0f}s)")
            time.sleep(back)
    raise RuntimeError(f"tile fetch failed after {tries} attempts: {last}")


def tile_grid(min_lat, min_lon, max_lat, max_lon, rows, cols):
    dlat = (max_lat - min_lat) / rows
    dlon = (max_lon - min_lon) / cols
    for r in range(rows):
        for c in range(cols):
            yield (
                min_lat + r * dlat,
                min_lon + c * dlon,
                min_lat + (r + 1) * dlat,
                min_lon + (c + 1) * dlon,
            )


# ---------------------------------------------------------------------------
# Clip -- geometry outside the header bbox would make the bbox a lie
# ---------------------------------------------------------------------------

def clip_polyline(pts, min_lat, min_lon, max_lat, max_lon):
    """Split a lat/lon polyline into the runs that lie inside the box.

    Planar clipping on degrees. At this scale, for a layer explicitly unfit for measurement
    (3.3g.3), the projection error of treating degrees as planar for clipping is far below the
    0.5 m Float32 quantization we already accepted.
    """
    def inside(p):
        return min_lat <= p[0] <= max_lat and min_lon <= p[1] <= max_lon

    def intersect(a, b):
        # walk the parameter t where the segment crosses each boundary it must cross
        t0, t1 = 0.0, 1.0
        dlat = b[0] - a[0]
        dlon = b[1] - a[1]
        for delta, base, lo, hi in ((dlat, a[0], min_lat, max_lat), (dlon, a[1], min_lon, max_lon)):
            if delta == 0.0:
                if base < lo or base > hi:
                    return None
                continue
            for bound, sign in ((lo, -1.0), (hi, 1.0)):
                t = (bound - base) / delta
                if (delta * sign) > 0:      # leaving through this bound
                    t1 = min(t1, t)
                else:                        # entering through this bound
                    t0 = max(t0, t)
        if t0 > t1:
            return None
        return t0, t1

    runs = []
    current = []
    for i in range(len(pts) - 1):
        a, b = pts[i], pts[i + 1]
        seg = intersect(a, b)
        if seg is None:
            if len(current) >= 2:
                runs.append(current)
            current = []
            continue
        t0, t1 = seg
        p0 = (a[0] + (b[0] - a[0]) * t0, a[1] + (b[1] - a[1]) * t0)
        p1 = (a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1)
        if not current:
            current = [p0]
        elif current[-1] != p0:
            if len(current) >= 2:
                runs.append(current)
            current = [p0]
        current.append(p1)
        if t1 < 1.0:  # segment left the box before its end -> the run ends here
            if len(current) >= 2:
                runs.append(current)
            current = []
    if len(current) >= 2:
        runs.append(current)
    return runs


def split_long(pts):
    """n is a UInt16: split at a SHARED vertex so the pieces still draw as one line."""
    if len(pts) <= MAX_N:
        return [pts]
    out, i = [], 0
    while i < len(pts) - 1:
        chunk = pts[i:i + MAX_N]
        out.append(chunk)
        i += MAX_N - 1  # repeat the last vertex as the next chunk's first
    return [c for c in out if len(c) >= 2]


# ---------------------------------------------------------------------------
# Pack + read back with an independent code path
# ---------------------------------------------------------------------------

def pack(polylines, bbox) -> bytes:
    min_lat, min_lon, max_lat, max_lon = bbox
    buf = bytearray()
    buf += struct.pack("<4sffffI", MAGIC, min_lat, min_lon, max_lat, max_lon, len(polylines))
    for kind, pts in polylines:
        buf += struct.pack("<BH", kind, len(pts))
        for lat, lon in pts:
            buf += struct.pack("<ff", lat, lon)
    return bytes(buf)


def decode(blob: bytes):
    """Deliberately NOT the writer run backwards: walks the byte stream by offset and length,
    checking each field as a reader with no knowledge of what produced it would."""
    if len(blob) < 24:
        raise ValueError(f"file is {len(blob)} bytes, shorter than a 24-byte header")
    magic = blob[0:4]
    if magic != MAGIC:
        raise ValueError(f"magic is {magic!r}, not {MAGIC!r} -- a reader must refuse this file")
    min_lat, min_lon, max_lat, max_lon = struct.unpack("<ffff", blob[4:20])
    count = struct.unpack("<I", blob[20:24])[0]
    off = 24
    out = []
    for idx in range(count):
        if off + 3 > len(blob):
            raise ValueError(f"polyline {idx}: header runs past end of file")
        kind = blob[off]
        n = struct.unpack("<H", blob[off + 1:off + 3])[0]
        off += 3
        need = n * 8
        if off + need > len(blob):
            raise ValueError(f"polyline {idx}: {n} vertices run past end of file")
        pts = [struct.unpack("<ff", blob[off + k * 8: off + k * 8 + 8]) for k in range(n)]
        off += need
        out.append((kind, pts))
    if off != len(blob):
        raise ValueError(f"{len(blob) - off} trailing bytes after {count} polylines")
    return (min_lat, min_lon, max_lat, max_lon), out


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def self_test() -> int:
    """Exercise the two functions that can silently ROT geometry while every structural check
    still passes: the clipper and the UInt16 splitter. Lives here rather than in a
    tools/verify_*.sh organ, per the standing ruling against new verify organs."""
    box = (0.0, 0.0, 10.0, 10.0)
    ok = True

    def case(name, pts, want_runs=None):
        nonlocal ok
        runs = clip_polyline(pts, *box)
        inside = all(0 <= p[0] <= 10 and 0 <= p[1] <= 10 for r in runs for p in r)
        good = inside and (want_runs is None or len(runs) == want_runs)
        ok = ok and good
        want = "" if want_runs is None else f" (want {want_runs})"
        print(f"  {'ok  ' if good else 'FAIL'} {name:<32} runs={len(runs)}{want} all-inside={inside}")

    print("SELF TEST -- clipper:")
    case("fully inside", [(1, 1), (2, 2), (3, 3)], 1)
    case("fully outside", [(20, 20), (30, 30)], 0)
    case("enters and stays", [(-5, 5), (5, 5)], 1)
    case("crosses right through", [(5, -5), (5, 15)], 1)
    case("in-out-in splits", [(5, 5), (5, -5), (5, 5.5)], 2)
    case("touches corner", [(-1, 11), (11, -1)])
    case("closed ring inside stays one", [(2, 2), (2, 8), (8, 8), (8, 2), (2, 2)], 1)
    case("ring straddling an edge", [(5, 5), (5, 15), (9, 15), (9, 5), (5, 5)])
    case("degenerate single vertex", [(5, 5)], 0)

    print("SELF TEST -- UInt16 splitter:")
    long = [(0.0, i * 1e-6) for i in range(140_000)]
    pieces = split_long(long)
    shared = all(pieces[i][-1] == pieces[i + 1][0] for i in range(len(pieces) - 1))
    sized = all(2 <= len(p) <= MAX_N for p in pieces)
    ok = ok and shared and sized
    print(f"  {'ok  ' if shared and sized else 'FAIL'} {len(long)} vertices -> {len(pieces)} pieces "
          f"{[len(p) for p in pieces]}, shared-vertex joins={shared}")

    print("SELF TEST -- header bbox must not be tighter than the data:")
    for v, lo_expect in ((39.85, True), (-83.15, True)):
        lo = f32_out_lo(v)
        good = lo < v and abs(lo - v) < 1e-4
        ok = ok and good
        print(f"  {'ok  ' if good else 'FAIL'} f32_out_lo({v}) = {lo!r} < {v}")
    for v in (40.10, -82.85):
        hi = f32_out_hi(v)
        good = hi > v and abs(hi - v) < 1e-4
        ok = ok and good
        print(f"  {'ok  ' if good else 'FAIL'} f32_out_hi({v}) = {hi!r} > {v}")

    print("\n" + ("ALL SELF TESTS PASSED" if ok else "*** SELF TEST FAILED ***"))
    return 0 if ok else 5


def main() -> int:
    here = os.path.dirname(os.path.abspath(__file__))
    repo = os.path.dirname(here)

    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--min-lat", type=float, default=DEF_MIN_LAT)
    ap.add_argument("--min-lon", type=float, default=DEF_MIN_LON)
    ap.add_argument("--max-lat", type=float, default=DEF_MAX_LAT)
    ap.add_argument("--max-lon", type=float, default=DEF_MAX_LON)
    ap.add_argument("--rows", type=int, default=4, help="tile rows (smaller tiles = politer queries)")
    ap.add_argument("--cols", type=int, default=4)
    ap.add_argument("--sleep", type=float, default=3.0, help="seconds between Overpass queries")
    ap.add_argument("--street-kinds", default=DEFAULT_STREET_KINDS)
    ap.add_argument("--waterway-kinds", default=DEFAULT_WATERWAY_KINDS)
    ap.add_argument("--out", default=os.path.join(repo, "tools/fixtures/basemap_columbus.wbm"))
    ap.add_argument("--cache-dir", default=os.path.expanduser("~/.cache/wisp_basemap"),
                    help="raw Overpass JSON; OUTSIDE the repo on purpose -- it is not an artifact")
    ap.add_argument("--manifest", default=os.path.join(here, "basemap/build_manifest.json"))
    ap.add_argument("--max-bytes", type=int, default=DEFAULT_MAX_BYTES)
    ap.add_argument("--report-only", action="store_true", help="counts from cache; fetch nothing new, pack nothing")
    ap.add_argument("--probe", action="store_true", help="fetch ONE central tile and report its size, then stop")
    ap.add_argument("--self-test", action="store_true", help="exercise the clipper + splitter on synthetic geometry, then stop")
    args = ap.parse_args()

    if args.self_test:
        return self_test()

    keep_streets = {s.strip() for s in args.street_kinds.split(",") if s.strip()}
    keep_water = {s.strip() for s in args.waterway_kinds.split(",") if s.strip()}
    os.makedirs(args.cache_dir, exist_ok=True)

    box = (args.min_lat, args.min_lon, args.max_lat, args.max_lon)
    print(f"REQUESTED BOX  {args.min_lat} .. {args.max_lat} N  x  {args.min_lon} .. {args.max_lon} W")

    tiles = list(tile_grid(*box, args.rows, args.cols)) if not args.probe else [(
        args.min_lat + (args.max_lat - args.min_lat) * 0.375,
        args.min_lon + (args.max_lon - args.min_lon) * 0.375,
        args.min_lat + (args.max_lat - args.min_lat) * 0.625,
        args.min_lon + (args.max_lon - args.min_lon) * 0.625,
    )]

    ways: dict[int, dict] = {}
    fetched_bytes = 0
    for i, (s, w, n, e) in enumerate(tiles, 1):
        name = f"tile_{s:.4f}_{w:.4f}_{n:.4f}_{e:.4f}.json".replace("-", "m")
        path = os.path.join(args.cache_dir, name)
        cached = os.path.exists(path) and os.path.getsize(path) > 0
        if args.report_only and not cached:
            print(f"  [{i}/{len(tiles)}] MISSING from cache: {name}")
            continue
        print(f"  [{i}/{len(tiles)}] {'cache' if cached else 'fetch'} {s:.4f},{w:.4f} -> {n:.4f},{e:.4f}")
        payload = fetch_tile(overpass_query(s, w, n, e), path, args.sleep)
        fetched_bytes += os.path.getsize(path)
        for el in payload.get("elements", []):
            if el.get("type") == "way" and el.get("id") not in ways:
                ways[el["id"]] = el

    print(f"\nRAW: {len(ways)} distinct OSM ways, {fetched_bytes / 1e6:.1f} MB of cached JSON")
    if args.probe:
        print("PROBE ONLY -- nothing packed.")
        return 0

    # ---- classify + count, so the keep/drop decision is visible ----
    hw_counts, ww_counts = Counter(), Counter()
    selected = []  # (kind, pts, name)
    for el in ways.values():
        tags = el.get("tags", {}) or {}
        geom = el.get("geometry") or []
        pts = [(g["lat"], g["lon"]) for g in geom if g and g.get("lat") is not None]
        nm = tags.get("name", "")
        hw = tags.get("highway")
        if hw:
            hw_counts[hw] += 1
            if hw in keep_streets and len(pts) >= 2:
                selected.append((KIND_STREET, pts, nm))
            continue
        ww = tags.get("waterway")
        natural = tags.get("natural")
        landuse = tags.get("landuse")
        label = ww or (f"natural={natural}" if natural else f"landuse={landuse}")
        ww_counts[label] += 1
        keep = (ww in keep_water) or (natural == "water") or (landuse in {"reservoir", "basin"})
        if keep and len(pts) >= 2:
            selected.append((KIND_WATER, pts, nm))

    print("\nHIGHWAY VALUES (kept / dropped):")
    for v, c in hw_counts.most_common():
        print(f"  {'KEEP' if v in keep_streets else 'drop'}  {v:<16} {c:>7}")
    print("WATER VALUES (kept / dropped):")
    for v, c in ww_counts.most_common():
        kept = (v in keep_water) or v == "natural=water" or v in {"landuse=reservoir", "landuse=basin"}
        print(f"  {'KEEP' if kept else 'drop'}  {v:<16} {c:>7}")

    if args.report_only:
        return 0

    # ---- clip to the box, split over-long, drop degenerate ----
    header = (f32_out_lo(args.min_lat), f32_out_lo(args.min_lon),
              f32_out_hi(args.max_lat), f32_out_hi(args.max_lon))
    polylines, names = [], []
    dropped_short = split_count = 0
    for kind, pts, nm in selected:
        for run in clip_polyline(pts, *box):
            pieces = split_long(run)
            split_count += len(pieces) - 1
            for piece in pieces:
                if len(piece) < 2:
                    dropped_short += 1
                    continue
                polylines.append((kind, piece))
                names.append(nm)

    blob = pack(polylines, header)
    os.makedirs(os.path.dirname(args.out), exist_ok=True)
    with open(args.out, "wb") as fh:
        fh.write(blob)

    n_street = sum(1 for k, _ in polylines if k == KIND_STREET)
    n_water = len(polylines) - n_street
    v_street = sum(len(p) for k, p in polylines if k == KIND_STREET)
    v_water = sum(len(p) for k, p in polylines if k == KIND_WATER)

    print(f"\nWROTE {args.out}")
    print(f"  bytes        {len(blob):,}  ({len(blob) / 1e6:.2f} MB)")
    print(f"  header bbox  {header[0]:.6f} .. {header[2]:.6f} N x {header[1]:.6f} .. {header[3]:.6f} W")
    print(f"  polylines    {len(polylines):,}   street {n_street:,}  water {n_water:,}")
    print(f"  vertices     {v_street + v_water:,}   street {v_street:,}  water {v_water:,}")
    print(f"  split for UInt16 n: {split_count}   dropped as <2 vertices after clip: {dropped_short}")

    # ---- ROUND TRIP: read the bytes back with the independent decoder ----
    print("\nROUND TRIP (independent decoder, not the writer reversed):")
    with open(args.out, "rb") as fh:
        on_disk = fh.read()
    dec_bbox, dec = decode(on_disk)
    assert on_disk[:4] == MAGIC, "magic"
    print(f"  magic          WBM1 ok")
    print(f"  decoded count  {len(dec):,}  == header count and == {len(polylines):,} packed")
    assert len(dec) == len(polylines), "count mismatch"
    bad_n = [i for i, (_, p) in enumerate(dec) if len(p) < 2 or len(p) > MAX_N]
    print(f"  n in [2, 65535]  violations: {len(bad_n)}")
    assert not bad_n, f"n out of range at {bad_n[:5]}"
    lo_lat, lo_lon, hi_lat, hi_lon = dec_bbox
    out_of_box = 0
    for _, pts in dec:
        for lat, lon in pts:
            if not (lo_lat <= lat <= hi_lat and lo_lon <= lon <= hi_lon):
                out_of_box += 1
    print(f"  vertices outside header bbox: {out_of_box} of {v_street + v_water:,}")
    assert out_of_box == 0, "geometry escapes the bbox -- the bbox would be a lie"
    d_street = sum(1 for k, _ in dec if k == KIND_STREET)
    d_water = sum(1 for k, _ in dec if k == KIND_WATER)
    print(f"  kind counts    street {d_street:,} (filtered {n_street:,})  water {d_water:,} (filtered {n_water:,})")
    assert (d_street, d_water) == (n_street, n_water), "kind counts drifted"
    assert all(k in (KIND_STREET, KIND_WATER) for k, _ in dec), "unknown kind byte"

    # ---- GEOMETRY: structure passing cannot tell you it is the right city ----
    print("\nGEOMETRY CHECK (a lat/lon swap makes a valid file of the Indian Ocean):")
    ok = True
    for needle, axis, expect, tol in GEOMETRY_PROBES:
        vals = []
        for (kind, pts), nm in zip(dec, names):
            if kind == KIND_STREET and needle.lower() in nm.lower():
                vals += [p[0] if axis == "lat" else p[1] for p in pts]
        if not vals:
            print(f"  FAIL  no street named ~{needle!r} in the extract")
            ok = False
            continue
        vals.sort()
        med = vals[len(vals) // 2]
        good = abs(med - expect) <= tol
        ok = ok and good
        print(f"  {'ok  ' if good else 'FAIL'}  {needle:<14} median {axis} {med:+.5f}  expected {expect:+.5f} +-{tol}"
              f"  ({len(vals):,} vertices)")
    for needle in WATER_PROBES:
        hits = sum(1 for (kind, _), nm in zip(dec, names) if kind == KIND_WATER and needle.lower() in nm.lower())
        print(f"  {'ok  ' if hits else 'FAIL'}  {needle:<14} {hits} water polylines carry the name")
        ok = ok and hits > 0
    if not ok:
        print("\nGEOMETRY CHECK FAILED -- the bytes are well-formed and the map may be of the wrong place.")
        return 4

    # ---- provenance ----
    manifest = {
        "built_from": "OpenStreetMap via Overpass API",
        "licence": "ODbL 1.0 -- (c) OpenStreetMap contributors",
        "format": "WBM1 (world_contract.md 3.3g.3, frozen)",
        "requested_box": {"min_lat": args.min_lat, "min_lon": args.min_lon,
                          "max_lat": args.max_lat, "max_lon": args.max_lon},
        "header_box": {"min_lat": header[0], "min_lon": header[1],
                       "max_lat": header[2], "max_lon": header[3]},
        "tiles": f"{args.rows}x{args.cols}",
        "street_kinds_kept": sorted(keep_streets),
        "waterway_kinds_kept": sorted(keep_water),
        "highway_counts": dict(hw_counts.most_common()),
        "water_counts": dict(ww_counts.most_common()),
        "polylines": {"street": n_street, "water": n_water, "total": len(polylines)},
        "vertices": {"street": v_street, "water": v_water, "total": v_street + v_water},
        "bytes": len(blob),
    }
    os.makedirs(os.path.dirname(args.manifest), exist_ok=True)
    with open(args.manifest, "w") as fh:
        json.dump(manifest, fh, indent=2, sort_keys=True)
        fh.write("\n")
    print(f"\nmanifest -> {args.manifest}")
    print("\n" + ODBL_NOTICE)

    if len(blob) > args.max_bytes:
        print(f"\n{'=' * 78}\nOVER BUDGET -- NOT SILENTLY TRIMMED. THE FILE IS WRITTEN; THIS NEEDS A RULING.")
        print(f"  {len(blob):,} bytes vs a {args.max_bytes:,} budget ({len(blob) / args.max_bytes:.2f}x)")
        print("  options, none of them taken here:")
        print("    - tighter box (--min-lat/--max-lat/...); the metro box is generous")
        print("    - drop a street class (--street-kinds); footway+path+steps is the big block")
        print(f"{'=' * 78}")
        return 3
    print(f"\nWithin budget: {len(blob):,} <= {args.max_bytes:,} bytes.")
    return 0


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