"""Tests for meshai/coverage_area.py — Shapely geometry gate.

Includes a reproduction of the LA/OR leak that originally triggered this work:
an event whose geometry is firmly inside California must be DROPPED when only
an Idaho coverage box is configured.

Bbox/coordinate convention (GeoJSON):
  bbox  = [west, south, east, north] = [min_lon, min_lat, max_lon, max_lat]
  Point = [lon, lat]

Idaho reference area (magic-valley to eastern border, generous):
  west=-117, south=42, east=-111, north=44
"""

from __future__ import annotations

import json
from unittest.mock import MagicMock, patch

import pytest

from meshai.coverage_area import (
    MonitoringArea,
    areas_from_config,
    build_geom_json,
    classify_event_areas,
    classify_geom_areas,
    event_in_areas,
)
from meshai.env.nws import NWSAlertsAdapter
from meshai.notifications.events import make_event
from meshai.notifications.pipeline.coverage_filter import (
    CoverageFilter,
    FAIL_CLOSED_CATEGORIES,
)
from meshai.config import Coverage


# ---------------------------------------------------------------------------
# Reference areas
# ---------------------------------------------------------------------------

IDAHO = MonitoringArea(north=44.0, south=42.0, east=-111.0, west=-117.0)
# Second non-contiguous box covering Yellowstone / eastern corner of WY
YELLOWSTONE = MonitoringArea(north=45.2, south=44.0, east=-109.5, west=-111.5)

IDAHO_AREAS = [IDAHO]
TWO_AREAS = [IDAHO, YELLOWSTONE]

# A point firmly inside Idaho (Twin Falls area)
TF_LON, TF_LAT = -114.47, 42.56

# A point at downtown Los Angeles — should be OUT of Idaho
LA_LON, LA_LAT = -118.24, 34.05

# A CA polygon enclosing the LA area (4-corner box + close)
CA_POLYGON_COORDS = [
    [-119.0, 33.5],
    [-117.5, 33.5],
    [-117.5, 34.6],
    [-119.0, 34.6],
    [-119.0, 33.5],
]
CA_POLYGON_GEOJSON = json.dumps({
    "type": "Polygon",
    "coordinates": [CA_POLYGON_COORDS],
})

# A polygon straddling the Idaho western border (partly in Oregon, partly Idaho)
STRADDLER_COORDS = [
    [-117.5, 42.5],  # in Oregon
    [-116.0, 42.5],  # in Idaho
    [-116.0, 43.5],  # in Idaho
    [-117.5, 43.5],  # in Oregon
    [-117.5, 42.5],
]
STRADDLER_GEOJSON = json.dumps({
    "type": "Polygon",
    "coordinates": [STRADDLER_COORDS],
})


# ===========================================================================
# MonitoringArea.as_box
# ===========================================================================

class TestMonitoringArea:
    def test_as_box_is_shapely_polygon(self):
        from shapely.geometry import box
        b = IDAHO.as_box()
        expected = box(IDAHO.west, IDAHO.south, IDAHO.east, IDAHO.north)
        assert b.equals(expected)

    def test_frozen_immutability(self):
        with pytest.raises(Exception):
            IDAHO.north = 99.0  # type: ignore[misc]


# ===========================================================================
# build_geom_json — geometry extraction priority
# ===========================================================================

class TestBuildGeomJson:
    def test_none_input_returns_none(self):
        assert build_geom_json(None) is None

    def test_empty_dict_returns_none(self):
        assert build_geom_json({}) is None

    def test_geometry_wins_over_bbox(self):
        geo = {
            "geometry": {"type": "Point", "coordinates": [TF_LON, TF_LAT]},
            "bbox": [-115.0, 42.0, -113.0, 43.0],
        }
        result = build_geom_json(geo)
        assert json.loads(result) == {"type": "Point", "coordinates": [TF_LON, TF_LAT]}

    def test_bbox_renders_as_polygon(self):
        geo = {"bbox": [-115.0, 42.0, -113.0, 43.0]}
        result = build_geom_json(geo)
        parsed = json.loads(result)
        assert parsed["type"] == "Polygon"
        coords = parsed["coordinates"][0]
        # should be a closed ring of 5 points
        assert len(coords) == 5
        assert coords[0] == coords[-1]

    def test_centroid_renders_as_point(self):
        geo = {"centroid": [TF_LON, TF_LAT]}
        result = build_geom_json(geo)
        parsed = json.loads(result)
        assert parsed == {"type": "Point", "coordinates": [TF_LON, TF_LAT]}

    def test_bbox_wins_over_centroid(self):
        geo = {
            "bbox": [-115.0, 42.0, -113.0, 43.0],
            "centroid": [LA_LON, LA_LAT],
        }
        result = build_geom_json(geo)
        assert "Polygon" in result

    def test_returns_none_if_no_usable_shape(self):
        geo = {"name": "irrelevant"}
        assert build_geom_json(geo) is None


