"""
Unit tests for core/news_filter.py.

Covers:
  - Disabled / unsynced fail-open.
  - Block windows: BEFORE (45 min default) and AFTER (15 min default).
  - HIGH-only gating (MEDIUM cached but doesn't block).
  - Currency routing via ASSET_CURRENCIES (USD news blocks all USD-quoted
    assets; cross-currency only blocks the matched leg).
  - Unknown-symbol fail-open.
  - BlockingEvent payload completeness (for log forensics).
  - JSON parser: real FF schema parses correctly; malformed entries
    skipped without raising; events outside next-24h window dropped.

Sync method exercised by injecting `_upcoming` directly — no real
HTTP. One parser-level test feeds a JSON byte string through `_parse`.
"""

from __future__ import annotations

import asyncio
import json
import sys
import urllib.error
from datetime import datetime, timedelta, timezone
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from core import config_futures as cfg_fut
from core.news_filter import BlockingEvent, NewsEvent, NewsFilter


# ============================================================
# HELPERS
# ============================================================

def _ok(label: str) -> None:
    print(f"  ok  {label}")


def make_filter(*, enabled: bool = True, before: int = 45, after: int = 15,
                 events: list[NewsEvent] | None = None) -> NewsFilter:
    nf = NewsFilter(
        enabled=enabled,
        before_min=before,
        after_min=after,
        source_url="https://example.invalid/ff.json",
        http_timeout=1,
        logger=None,
    )
    if events:
        nf._upcoming = list(events)
        nf._last_sync_at = datetime.now(timezone.utc)
    return nf


def event(*, dt: datetime, country: str = "USD", impact: str = "High",
          title: str = "Test Event") -> NewsEvent:
    return NewsEvent(dt=dt, country=country, impact=impact, title=title)


# ============================================================
# DISABLED / UNSYNCED FAIL-OPEN
# ============================================================

def test_disabled_filter_never_blocks():
    now = datetime.now(timezone.utc)
    nf = make_filter(enabled=False, events=[
        event(dt=now + timedelta(minutes=5), country="USD"),  # imminent
    ])
    assert nf.is_blocked("MES", now) is None
    _ok("disabled filter never blocks even with imminent event")


def test_unsynced_fail_open():
    """No sync ever done -> empty _upcoming -> no block."""
    now = datetime.now(timezone.utc)
    nf = make_filter()  # no events injected
    assert nf._last_sync_at is None
    assert nf.is_blocked("MES", now) is None
    _ok("never-synced filter is fail-open (returns None)")


# ============================================================
# BLOCK WINDOWS — BEFORE / AFTER
# ============================================================

def test_blocks_pre_event_within_before_window():
    """USD HIGH event +30min, before_min=45 -> blocked, reason BEFORE."""
    now = datetime.now(timezone.utc)
    ev = event(dt=now + timedelta(minutes=30))
    nf = make_filter(events=[ev])
    blk = nf.is_blocked("MES", now)
    assert isinstance(blk, BlockingEvent)
    assert blk.reason == "BEFORE"
    assert 29 < blk.minutes_to_event < 31
    assert blk.event is ev
    _ok("blocks 30 min before event (within before_min=45)")


def test_not_blocked_pre_event_outside_window():
    """USD HIGH event +60min, before_min=45 -> not blocked."""
    now = datetime.now(timezone.utc)
    nf = make_filter(events=[event(dt=now + timedelta(minutes=60))])
    assert nf.is_blocked("MES", now) is None
    _ok("not blocked 60 min before event (outside before_min=45)")


def test_blocks_post_event_within_after_window():
    """USD HIGH event -10min, after_min=15 -> blocked, reason AFTER."""
    now = datetime.now(timezone.utc)
    ev = event(dt=now - timedelta(minutes=10))
    nf = make_filter(events=[ev])
    blk = nf.is_blocked("MES", now)
    assert isinstance(blk, BlockingEvent)
    assert blk.reason == "AFTER"
    assert -11 < blk.minutes_to_event < -9
    _ok("blocks 10 min after event (within after_min=15)")


