"""Geo-nearest cadastral building matcher for listings (#cadastral-geo-match).

Fills `listings.building_cadastral_number` (currently 0% / ~43k rows) from the nearest
cadastral building, via a LOCAL materialized mirror of the gendesign_cad_buildings FDW.

WHY a local mirror (perf fact, measured live):
    gendesign_cad_buildings is a postgres_fdw foreign table (~36.7k EKB rows) with NO
    PostGIS geom — only scalar lat/lon. A per-listing nearest-building query over the FDW
    takes ~1.16s/row → ~13h for 43k listings. UNUSABLE per-row.
    ⇒ We materialize the FDW once (single bulk scan) into cad_buildings_local with a real
      Point geom + GIST, then run a fast LOCAL KNN nearest-neighbour join (geom <-> point).
    The whole local backfill runs in well under a minute (see PR EXPLAIN evidence).

APPROXIMATION (deliberate first increment):
    This is a GEO-NEAREST match — a street-level-geocoded listing is matched to the nearest
    cadastral building within `threshold_m`, NOT necessarily its exact cadastral building.
    The threshold is always logged. Exact cadastral resolution + parcel-containment are
    deferred (cad_parcels FDW not exposed). Tier-0 house matching in the estimator already
    treats building_cadastral_number as a hint, not ground truth, so an approximate fill is
    a net win over 0% coverage.

Pipeline (one combined run, scheduler source='cadastral_geo_match'):
    1. refresh_cad_buildings_local(db) — TRUNCATE + bulk INSERT from FDW (one scan).
    2. match_listings_to_buildings(db, threshold_m) — set-based LATERAL KNN UPDATE.
    Refresh-then-match in ONE run guarantees the match always reads a fresh mirror.

psycopg v3: app SQL uses CAST(:x AS type), never :x::type.
"""

from __future__ import annotations

import logging
import math
import time
from dataclasses import dataclass, field

from sqlalchemy import text
from sqlalchemy.orm import Session

from app.services import scrape_runs as runs_mod

logger = logging.getLogger(__name__)

DEFAULT_THRESHOLD_M = 50
DEFAULT_BATCH_SIZE = 5000

# EKB latitude — used to convert the metric threshold into a degree gate radius for the
# GIST-indexable geometry-space pre-filter (the gate is a SUPERSET of the true circle; the
# ST_DistanceSphere recheck decides acceptance, so a slightly-too-wide gate is harmless).
_EKB_LAT_DEG = 56.84
_M_PER_DEG_LAT = 111_320.0


def _deg_gate_for(threshold_m: float) -> float:
    """Degree radius that always encloses `threshold_m` metres at EKB latitude.

    Longitude degrees shrink by cos(lat), so we size the gate on the tighter (longitude)
    axis and apply a 1.5× safety factor. The gate only pre-filters candidates for the GIST
    KNN scan; the exact ST_DistanceSphere(...) <= threshold_m recheck is authoritative, so
    over-sizing the gate never produces a false match, only a few extra candidates to rank.
    """
    m_per_deg_lon = _M_PER_DEG_LAT * math.cos(math.radians(_EKB_LAT_DEG))
    return 1.5 * threshold_m / m_per_deg_lon


# ── Refresh: materialize FDW → cad_buildings_local (single bulk scan) ─────────
def refresh_cad_buildings_local(db: Session) -> int:
    """TRUNCATE cad_buildings_local; bulk INSERT from gendesign_cad_buildings FDW.

    Single FDW scan (no per-row round-trips). Builds Point(4326) geom from lon/lat.
    Idempotent: TRUNCATE+INSERT in one transaction — the table is fully replaced atomically.

    Returns the number of rows loaded.
    """
    db.execute(text("TRUNCATE cad_buildings_local"))
    db.execute(
        text(
            """
            INSERT INTO cad_buildings_local (
                cad_num, readable_address, year_built, floors, area_m2, purpose,
                lat, lon, geom
            )
            SELECT
                cad_num, readable_address, year_built, floors, area_m2, purpose,
                lat, lon,
                ST_SetSRID(ST_MakePoint(lon, lat), 4326)
            FROM gendesign_cad_buildings
            WHERE lat IS NOT NULL
              AND lon IS NOT NULL
              AND cad_num IS NOT NULL
            ON CONFLICT (cad_num) DO NOTHING
            """
        )
    )
    count = db.execute(text("SELECT count(*) FROM cad_buildings_local")).scalar() or 0
    db.commit()
    logger.info("refresh_cad_buildings_local: loaded %d rows from FDW", count)
    return int(count)