# ===========================================================================
# classify_geom_areas
# ===========================================================================

class TestClassifyGeomAreas:
    def test_null_geom_returns_null_geom(self):
        assert classify_geom_areas(None, IDAHO_AREAS) == "null-geom"

    def test_no_area_returns_no_area(self):
        geom_json = json.dumps({"type": "Point", "coordinates": [TF_LON, TF_LAT]})
        assert classify_geom_areas(geom_json, []) == "no-area"

    def test_idaho_point_in_bounds(self):
        geom_json = json.dumps({"type": "Point", "coordinates": [TF_LON, TF_LAT]})
        assert classify_geom_areas(geom_json, IDAHO_AREAS) == "in-bounds"

    def test_la_point_out_of_bounds(self):
        """LA repro: a point at (34.05N, 118.24W) must be OUT of an Idaho box."""
        geom_json = json.dumps({"type": "Point", "coordinates": [LA_LON, LA_LAT]})
        assert classify_geom_areas(geom_json, IDAHO_AREAS) == "out-of-bounds"

    def test_ca_polygon_out_of_bounds(self):
        """CA polygon straddling 34N should be fully outside Idaho."""
        assert classify_geom_areas(CA_POLYGON_GEOJSON, IDAHO_AREAS) == "out-of-bounds"

    def test_straddler_polygon_in_bounds(self):
        """Polygon crossing the ID/OR border still intersects Idaho → in-bounds."""
        assert classify_geom_areas(STRADDLER_GEOJSON, IDAHO_AREAS) == "in-bounds"

    def test_invalid_geom_returns_invalid_geom(self):
        assert classify_geom_areas("this is not json", IDAHO_AREAS) == "invalid-geom"

    def test_empty_geom_string_returns_invalid_geom(self):
        assert classify_geom_areas("", IDAHO_AREAS) == "invalid-geom"

    def test_multi_area_union_point_in_second_box(self):
        """A point inside the Yellowstone box (not Idaho) → in-bounds via union."""
        # Yellowstone area: north=45.2, south=44.0, east=-109.5, west=-111.5
        ys_point = json.dumps({"type": "Point", "coordinates": [-110.5, 44.5]})
        assert classify_geom_areas(ys_point, TWO_AREAS) == "in-bounds"

    def test_multi_area_union_point_in_neither_box(self):
        """A point in Wyoming (but not in either box) → out-of-bounds."""
        # Far eastern Wyoming, outside both boxes
        wy_point = json.dumps({"type": "Point", "coordinates": [-104.0, 42.5]})
        assert classify_geom_areas(wy_point, TWO_AREAS) == "out-of-bounds"

    def test_border_edge_point_is_in_bounds(self):
        """A point exactly on the Idaho box edge → in-bounds (intersects semantics)."""
        # Exactly on the western edge of Idaho box (lon=-117, lat=43)
        edge = json.dumps({"type": "Point", "coordinates": [-117.0, 43.0]})
        assert classify_geom_areas(edge, IDAHO_AREAS) == "in-bounds"


# ===========================================================================
# areas_from_config
# ===========================================================================