def test_not_blocked_post_event_outside_window():
    """USD HIGH event -30min, after_min=15 -> not blocked."""
    now = datetime.now(timezone.utc)
    nf = make_filter(events=[event(dt=now - timedelta(minutes=30))])
    assert nf.is_blocked("MES", now) is None
    _ok("not blocked 30 min after event (outside after_min=15)")


def test_block_window_boundary_inclusive_before():
    """At exactly +before_min: still blocked (inclusive boundary)."""
    now = datetime.now(timezone.utc)
    nf = make_filter(events=[event(dt=now + timedelta(minutes=45))])
    blk = nf.is_blocked("MES", now)
    assert blk is not None and blk.reason == "BEFORE"
    _ok("boundary +45 min before event is INCLUSIVE (blocks)")


def test_block_window_boundary_inclusive_after():
    """At exactly -after_min: still blocked (inclusive boundary)."""
    now = datetime.now(timezone.utc)
    nf = make_filter(events=[event(dt=now - timedelta(minutes=15))])
    blk = nf.is_blocked("MES", now)
    assert blk is not None and blk.reason == "AFTER"
    _ok("boundary -15 min after event is INCLUSIVE (blocks)")


# ============================================================
# IMPACT GATING — HIGH only
# ============================================================

def test_medium_impact_does_not_block():
    """MEDIUM impact event in window -> not blocked (V14: HIGH only gates)."""
    now = datetime.now(timezone.utc)
    nf = make_filter(events=[
        event(dt=now + timedelta(minutes=10), impact="Medium"),
    ])
    assert nf.is_blocked("MES", now) is None
    _ok("MEDIUM impact event in window does NOT block (HIGH-only gate)")


def test_low_impact_does_not_block():
    """Defensive: even if a Low slipped past parser, it shouldn't block."""
    now = datetime.now(timezone.utc)
    nf = make_filter(events=[
        event(dt=now + timedelta(minutes=10), impact="Low"),
    ])
    assert nf.is_blocked("MES", now) is None
    _ok("LOW impact event in window does NOT block (defense-in-depth)")


# ============================================================
# CURRENCY ROUTING — ASSET_CURRENCIES
# ============================================================

def test_irrelevant_currency_does_not_block_usd_only_asset():
    """GBP HIGH event +10min for MES (USD-only) -> not blocked."""
    now = datetime.now(timezone.utc)
    nf = make_filter(events=[
        event(dt=now + timedelta(minutes=10), country="GBP"),
    ])
    assert nf.is_blocked("MES", now) is None
    _ok("GBP event does NOT block MES (USD-only currency exposure)")


def test_usd_event_blocks_all_usd_quoted_assets():
    """USD HIGH event blocks MES, MGC, MCL, AND all FX (USD leg)."""
    now = datetime.now(timezone.utc)
    nf = make_filter(events=[event(dt=now + timedelta(minutes=10), country="USD")])
    for sym in ("MES", "MNQ", "MYM", "MGC", "MCL", "6E", "6B", "6A", "6J", "6C"):
        blk = nf.is_blocked(sym, now)
        assert blk is not None, f"{sym} must be blocked by USD event"
    _ok("USD event blocks ALL USD-quoted assets (indices, metals, energy, FX)")


def test_eur_event_blocks_only_6e():
    """EUR HIGH event +10min: blocks 6E, NOT MES/6B/etc."""
    now = datetime.now(timezone.utc)
    nf = make_filter(events=[event(dt=now + timedelta(minutes=10), country="EUR")])
    assert nf.is_blocked("6E", now) is not None
    for sym in ("MES", "MNQ", "MYM", "MGC", "MCL", "6B", "6A", "6J", "6C"):
        assert nf.is_blocked(sym, now) is None, f"{sym} must NOT be blocked by EUR"
    _ok("EUR event blocks only 6E (cross-currency leg routing)")


def test_unknown_symbol_fail_open():
    """Symbol absent from ASSET_CURRENCIES -> never blocks (onboarding)."""
    now = datetime.now(timezone.utc)
    nf = make_filter(events=[event(dt=now + timedelta(minutes=10))])
    assert nf.is_blocked("ZZZ_UNKNOWN", now) is None
    _ok("unknown symbol -> fail-open (onboarding-friendly)")