# ── Match: LATERAL KNN nearest building → listings.building_cadastral_number ──
# Set-based UPDATE. For each candidate listing (geo present, optionally only NULL bcn) the
# LATERAL subquery picks the single nearest cad_buildings_local building and accepts it only
# if its TRUE distance is within :threshold_m metres.
#
# PERF (measured live, the whole point of this design):
#   The gate is a GEOMETRY-space ST_DWithin(cb.geom, l_geom, :deg_gate) — planar degrees,
#   fully GIST-indexable, NO per-row geography cast. The `<-> ` KNN order then returns the
#   single nearest candidate via the GIST index. ONLY that one nearest row is converted to a
#   true metric distance via ST_DistanceSphere (spherical metres) and compared to :threshold_m.
#   This is ~175× faster than a geography-cast ST_DWithin in the WHERE: a geography filter
#   forced a per-candidate cast+recheck that the planner could not push into the index, so the
#   full 41k-listing UPDATE ran 58s+ and never finished; the geometry-gate version does a
#   5000-row chunk in ~330ms and the full ~41k backfill in a few seconds.
#
#   :deg_gate is a degree radius that ALWAYS encloses :threshold_m at EKB latitude (~56.8°):
#   1° lat ≈ 111_320 m, and we widen by /cos(lat) for longitude + a safety factor, so the
#   gate is a superset of the true threshold circle and the ST_DistanceSphere recheck is what
#   actually decides acceptance (no false negatives from the gate).
#
# Batching: pick a chunk of candidate listing ids first (LIMIT :batch_size), then run the
# LATERAL UPDATE only over that chunk — bounds lock/WAL footprint on the ~43k table and lets
# the run loop checkpoint a heartbeat between chunks. only_missing=true makes each chunk drain
# monotonically (filled rows drop out of the candidate set), so the loop terminates.
# Single set-based LATERAL KNN UPDATE over ALL candidates in one statement.
# NOT chunked by OFFSET: an OFFSET walk over the `building_cadastral_number IS NULL`
# predicate is unsound because the UPDATE mutates that very predicate mid-walk —
# matched rows drop out of the candidate set, so OFFSET (reset OR advance) skips
# un-processed rows. The full UPDATE is ~5s for ~41k listings (GIST KNN, measured),
# so a single statement is both correct and fast enough; no chunking needed.
_MATCH_SQL = text(
    """
    WITH matched AS (
        SELECT l.id AS id, m.cad_num AS cad_num
        FROM listings l
        JOIN LATERAL (
            SELECT cb.cad_num,
                   ST_DistanceSphere(cb.geom, l.geom) AS dist_m
            FROM cad_buildings_local cb
            WHERE ST_DWithin(cb.geom, l.geom, CAST(:deg_gate AS double precision))
            ORDER BY cb.geom <-> l.geom
            LIMIT 1
        ) m ON true
        WHERE l.geom IS NOT NULL
          AND (
              CAST(:only_missing AS boolean) = false
              OR l.building_cadastral_number IS NULL
          )
          AND m.dist_m <= CAST(:threshold_m AS double precision)
    )
    UPDATE listings l
    SET building_cadastral_number = matched.cad_num
    FROM matched
    WHERE l.id = matched.id
    """
)

_CANDIDATES_SQL = text(
    """
    SELECT count(*) FROM listings l
    WHERE l.geom IS NOT NULL
      AND (CAST(:only_missing AS boolean) = false OR l.building_cadastral_number IS NULL)
    """
)


@dataclass
class CadMatchResult:
    buildings_loaded: int = 0  # rows in cad_buildings_local after refresh
    candidates_total: int = 0  # listings eligible for matching (geo present + filter)
    listings_matched: int = 0  # listings updated with a building_cadastral_number
    threshold_m: int = DEFAULT_THRESHOLD_M
    duration_sec: float = field(default=0.0)

    def to_counters(self) -> dict[str, int]:
        return {
            "buildings_loaded": self.buildings_loaded,
            "candidates_total": self.candidates_total,
            "listings_matched": self.listings_matched,
            "threshold_m": self.threshold_m,
            "duration_sec": int(self.duration_sec),
        }