class TestAreasFromConfig:
    def test_from_areas_list(self):
        cov = Coverage(areas=[
            {"name": "Idaho", "west": -117.0, "south": 42.0, "east": -111.0, "north": 44.0}
        ])
        areas = areas_from_config(cov)
        assert len(areas) == 1
        a = areas[0]
        assert a.west == -117.0 and a.south == 42.0
        assert a.east == -111.0 and a.north == 44.0

    def test_falls_back_to_bbox(self):
        cov = Coverage(bbox=[-117.0, 42.0, -111.0, 44.0])
        areas = areas_from_config(cov)
        assert len(areas) == 1
        assert areas[0].west == -117.0

    def test_areas_takes_precedence_over_bbox(self):
        cov = Coverage(
            bbox=[-110.0, 30.0, -100.0, 35.0],  # TX — totally different
            areas=[{"name": "ID", "west": -117.0, "south": 42.0, "east": -111.0, "north": 44.0}],
        )
        areas = areas_from_config(cov)
        assert len(areas) == 1
        assert areas[0].west == -117.0  # Idaho, not TX

    def test_empty_config_returns_empty(self):
        cov = Coverage()
        assert areas_from_config(cov) == []

    def test_multi_area_list(self):
        cov = Coverage(areas=[
            {"name": "Idaho", "west": -117.0, "south": 42.0, "east": -111.0, "north": 44.0},
            {"name": "Yellowstone", "west": -111.5, "south": 44.0, "east": -109.5, "north": 45.2},
        ])
        areas = areas_from_config(cov)
        assert len(areas) == 2

    def test_malformed_area_dict_skipped(self):
        cov = Coverage(areas=[
            {"name": "bad"},  # missing west/south/east/north
            {"name": "Idaho", "west": -117.0, "south": 42.0, "east": -111.0, "north": 44.0},
        ])
        areas = areas_from_config(cov)
        assert len(areas) == 1  # only the valid one


# ===========================================================================
# event_in_areas
# ===========================================================================

class TestEventInAreas:
    def _event_with_geom(self, geometry_dict, lat=None, lon=None):
        return make_event(
            source="nws", category="weather_warning", severity="priority",
            title="Test", lat=lat, lon=lon,
            data={"geometry": geometry_dict},
        )

    def _event_with_centroid(self, lat, lon):
        return make_event(
            source="nws", category="weather_warning", severity="priority",
            title="Test", lat=lat, lon=lon,
        )

    def test_idaho_centroid_kept(self):
        event = self._event_with_centroid(TF_LAT, TF_LON)
        assert event_in_areas(event, IDAHO_AREAS) is True

    def test_la_centroid_dropped(self):
        """LA-repro: centroid at (34.05, -118.24) must be dropped by Idaho gate."""
        event = self._event_with_centroid(LA_LAT, LA_LON)
        assert event_in_areas(event, IDAHO_AREAS) is False

    def test_ca_polygon_dropped(self):
        """LA-repro: event carrying a CA polygon must be dropped by Idaho gate."""
        ca_geom = {"type": "Polygon", "coordinates": [CA_POLYGON_COORDS]}
        event = self._event_with_geom(ca_geom)
        assert event_in_areas(event, IDAHO_AREAS) is False

    def test_idaho_polygon_kept(self):
        idaho_geom = {
            "type": "Polygon",
            "coordinates": [[
                [-115.0, 42.5], [-113.0, 42.5], [-113.0, 43.5],
                [-115.0, 43.5], [-115.0, 42.5],
            ]],
        }
        event = self._event_with_geom(idaho_geom)
        assert event_in_areas(event, IDAHO_AREAS) is True

    def test_no_geometry_kept_fail_open(self):
        """Event with no lat/lon and no data geometry → null-geom → kept."""
        event = make_event(
            source="swpc", category="kp_index", severity="priority",
            title="No geometry event",
        )
        assert event_in_areas(event, IDAHO_AREAS) is True

    def test_empty_areas_kept(self):
        event = self._event_with_centroid(LA_LAT, LA_LON)
        assert event_in_areas(event, []) is True

    def test_event_with_bbox_in_data(self):
        """Event carrying data[bbox] (not geometry) is classified via bbox polygon."""
        event = make_event(
            source="firms", category="wildfire_hotspot", severity="priority",
            title="Fire",
            data={"bbox": [-114.5, 42.5, -113.5, 43.5]},  # Idaho bbox
        )
        assert event_in_areas(event, IDAHO_AREAS) is True

    def test_event_with_out_of_area_bbox_in_data(self):
        event = make_event(
            source="firms", category="wildfire_hotspot", severity="priority",
            title="Fire",
            data={"bbox": [-119.0, 33.5, -117.5, 34.6]},  # CA bbox
        )
        assert event_in_areas(event, IDAHO_AREAS) is False


# ===========================================================================
# CoverageFilter — unit tests
# ===========================================================================