# ============================================================
# BLOCKING EVENT PAYLOAD
# ============================================================

def test_blocking_event_payload_complete():
    """BlockingEvent must carry full diagnostics for log forensics."""
    now = datetime.now(timezone.utc)
    ev = event(
        dt=now + timedelta(minutes=20),
        country="USD",
        impact="High",
        title="FOMC Statement",
    )
    nf = make_filter(events=[ev])
    blk = nf.is_blocked("MES", now)
    assert blk is not None
    assert blk.event.title == "FOMC Statement"
    assert blk.event.country == "USD"
    assert blk.event.impact == "High"
    assert blk.event.dt == ev.dt
    assert isinstance(blk.minutes_to_event, float)
    assert blk.reason in ("BEFORE", "AFTER")
    _ok("BlockingEvent payload carries full forensics")


# ============================================================
# PARSER — real FF JSON shape
# ============================================================

def test_parse_real_ff_json_shape():
    """
    Feed a JSON list with FF-shaped entries to NewsFilter._parse.
    Verify: HIGH+MEDIUM kept, LOW dropped, malformed skipped, all UTC.
    """
    now = datetime.now(timezone.utc)
    soon = now + timedelta(minutes=30)
    later = now + timedelta(hours=12)
    too_late = now + timedelta(days=2)   # outside next-24h window
    iso = lambda d: d.replace(microsecond=0).isoformat()

    payload = [
        {"title": "FOMC Statement",     "country": "USD",
         "impact": "High",   "date": iso(soon)},
        {"title": "ECB Rate Decision",  "country": "EUR",
         "impact": "Medium", "date": iso(later)},
        {"title": "Random Speech",      "country": "USD",
         "impact": "Low",    "date": iso(soon)},      # dropped: Low
        {"title": "Out of window",      "country": "USD",
         "impact": "High",   "date": iso(too_late)},  # dropped: >24h
        {"title": "Malformed date",     "country": "USD",
         "impact": "High",   "date": "garbage"},       # dropped: parse error
        {"title": "No date field",      "country": "USD",
         "impact": "High"},                            # dropped: missing date
    ]
    raw = json.dumps(payload).encode("utf-8")
    events = NewsFilter._parse(raw)
    titles = [e.title for e in events]
    assert "FOMC Statement" in titles
    assert "ECB Rate Decision" in titles
    assert "Random Speech" not in titles
    assert "Out of window" not in titles
    assert "Malformed date" not in titles
    assert "No date field" not in titles
    # All datetimes must be tz-aware UTC
    for e in events:
        assert e.dt.tzinfo is not None
        assert e.dt.utcoffset() == timedelta(0)
    # Sorted ascending by dt
    assert events == sorted(events, key=lambda e: e.dt)
    _ok("parser: HIGH+MEDIUM kept, LOW/out-of-window/malformed dropped, sorted UTC")


def test_parse_iso8601_with_offset_converted_to_utc():
    """ISO 8601 with explicit -04:00 offset -> converted to UTC correctly."""
    now = datetime.now(timezone.utc)
    # Build event at now+30min, expressed as -04:00 (US EDT)
    edt = timezone(timedelta(hours=-4))
    soon_edt = (now + timedelta(minutes=30)).astimezone(edt).replace(microsecond=0)
    payload = [{
        "title": "NFP", "country": "USD", "impact": "High",
        "date": soon_edt.isoformat(),
    }]
    raw = json.dumps(payload).encode("utf-8")
    events = NewsFilter._parse(raw)
    assert len(events) == 1
    e = events[0]
    assert e.dt.tzinfo is not None
    assert e.dt.utcoffset() == timedelta(0)
    # Round-trip: original moment preserved
    diff_sec = abs((e.dt - (now + timedelta(minutes=30))).total_seconds())
    assert diff_sec < 60, f"UTC conversion drifted: {diff_sec}s"
    _ok("parser: ISO 8601 with offset converted to UTC correctly")


def test_parse_invalid_payload_raises():
    """Non-list JSON (e.g. error envelope) -> ValueError so caller fails-open."""
    raw = json.dumps({"error": "rate limited"}).encode("utf-8")
    try:
        NewsFilter._parse(raw)
    except ValueError:
        _ok("parser: dict payload raises ValueError (caller fails-open)")
        return
    raise AssertionError("expected ValueError on non-list payload")