def match_listings_to_buildings(
    db: Session,
    *,
    threshold_m: int = DEFAULT_THRESHOLD_M,
    batch_size: int = DEFAULT_BATCH_SIZE,
    only_missing: bool = True,
) -> int:
    """Set-based LATERAL KNN UPDATE: fill listings.building_cadastral_number.

    GEO-NEAREST (approximate): each candidate listing is matched to the nearest
    cad_buildings_local building within `threshold_m` metres. The threshold is logged.

    Single set-based UPDATE over ALL candidates (no chunking). An OFFSET walk would be
    unsound: the UPDATE mutates the `building_cadastral_number IS NULL` predicate it pages
    over, so matched rows drop out and OFFSET (reset OR advance) skips un-processed rows.
    The full UPDATE is ~5s for ~41k listings (GIST KNN), so one statement is correct and
    fast. `batch_size` is accepted for schedule-param compatibility but no longer used.
    Returns listings matched.
    """
    logger.info(
        "match_listings_to_buildings: GEO-NEAREST match threshold_m=%d "
        "only_missing=%s (approximate: nearest building, not exact cadastral)",
        threshold_m,
        only_missing,
    )

    deg_gate = _deg_gate_for(float(threshold_m))
    result = db.execute(
        _MATCH_SQL,
        {
            "threshold_m": float(threshold_m),
            "deg_gate": deg_gate,
            "only_missing": only_missing,
        },
    )
    total_matched = result.rowcount
    db.commit()

    logger.info(
        "match_listings_to_buildings: DONE total_matched=%d threshold_m=%d",
        total_matched,
        threshold_m,
    )
    return total_matched


# ── Combined refresh + match (callable directly or via run wrapper) ───────────
def refresh_and_match(
    db: Session,
    *,
    threshold_m: int = DEFAULT_THRESHOLD_M,
    batch_size: int = DEFAULT_BATCH_SIZE,
    only_missing: bool = True,
) -> CadMatchResult:
    """Refresh the local cad mirror, then geo-match listings. Returns a CadMatchResult."""
    start = time.monotonic()
    res = CadMatchResult(threshold_m=threshold_m)

    res.buildings_loaded = refresh_cad_buildings_local(db)
    res.candidates_total = int(
        db.execute(_CANDIDATES_SQL, {"only_missing": only_missing}).scalar() or 0
    )
    res.listings_matched = match_listings_to_buildings(
        db, threshold_m=threshold_m, batch_size=batch_size, only_missing=only_missing
    )
    res.duration_sec = time.monotonic() - start

    coverage = 100.0 * res.listings_matched / res.candidates_total if res.candidates_total else 0.0
    logger.info(
        "refresh_and_match: DONE buildings=%d candidates=%d matched=%d "
        "coverage=%.1f%% threshold_m=%d duration=%.1fs",
        res.buildings_loaded,
        res.candidates_total,
        res.listings_matched,
        coverage,
        threshold_m,
        res.duration_sec,
    )
    return res


# ── Run lifecycle wrapper (scheduler entrypoint) ─────────────────────────────
def run_cadastral_geo_match(db: Session, *, run_id: int, params: dict) -> CadMatchResult:
    """Run-lifecycle wrapper for the combined refresh+match job (sync, DB-only).

    Launched by the kit scheduler (source='cadastral_geo_match') via
    product_handlers._job_cadastral_geo_match, or manually. Refreshes cad_buildings_local
    from the FDW (one bulk scan) then geo-matches listings.building_cadastral_number to the
    nearest cadastral building within threshold_m.

    Params (default_params jsonb):
        threshold_m:  max nearest-building distance to accept (metres, default 50).
        batch_size:   listings per LATERAL UPDATE chunk (default 5000).
        only_missing: only fill WHERE building_cadastral_number IS NULL (default true).

    Finalises scrape_runs (mark_done / mark_failed) with counters.
    """
    threshold_m = int(params.get("threshold_m", DEFAULT_THRESHOLD_M))
    batch_size = int(params.get("batch_size", DEFAULT_BATCH_SIZE))
    only_missing = bool(params.get("only_missing", True))

    counters: dict[str, int] = {
        "buildings_loaded": 0,
        "candidates_total": 0,
        "listings_matched": 0,
        "threshold_m": threshold_m,
    }

    try:
        runs_mod.update_heartbeat(db, run_id, counters)

        res = refresh_and_match(
            db,
            threshold_m=threshold_m,
            batch_size=batch_size,
            only_missing=only_missing,
        )

        counters = res.to_counters()
        runs_mod.mark_done(db, run_id, counters)
        logger.info(
            "run_cadastral_geo_match: run_id=%d DONE buildings=%d candidates=%d "
            "matched=%d threshold_m=%d duration=%.1fs",
            run_id,
            res.buildings_loaded,
            res.candidates_total,
            res.listings_matched,
            threshold_m,
            res.duration_sec,
        )
        return res
    except Exception as exc:
        logger.exception("run_cadastral_geo_match: run_id=%d FAILED", run_id)
        try:
            db.rollback()
        except Exception:
            pass
        runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
        raise