class TestCoverageFilter:
    def _make_filter(self, areas=None, enabled=True, excluded=None, received=None):
        if received is None:
            received = []
        flt = CoverageFilter(
            next_handler=received.append,
            areas=areas if areas is not None else IDAHO_AREAS,
            enabled=enabled,
            excluded_adapters=set(excluded or []),
        )
        return flt, received

    # -- LA leak repro -------------------------------------------------------

    def test_la_event_with_polygon_dropped(self):
        """LA-repro: event with CA polygon GeoJSON is dropped by Idaho gate."""
        flt, received = self._make_filter()
        ca_geom = {"type": "Polygon", "coordinates": [CA_POLYGON_COORDS]}
        event = make_event(
            source="nws", category="weather_warning", severity="priority",
            title="Severe Thunderstorm Warning — Los Angeles County",
            lat=LA_LAT, lon=LA_LON,
            data={"geometry": ca_geom},
        )
        flt.handle(event)
        assert len(received) == 0, "LA event with CA polygon must be dropped"

    def test_la_event_with_centroid_dropped(self):
        """LA-repro: event with centroid at LA is dropped by Idaho gate."""
        flt, received = self._make_filter()
        event = make_event(
            source="nws", category="weather_warning", severity="priority",
            title="LA weather alert", lat=LA_LAT, lon=LA_LON,
        )
        flt.handle(event)
        assert len(received) == 0, "LA centroid event must be dropped"

    # -- Idaho events pass ---------------------------------------------------

    def test_idaho_event_kept(self):
        flt, received = self._make_filter()
        event = make_event(
            source="nws", category="weather_warning", severity="priority",
            title="Idaho alert", lat=TF_LAT, lon=TF_LON,
        )
        flt.handle(event)
        assert len(received) == 1

    # -- Disabled / no-areas pass-through ------------------------------------

    def test_coverage_disabled_keeps_everything(self):
        flt, received = self._make_filter(enabled=False)
        event = make_event(
            source="nws", category="weather_warning", severity="priority",
            title="LA alert", lat=LA_LAT, lon=LA_LON,
        )
        flt.handle(event)
        assert len(received) == 1

    def test_empty_areas_keeps_everything(self):
        flt, received = self._make_filter(areas=[])
        event = make_event(
            source="nws", category="weather_warning", severity="priority",
            title="LA alert", lat=LA_LAT, lon=LA_LON,
        )
        flt.handle(event)
        assert len(received) == 1

    # -- Excluded adapter bypass ---------------------------------------------

    def test_excluded_adapter_bypasses_gate(self):
        """Events from excluded adapters pass even if out of area."""
        flt, received = self._make_filter(excluded=["swpc"])
        event = make_event(
            source="swpc", category="kp_index", severity="priority",
            title="Solar event", lat=LA_LAT, lon=LA_LON,
        )
        flt.handle(event)
        assert len(received) == 1

    def test_non_excluded_adapter_is_gated(self):
        """Events from non-excluded adapters still go through the gate."""
        flt, received = self._make_filter(excluded=["swpc"])
        event = make_event(
            source="nws", category="weather_warning", severity="priority",
            title="LA alert", lat=LA_LAT, lon=LA_LON,
        )
        flt.handle(event)
        assert len(received) == 0

    # -- fail-open -----------------------------------------------------------

    def test_no_geometry_event_kept_fail_open(self):
        """Events with no geometry (null-geom) are kept (fail-open)."""
        flt, received = self._make_filter()
        event = make_event(
            source="swpc", category="kp_index", severity="priority",
            title="Solar storm — no location",
        )
        flt.handle(event)
        assert len(received) == 1

    # -- multi-area union ----------------------------------------------------

    def test_multi_area_point_in_second_box_kept(self):
        flt, received = self._make_filter(areas=TWO_AREAS)
        # A point in the Yellowstone box (not Idaho proper)
        event = make_event(
            source="nws", category="weather_warning", severity="priority",
            title="Yellowstone area alert", lat=44.5, lon=-110.5,
        )
        flt.handle(event)
        assert len(received) == 1

    def test_multi_area_point_in_neither_dropped(self):
        flt, received = self._make_filter(areas=TWO_AREAS)
        # Far eastern Wyoming — outside both boxes
        event = make_event(
            source="nws", category="weather_warning", severity="priority",
            title="Wyoming alert", lat=42.5, lon=-104.0,
        )
        flt.handle(event)
        assert len(received) == 0


# ===========================================================================
# Pipeline wiring — CoverageFilter is in the chain
# ===========================================================================