# ============================================================
# DAILY CACHE LAYER (sync())
# ============================================================

def _ff_payload_bytes(*events) -> bytes:
    """Serialize NewsEvents back to FF JSON shape for _parse round-trip."""
    return json.dumps([
        {
            "title": e.title, "country": e.country, "impact": e.impact,
            "date": e.dt.isoformat(),
        } for e in events
    ]).encode("utf-8")


def _make_cached_filter(tmp_path: Path, fetch_impl) -> NewsFilter:
    """NewsFilter wired with a cache dir and a stubbed _fetch_blocking."""
    nf = NewsFilter(
        enabled=True,
        before_min=45, after_min=15,
        source_url="https://example.invalid/ff.json",
        http_timeout=1,
        logger=None,
        cache_dir=tmp_path,
    )
    nf._fetch_blocking = fetch_impl  # type: ignore[method-assign]
    return nf


def test_cache_hit_within_same_utc_day(tmp_path):
    """Two sync() calls on the same UTC day -> only one HTTP fetch."""
    now = datetime.now(timezone.utc)
    ev = event(dt=now + timedelta(minutes=30))
    calls = {"n": 0}
    def fake_fetch():
        calls["n"] += 1
        return _ff_payload_bytes(ev)

    nf = _make_cached_filter(tmp_path, fake_fetch)
    assert asyncio.run(nf.sync()) == 1
    assert asyncio.run(nf.sync()) == 1
    assert calls["n"] == 1, "second sync must hit memory cache, not HTTP"
    _ok("same-day sync re-uses memory cache (single HTTP call)")


def test_cache_reload_from_disk_after_restart(tmp_path):
    """A fresh NewsFilter on the same day reads disk cache, no HTTP."""
    now = datetime.now(timezone.utc)
    ev = event(dt=now + timedelta(minutes=30), title="Disk-cached")
    fetch_count = {"n": 0}
    def fake_fetch():
        fetch_count["n"] += 1
        return _ff_payload_bytes(ev)

    nf1 = _make_cached_filter(tmp_path, fake_fetch)
    asyncio.run(nf1.sync())
    assert fetch_count["n"] == 1
    today = now.date().isoformat()
    assert (tmp_path / f"ff_calendar_{today}.json").exists()

    # Simulate restart: brand-new instance pointing at the same cache dir.
    def must_not_fetch():
        raise AssertionError("disk cache must satisfy the call, not HTTP")
    nf2 = _make_cached_filter(tmp_path, must_not_fetch)
    n = asyncio.run(nf2.sync())
    assert n == 1
    assert nf2._upcoming[0].title == "Disk-cached"
    _ok("post-restart sync reads disk cache; no HTTP fetch")


def test_429_fail_open_serves_existing_cache(tmp_path):
    """First fetch succeeds, second is 429: stale cache continues to serve."""
    now = datetime.now(timezone.utc)
    ev = event(dt=now + timedelta(minutes=30))
    state = {"calls": 0}
    def fake_fetch():
        state["calls"] += 1
        if state["calls"] == 1:
            return _ff_payload_bytes(ev)
        raise urllib.error.HTTPError(
            "https://example.invalid/ff.json", 429, "Too Many Requests",
            hdrs=None, fp=None,
        )

    nf = _make_cached_filter(tmp_path, fake_fetch)
    assert asyncio.run(nf.sync()) == 1
    # Force a re-fetch path by clearing the same-day short-circuit.
    nf._last_fetch_date = None
    # And bypass disk cache to ensure we hit the HTTP path.
    (tmp_path / f"ff_calendar_{now.date().isoformat()}.json").unlink()
    n = asyncio.run(nf.sync())
    assert n == 1, "must keep serving the pre-existing cache after 429"
    assert state["calls"] == 2
    assert nf._last_failed_fetch_at is not None
    _ok("HTTP 429 fail-open: existing cache kept, retry-throttle armed")