class TestCoverageFilterPipelineWiring:
    def test_coverage_filter_wired_in_pipeline(self):
        """build_pipeline stashes coverage_filter in _pipeline_components."""
        from unittest.mock import MagicMock, AsyncMock
        from meshai.notifications.pipeline import build_pipeline

        config = Coverage.__new__(Coverage)  # we need a full Config below
        from meshai.config import Config
        config = Config()
        config.coverage = Coverage(
            areas=[{"name": "Idaho", "west": -117.0, "south": 42.0,
                    "east": -111.0, "north": 44.0}],
            enabled=True,
        )
        mock_backend = MagicMock()
        mock_backend.generate = AsyncMock(return_value="stub")

        bus = build_pipeline(config, mock_backend)
        comps = bus._pipeline_components
        assert "coverage_filter" in comps
        cf = comps["coverage_filter"]
        assert hasattr(cf, "handle")
        assert isinstance(cf, CoverageFilter)

    def test_coverage_filter_gate_fires_in_pipeline(self):
        """Events emitted to the bus from out-of-area are dropped before dispatch."""
        from unittest.mock import MagicMock, AsyncMock
        from meshai.notifications.pipeline import build_pipeline
        from meshai.config import Config, NotificationToggle

        config = Config()
        config.coverage = Coverage(
            areas=[{"name": "Idaho", "west": -117.0, "south": 42.0,
                    "east": -111.0, "north": 44.0}],
            enabled=True,
        )
        # Enable weather toggle so ToggleFilter passes the event through to
        # CoverageFilter (otherwise toggle filter swallows it first)
        config.notifications.toggles["weather"].enabled = True

        mock_backend = MagicMock()
        mock_backend.generate = AsyncMock(return_value="stub")

        received_by_tee: list = []
        bus = build_pipeline(config, mock_backend)

        # Monkeypatch the coverage_filter's next_handler to observe what reaches _tee
        cf = bus._pipeline_components["coverage_filter"]
        cf._next = received_by_tee.append  # type: ignore[assignment]

        # Emit an out-of-area LA event
        la_event = make_event(
            source="nws", category="weather_warning", severity="priority",
            title="LA Thunderstorm Warning", lat=LA_LAT, lon=LA_LON,
        )
        bus.emit(la_event)
        assert len(received_by_tee) == 0, "LA event must not reach _tee"

        # Emit an in-area Idaho event
        id_event = make_event(
            source="nws", category="weather_warning", severity="priority",
            title="Idaho Thunderstorm Warning", lat=TF_LAT, lon=TF_LON,
        )
        bus.emit(id_event)
        assert len(received_by_tee) == 1, "Idaho event must reach _tee"


# ===========================================================================
# Idaho + MultiPolygon geometry fixtures (for the fail-closed / geometry work)
# ===========================================================================

# A polygon firmly inside the Idaho box (centroid ~ 42.9N, -114.2W → Twin Falls)
ID_POLYGON_COORDS = [
    [-115.0, 42.5],
    [-113.0, 42.5],
    [-113.0, 43.5],
    [-115.0, 43.5],
    [-115.0, 42.5],
]
ID_POLYGON_GEOM = {"type": "Polygon", "coordinates": [ID_POLYGON_COORDS]}

# A MultiPolygon whose FIRST polygon's outer ring is inside Idaho; a second,
# far-away polygon (in Kansas) exercises the "first polygon wins" centroid rule.
KS_POLYGON_COORDS = [
    [-98.0, 38.0],
    [-97.0, 38.0],
    [-97.0, 39.0],
    [-98.0, 39.0],
    [-98.0, 38.0],
]
ID_MULTIPOLYGON_GEOM = {
    "type": "MultiPolygon",
    "coordinates": [[ID_POLYGON_COORDS], [KS_POLYGON_COORDS]],
}


# ===========================================================================
# classify_event_areas — 3-state classification helper
# ===========================================================================

class TestClassifyEventAreas:
    def test_idaho_polygon_in_bounds(self):
        event = make_event(
            source="nws", category="weather_advisory", severity="routine",
            title="ID advisory", data={"geometry": ID_POLYGON_GEOM},
        )
        assert classify_event_areas(event, IDAHO_AREAS) == "in-bounds"

    def test_ca_polygon_out_of_bounds(self):
        ca_geom = {"type": "Polygon", "coordinates": [CA_POLYGON_COORDS]}
        event = make_event(
            source="nws", category="weather_advisory", severity="routine",
            title="CA advisory", data={"geometry": ca_geom},
        )
        assert classify_event_areas(event, IDAHO_AREAS) == "out-of-bounds"

    def test_no_geometry_no_centroid_is_null_geom(self):
        """The exact zone-only-alert shape: geometry=None, no lat/lon."""
        event = make_event(
            source="nws", category="weather_advisory", severity="routine",
            title="Heat Advisory (zone-only)", data={"geometry": None},
        )
        assert classify_event_areas(event, IDAHO_AREAS) == "null-geom"

    def test_multipolygon_in_bounds(self):
        event = make_event(
            source="nws", category="weather_warning", severity="priority",
            title="ID multipolygon warning", data={"geometry": ID_MULTIPOLYGON_GEOM},
        )
        assert classify_event_areas(event, IDAHO_AREAS) == "in-bounds"


# ===========================================================================
# CoverageFilter — fail-CLOSED for weather, fail-OPEN for everything else
# ===========================================================================

class TestCoverageFilterFailClosed:
    def _make_filter(self, areas=None, excluded=None):
        received: list = []
        flt = CoverageFilter(
            next_handler=received.append,
            areas=areas if areas is not None else IDAHO_AREAS,
            enabled=True,
            excluded_adapters=set(excluded or []),
        )
        return flt, received

    def test_fail_closed_categories_are_the_nws_categories(self):
        """Guard: the fail-closed set is exactly env/nws._derive_category's outputs."""
        assert FAIL_CLOSED_CATEGORIES == {
            "weather_warning", "weather_watch",
            "weather_advisory", "weather_statement",
        }

    # -- THE REAL LA LEAK: zone-only advisory, no polygon, no centroid --------

    def test_zone_only_advisory_no_geometry_dropped_failclosed(self):
        """REAL repro of the leak: a zone-only NWS Heat Advisory with NO polygon
        and NO centroid, under an Idaho-only coverage area, is now DROPPED
        (fail-closed). Previously the fail-open gate KEPT it → wrong-region leak.
        """
        flt, received = self._make_filter()
        event = make_event(
            source="nws", category="weather_advisory", severity="routine",
            title="Heat Advisory issued for Los Angeles County",
            # zone-only: geometry is explicitly None, and there is NO lat/lon
            data={"geometry": None},
        )
        assert event.lat is None and event.lon is None  # truly unlocatable
        flt.handle(event)
        assert len(received) == 0, (
            "zone-only weather advisory with no geometry must be DROPPED "
            "(fail-closed) — this is the exact LA leak"
        )

    def test_zone_only_statement_no_geometry_dropped_failclosed(self):
        """A weather_statement (Special Weather Statement) with no geometry is
        also fail-closed — all four NWS categories are covered."""
        flt, received = self._make_filter()
        event = make_event(
            source="nws", category="weather_statement", severity="routine",
            title="Special Weather Statement", data={"geometry": None},
        )
        flt.handle(event)
        assert len(received) == 0

    # -- out-of-bounds real polygon (CA) -------------------------------------

    def test_ca_polygon_advisory_dropped_out_of_bounds(self):
        flt, received = self._make_filter()
        ca_geom = {"type": "Polygon", "coordinates": [CA_POLYGON_COORDS]}
        event = make_event(
            source="nws", category="weather_advisory", severity="routine",
            title="CA advisory", data={"geometry": ca_geom},
        )
        flt.handle(event)
        assert len(received) == 0, "out-of-bounds CA polygon must be dropped"

    # -- in-bounds real polygon (ID) -----------------------------------------

    def test_idaho_polygon_advisory_kept_in_bounds(self):
        flt, received = self._make_filter()
        event = make_event(
            source="nws", category="weather_advisory", severity="routine",
            title="ID advisory", data={"geometry": ID_POLYGON_GEOM},
        )
        flt.handle(event)
        assert len(received) == 1, "in-bounds Idaho polygon must be kept"

    def test_idaho_multipolygon_advisory_kept_in_bounds(self):
        flt, received = self._make_filter()
        event = make_event(
            source="nws", category="weather_advisory", severity="routine",
            title="ID multipolygon advisory", data={"geometry": ID_MULTIPOLYGON_GEOM},
        )
        flt.handle(event)
        assert len(received) == 1, "in-bounds Idaho MultiPolygon must be kept"

    # -- fail-OPEN preserved for non-weather ---------------------------------

    def test_non_weather_no_geometry_kept_fail_open(self):
        """A non-weather event (quake) with no geometry AND no centroid is still
        KEPT (fail-open unchanged) — proves we did not over-tighten other
        adapters. Only the FAIL_CLOSED_CATEGORIES are dropped when unlocatable.
        """
        flt, received = self._make_filter()
        event = make_event(
            source="usgs", category="earthquake", severity="priority",
            title="Quake with no geometry",  # no lat/lon, no data geometry
        )
        assert event.lat is None and event.lon is None
        assert event.category not in FAIL_CLOSED_CATEGORIES
        flt.handle(event)
        assert len(received) == 1, "non-weather unlocatable event must be KEPT (fail-open)"

    def test_swpc_no_geometry_kept_fail_open(self):
        """SWPC (space weather, global) with no geometry stays fail-open."""
        flt, received = self._make_filter()
        event = make_event(
            source="swpc", category="kp_index", severity="priority",
            title="Geomagnetic storm — no location",
        )
        flt.handle(event)
        assert len(received) == 1