def test_429_with_no_cache_returns_zero(tmp_path):
    """No prior cache + 429 on first fetch -> 0 events, fail-open."""
    def fake_fetch():
        raise urllib.error.HTTPError(
            "https://example.invalid/ff.json", 429, "Too Many Requests",
            hdrs=None, fp=None,
        )
    nf = _make_cached_filter(tmp_path, fake_fetch)
    n = asyncio.run(nf.sync())
    assert n == 0
    # is_blocked stays fail-open with empty cache
    assert nf.is_blocked("MES", datetime.now(timezone.utc)) is None
    _ok("HTTP 429 with empty cache: 0 events, gate stays fail-open")


def test_retry_throttle_skips_repeat_fetch_within_hour(tmp_path):
    """After a failure, the next sync() within 1h must NOT re-fetch."""
    state = {"calls": 0}
    def fake_fetch():
        state["calls"] += 1
        raise urllib.error.HTTPError(
            "https://example.invalid/ff.json", 429, "Too Many Requests",
            hdrs=None, fp=None,
        )
    nf = _make_cached_filter(tmp_path, fake_fetch)
    asyncio.run(nf.sync())
    assert state["calls"] == 1
    asyncio.run(nf.sync())   # should be throttled, no second call
    assert state["calls"] == 1
    # Move the failure timestamp back 2h -> throttle expires, fetch retried.
    nf._last_failed_fetch_at = datetime.now(timezone.utc) - timedelta(hours=2)
    asyncio.run(nf.sync())
    assert state["calls"] == 2
    _ok("retry throttle: blocks re-fetch <1h after failure, allows after")


def test_disk_cache_loaded_when_memory_empty_and_no_http_needed(tmp_path):
    """Pre-seed a disk cache; first-ever sync must use it instead of HTTP."""
    now = datetime.now(timezone.utc)
    today = now.date().isoformat()
    cache_file = tmp_path / f"ff_calendar_{today}.json"
    payload = {
        "fetched_at": now.isoformat(),
        "date": today,
        "source_url": "x",
        "events": [{
            "dt": (now + timedelta(minutes=30)).isoformat(),
            "country": "USD", "impact": "High", "title": "Seeded",
        }],
    }
    cache_file.write_text(json.dumps(payload))

    def must_not_fetch():
        raise AssertionError("disk cache should satisfy the call")
    nf = _make_cached_filter(tmp_path, must_not_fetch)
    assert asyncio.run(nf.sync()) == 1
    assert nf._upcoming[0].title == "Seeded"
    _ok("pre-seeded disk cache short-circuits HTTP on first sync")


def test_disabled_filter_skips_sync_entirely(tmp_path):
    """sync() on a disabled filter must not call HTTP or write disk."""
    def must_not_fetch():
        raise AssertionError("disabled filter must not fetch")
    nf = NewsFilter(
        enabled=False,
        before_min=45, after_min=15,
        source_url="https://example.invalid/ff.json",
        http_timeout=1, logger=None, cache_dir=tmp_path,
    )
    nf._fetch_blocking = must_not_fetch  # type: ignore[method-assign]
    assert asyncio.run(nf.sync()) == 0
    assert not any(tmp_path.iterdir()), "disabled filter must not touch disk"
    _ok("disabled filter: zero side-effects")


# ============================================================
# RUN (standalone runner)
# ============================================================

def main() -> int:
    print("test_news_filter.py")
    test_disabled_filter_never_blocks()
    test_unsynced_fail_open()
    test_blocks_pre_event_within_before_window()
    test_not_blocked_pre_event_outside_window()
    test_blocks_post_event_within_after_window()
    test_not_blocked_post_event_outside_window()
    test_block_window_boundary_inclusive_before()
    test_block_window_boundary_inclusive_after()
    test_medium_impact_does_not_block()
    test_low_impact_does_not_block()
    test_irrelevant_currency_does_not_block_usd_only_asset()
    test_usd_event_blocks_all_usd_quoted_assets()
    test_eur_event_blocks_only_6e()
    test_unknown_symbol_fail_open()
    test_blocking_event_payload_complete()
    test_parse_real_ff_json_shape()
    test_parse_iso8601_with_offset_converted_to_utc()
    test_parse_invalid_payload_raises()
    print("ALL TESTS PASSED")
    return 0


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