# ===========================================================================
# NWS adapter — attaches raw alert geometry + MultiPolygon centroid, and the
# geometry flows env-event → to_event → Event.data["geometry"]
# ===========================================================================

def _nws_adapter():
    config = MagicMock()
    config.areas = ["ID"]
    config.user_agent = "(test, test@example.com)"
    config.severity_min = "moderate"
    config.tick_seconds = 60
    return NWSAlertsAdapter(config)


def _mock_urlopen_with_features(features):
    """Return a context-manager mock whose .read() yields a geo+json payload."""
    payload = json.dumps({"features": features}).encode("utf-8")
    resp = MagicMock()
    resp.read.return_value = payload
    resp.__enter__.return_value = resp
    resp.__exit__.return_value = False
    return resp


class TestNWSGeometryAttach:
    def _fetch_one(self, geometry):
        adapter = _nws_adapter()
        feature = {
            "properties": {
                "id": "urn:oid:nws-test-1",
                "event": "Heat Advisory",
                "severity": "Moderate",
                "headline": "Heat Advisory",
                "description": "Hot.",
                "areaDesc": "Test County",
            },
            "geometry": geometry,
        }
        resp = _mock_urlopen_with_features([feature])
        with patch("meshai.env.nws.urlopen", return_value=resp):
            adapter._fetch()
        events = adapter.get_events()
        assert len(events) == 1
        return adapter, events[0]

    def test_polygon_geometry_attached_to_event(self):
        """The parsed env event carries event['geometry'] = the feature geometry."""
        _, event = self._fetch_one(ID_POLYGON_GEOM)
        assert event["geometry"] == ID_POLYGON_GEOM

    def test_polygon_centroid_computed(self):
        _, event = self._fetch_one(ID_POLYGON_GEOM)
        # centroid of the ID polygon ring ~ (42.9N, -114.2W) — inside Idaho
        assert 42.0 <= event["lat"] <= 44.0
        assert -117.0 <= event["lon"] <= -111.0

    def test_multipolygon_geometry_attached_and_centroid_computed(self):
        """MultiPolygon: geometry attached verbatim + centroid from first
        polygon's outer ring (must land in Idaho, not the far-away 2nd polygon).
        """
        _, event = self._fetch_one(ID_MULTIPOLYGON_GEOM)
        assert event["geometry"] == ID_MULTIPOLYGON_GEOM
        # first polygon is the Idaho one → centroid inside Idaho
        assert 42.0 <= event["lat"] <= 44.0
        assert -117.0 <= event["lon"] <= -111.0

    def test_zone_only_geometry_is_none(self):
        """A zone-only alert (no geometry) yields event['geometry'] is None and
        NO centroid — the exact unlocatable shape the gate fails closed on."""
        _, event = self._fetch_one(None)
        assert event["geometry"] is None
        assert "lat" not in event and "lon" not in event

    def test_to_event_carries_geometry_into_data(self):
        """Trace: env event['geometry'] → to_event → Event.data['geometry']."""
        adapter, event = self._fetch_one(ID_POLYGON_GEOM)
        pipeline_event = adapter.to_event(event)
        assert pipeline_event.data["geometry"] == ID_POLYGON_GEOM

    def test_to_event_carries_none_geometry_for_zone_only(self):
        adapter, event = self._fetch_one(None)
        pipeline_event = adapter.to_event(event)
        assert pipeline_event.data["geometry"] is None
