#!/usr/bin/env python3
"""
NWALScanner Incident Motion Renderer v030
-----------------------------
Endpoints:
  GET  /proxy/wxdata            — proxies CumulusMX wx data
  GET  /api/outages             — Florence + Sheffield local outage totals
  GET  /api/calls               — returns active calls (scanner + auto-traffic)
  POST /api/calls               — adds a new scanner call
  POST /api/calls/<id>/extend   — extends expiresAt on a call
  DELETE /api/calls/<id>        — removes a call
  GET  /api/traffic             — roadwork events for Lauderdale/Colbert (ticker)
"""

import json, os, time, uuid, threading, urllib.request, tempfile, subprocess, shutil, re
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
from urllib.parse import urlparse, unquote
from datetime import datetime, timezone
from html import unescape
from html.parser import HTMLParser
from outage_sources import get_local_outages

PORT        = int(os.environ.get('NWALS_PORT', '9000'))
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
RUNTIME_DIR = os.path.abspath(os.environ.get('NWALS_RUNTIME_DIR', os.path.join(BASE_DIR, 'runtime')))
os.makedirs(RUNTIME_DIR, exist_ok=True)
CALLS_FILE  = os.path.abspath(os.environ.get('NWALS_CALLS_FILE', os.path.join(BASE_DIR, 'calls.json')))
CUMULUS_URL = os.environ.get('NWALS_PEET_URL', 'http://127.0.0.1:5000/api/data/currentdata')
LOCATION_PROFILE_FILE = os.path.abspath(os.environ.get('NWALS_LOCATION_PROFILE', os.path.join(BASE_DIR, 'location_profile.json')))
ALGO_URL    = 'https://api.algotraffic.com/v4.0/TrafficEvents/'
ALGO_COUNTIES = {'lauderdale', 'colbert'}
ALGO_POLL_S = 300   # poll every 5 minutes

INCIDENT_VIDEO_DIR = os.path.join(RUNTIME_DIR, 'rendered_incidents')
os.makedirs(INCIDENT_VIDEO_DIR, exist_ok=True)
INCIDENT_VIDEO_DURATION_MS = 12300
_INCIDENT_RENDER_LOCK = threading.Lock()

# Scheduled sponsor-slide storage is instance-local so multi-client installs do
# not leak sponsor media or schedules across tenants.
SPONSOR_DIR = os.path.join(RUNTIME_DIR, 'sponsors')
os.makedirs(SPONSOR_DIR, exist_ok=True)
SPONSOR_FILE = os.path.join(RUNTIME_DIR, 'sponsors.json')
SPONSOR_MAX_BYTES = 12 * 1024 * 1024

# Broadcast Takeover Orchestrator (v082)
# Persistent state lives in the instance runtime folder so MASTER upgrades do
# not reset an operator's armed/manual configuration.
TAKEOVER_FILE = os.path.join(RUNTIME_DIR, 'broadcast_takeover.json')
_TAKEOVER_LOCK = threading.Lock()
_TAKEOVER_DEFAULTS = {
    'armed': False,
    'manual_takeover': False,
    'enabled': True,
    'lightning_enabled': True,
    'warnings_enabled': True,
    'nearby_radius_miles': 20,
    'immediate_radius_miles': 10,
    'nearby_min_strikes': 3,
    'lookback_minutes': 10,
    'return_delay_minutes': 8,
    'warning_types': ['Tornado Warning', 'Severe Thunderstorm Warning', 'Flash Flood Warning'],
    'last_auto_trigger_at': 0,
    'last_reason': '',
}

def _takeover_load():
    data = {}
    try:
        with open(TAKEOVER_FILE, 'r', encoding='utf-8') as fh:
            loaded = json.load(fh)
            if isinstance(loaded, dict):
                data = loaded
    except FileNotFoundError:
        pass
    except Exception as exc:
        print(f'[TAKEOVER] State read failed: {exc}', flush=True)
    out = dict(_TAKEOVER_DEFAULTS)
    out.update(data)
    return out

def _takeover_save(data):
    merged = dict(_TAKEOVER_DEFAULTS)
    merged.update(data if isinstance(data, dict) else {})
    tmp = TAKEOVER_FILE + '.tmp'
    with _TAKEOVER_LOCK:
        with open(tmp, 'w', encoding='utf-8') as fh:
            json.dump(merged, fh, indent=2)
        os.replace(tmp, TAKEOVER_FILE)
    return merged

def _takeover_miles(lat1, lon1, lat2, lon2):
    import math
    p1, p2 = math.radians(lat1), math.radians(lat2)
    dphi = math.radians(lat2-lat1)
    dlambda = math.radians(lon2-lon1)
    a = math.sin(dphi/2)**2 + math.cos(p1)*math.cos(p2)*math.sin(dlambda/2)**2
    return 3958.7613 * 2 * math.atan2(math.sqrt(a), math.sqrt(max(0.0,1-a)))

def broadcast_takeover_status():
    state = _takeover_load()
    now = time.time()
    reasons = []
    nearest = None
    count_nearby = 0
    lightning_pkg = load_lightning_package()
    lightning_healthy = bool(lightning_pkg.get('feed_healthy', lightning_pkg.get('provider') not in {None, 'none'}))
    lightning_ref = _profile_dict('lightning', 'reference')
    ref_lat = float(lightning_ref.get('latitude', _forecast_lat))
    ref_lon = float(lightning_ref.get('longitude', _forecast_lon))
    lookback_s = max(60, int(float(state.get('lookback_minutes',10))*60))
    near_r = float(state.get('nearby_radius_miles',20))
    immediate_r = float(state.get('immediate_radius_miles',10))
    for strike in (lightning_pkg.get('strikes') or []):
        try:
            ts = float(strike.get('timestamp_ms', strike.get('t', strike.get('timestamp', 0))))
            if ts > 1e12: ts /= 1000.0
            if not ts or now-ts > lookback_s: continue
            miles = _takeover_miles(ref_lat, ref_lon, float(strike['lat']), float(strike['lon']))
            if nearest is None or miles < nearest: nearest = miles
            if miles <= near_r: count_nearby += 1
        except Exception:
            continue

    warning_events=[]
    try:
        alerts = load_local_alerts()
        wanted={str(v).lower() for v in (state.get('warning_types') or [])}
        for f in alerts.get('features') or []:
            event=str((f.get('properties') or {}).get('event') or '')
            if event.lower() in wanted:
                warning_events.append(event)
    except Exception as exc:
        print(f'[TAKEOVER] Alert evaluation failed: {exc}', flush=True)

    auto_trigger=False
    if bool(state.get('armed')) and bool(state.get('enabled')):
        if state.get('warnings_enabled') and warning_events:
            auto_trigger=True
            reasons.append('Local ' + warning_events[0])
        if state.get('lightning_enabled') and lightning_healthy:
            if nearest is not None and nearest <= immediate_r:
                auto_trigger=True
                reasons.append(f'Lightning {nearest:.1f} mi from home')
            elif count_nearby >= int(state.get('nearby_min_strikes',3)):
                auto_trigger=True
                reasons.append(f'{count_nearby} strikes within {near_r:g} mi')

    if auto_trigger:
        state['last_auto_trigger_at']=now
        state['last_reason']=' · '.join(reasons)
        _takeover_save(state)
    hold_s=max(0,float(state.get('return_delay_minutes',8))*60)
    normal_hold = bool(state.get('last_auto_trigger_at',0) and now-float(state.get('last_auto_trigger_at',0)) < hold_s)
    feed_fail_hold = bool(
        state.get('armed') and state.get('enabled') and state.get('lightning_enabled')
        and not lightning_healthy and state.get('last_auto_trigger_at',0)
        and 'lightning' in str(state.get('last_reason','')).lower()
    )
    auto_hold=bool(state.get('armed')) and bool(state.get('enabled')) and (auto_trigger or normal_hold or feed_fail_hold)
    active=bool(state.get('manual_takeover')) or auto_hold
    if state.get('manual_takeover'):
        reason='Manual weather takeover'
    elif reasons:
        reason=' · '.join(reasons)
    elif feed_fail_hold:
        reason='Lightning data unavailable · safety hold active'
    elif auto_hold:
        reason=state.get('last_reason') or 'Weather takeover hold'
    else:
        reason='Normal playlist'
    activity='quiet'
    if warning_events or (nearest is not None and nearest <= immediate_r): activity='high'
    elif count_nearby: activity='active'
    return {
        'ok': True, 'active': active, 'reason': reason, 'activity': activity,
        'state': state, 'nearest_lightning_miles': round(nearest,1) if nearest is not None else None,
        'nearby_strikes': count_nearby, 'warning_events': warning_events,
        'lightning_provider': lightning_pkg.get('provider_label', lightning_pkg.get('provider','none')),
        'lightning_healthy': lightning_healthy,
        'lightning_error': lightning_pkg.get('error'),
        'server_time': datetime.now(timezone.utc).isoformat().replace('+00:00','Z')
    }
SPONSOR_ALLOWED_MIME = {
    'image/png': '.png',
    'image/jpeg': '.jpg',
    'image/webp': '.webp',
}

# Lightning provider order for v069:
#   1. WeatherBug Spark bridge v002
#   2. Dormant GOES GLM fallback, retained for whenever that upstream feed returns
#
# Baron is intentionally retired and is not read anywhere in this server.
SPARK_LIGHTNING_FILE = os.path.abspath(os.environ.get('NWALS_SPARK_DATA_FILE', os.path.join(BASE_DIR, 'spark_lightning.json')))
SPARK_LIGHTNING_STATUS_FILE = os.path.abspath(os.environ.get('NWALS_SPARK_STATUS_FILE', os.path.join(BASE_DIR, 'spark_lightning_status.json')))

# Support the GLM filenames used during earlier experiments without requiring
# another server edit later. The first usable candidate wins.
GLM_LIGHTNING_FILE_CANDIDATES = [
    os.path.join(BASE_DIR, 'glm_lightning.json'),
    os.path.join(BASE_DIR, 'goes_glm_lightning.json'),
    os.path.join(BASE_DIR, 'goes_lightning.json'),
]
GLM_LIGHTNING_STATUS_CANDIDATES = [
    os.path.join(BASE_DIR, 'glm_lightning_status.json'),
    os.path.join(BASE_DIR, 'goes_glm_lightning_status.json'),
    os.path.join(BASE_DIR, 'goes_lightning_status.json'),
]

LIGHTNING_MAX_AGE_S = 15 * 60
LIGHTNING_PROVIDER_STALE_S = 120

# v089: Hazcams realtime lightning is the primary platform source.  This is
# intentionally independent of the legacy Spark browser bridge so a WeatherBug
# page/API change cannot silently turn lightning monitoring into zero strikes.
HAZCAMS_LIGHTNING_URL = os.environ.get(
    'NWALS_HAZCAMS_LIGHTNING_URL',
    'https://realtime.hazcams.com/api/lightning.json'
)
HAZCAMS_LIGHTNING_REFRESH_S = max(5, int(os.environ.get('NWALS_HAZCAMS_LIGHTNING_REFRESH_S', '10')))
HAZCAMS_LIGHTNING_TIMEOUT_S = max(2, int(os.environ.get('NWALS_HAZCAMS_LIGHTNING_TIMEOUT_S', '6')))
_HAZCAMS_LIGHTNING_LOCK = threading.Lock()
_HAZCAMS_LIGHTNING_CACHE = {
    'last_attempt': 0.0, 'last_success': 0.0, 'strikes': [], 'error': None
}

def _refresh_hazcams_lightning(force=False):
    now = time.time()
    with _HAZCAMS_LIGHTNING_LOCK:
        if not force and now - float(_HAZCAMS_LIGHTNING_CACHE.get('last_attempt', 0)) < HAZCAMS_LIGHTNING_REFRESH_S:
            return dict(_HAZCAMS_LIGHTNING_CACHE)
        _HAZCAMS_LIGHTNING_CACHE['last_attempt'] = now
    try:
        req = urllib.request.Request(
            HAZCAMS_LIGHTNING_URL + ('&' if '?' in HAZCAMS_LIGHTNING_URL else '?') + '_=' + str(int(now * 1000)),
            headers={'Accept': 'application/json', 'User-Agent': 'NWALScanner/0.89 lightning'}
        )
        with urllib.request.urlopen(req, timeout=HAZCAMS_LIGHTNING_TIMEOUT_S) as response:
            raw = json.loads(response.read().decode('utf-8'))
        records = raw if isinstance(raw, list) else (raw.get('strikes', []) if isinstance(raw, dict) else [])
        normalized = []
        for record in records:
            strike = _normalize_strike(record, 'hazcams_realtime')
            if strike:
                normalized.append(strike)
        normalized.sort(key=lambda item: item['t'], reverse=True)
        cutoff_ms = int((now - LIGHTNING_MAX_AGE_S) * 1000)
        normalized = [item for item in normalized if int(item.get('timestamp_ms', 0)) >= cutoff_ms]
        with _HAZCAMS_LIGHTNING_LOCK:
            _HAZCAMS_LIGHTNING_CACHE.update({
                'last_success': now, 'strikes': normalized, 'error': None
            })
            return dict(_HAZCAMS_LIGHTNING_CACHE)
    except Exception as exc:
        with _HAZCAMS_LIGHTNING_LOCK:
            _HAZCAMS_LIGHTNING_CACHE['error'] = str(exc)
            return dict(_HAZCAMS_LIGHTNING_CACHE)

def _hazcams_is_healthy(cache):
    try:
        return bool(cache.get('last_success')) and time.time() - float(cache['last_success']) <= LIGHTNING_PROVIDER_STALE_S
    except Exception:
        return False

def _load_location_profile():
    try:
        with open(LOCATION_PROFILE_FILE, 'r', encoding='utf-8') as handle:
            value = json.load(handle)
            return value if isinstance(value, dict) else {}
    except Exception as exc:
        print(f'[PROFILE] Using built-in Shoals defaults: {exc}', flush=True)
        return {}

LOCATION_PROFILE = _load_location_profile()

def _profile_dict(*keys):
    value = LOCATION_PROFILE
    for key in keys:
        value = value.get(key, {}) if isinstance(value, dict) else {}
    return value if isinstance(value, dict) else {}

def _profile_list(*keys):
    value = LOCATION_PROFILE
    for key in keys:
        value = value.get(key, []) if isinstance(value, dict) else []
    return value if isinstance(value, list) else []

_weather_profile = _profile_dict('weather')
_forecast_lat = float(_weather_profile.get('latitude', 34.7998))
_forecast_lon = float(_weather_profile.get('longitude', -87.6773))

# V3 Weather.com forecast proxy.
V3_WEATHERCOM_FORECAST_URL = (
    'https://api.weather.com/v3/wx/forecast/daily/5day'
    f'?geocode={_forecast_lat}%2C{_forecast_lon}'
    '&format=json&units=e&language=en-US'
    '&apiKey=e1f10a1e78da46f5b10a1e78da96f525'
)
_v3_forecast_cache = None
_v3_forecast_cache_time = 0
_v3_forecast_lock = threading.Lock()
V3_FORECAST_CACHE_S = 15 * 60

# NWS local alerts proxy/cache. Server-side fetching avoids browser CORS or
# transient Weather.gov failures and lets every broadcast product share one feed.
_alerts_profile = _profile_dict('alerts')
NWS_ALERT_STATE = str(_alerts_profile.get('stateCode', 'AL')).upper()
NWS_ALERTS_URL = f'https://api.weather.gov/alerts/active?area={NWS_ALERT_STATE}&status=actual&message_type=alert,update'
NWS_ALERT_CACHE_S = 20
NWS_LOCAL_COUNTIES = {str(v).lower() for v in _alerts_profile.get('areaNames', ['Lauderdale', 'Colbert'])}
NWS_LOCAL_UGC = {str(v).upper() for v in _alerts_profile.get('ugcCodes', ['ALC077', 'ALC033'])}
NWS_LOCAL_SAME = {str(v) for v in _alerts_profile.get('sameCodes', ['001077', '001033'])}
_nws_alert_cache = None
_nws_alert_cache_time = 0
_nws_alert_lock = threading.Lock()

# In-memory roadwork cache for ticker (no disk storage needed)
_roadwork_cache = []
_cache_lock     = threading.Lock()

# ── Helpers ─────────────────────────────────────────────────────────────────


def load_json_file(path, default):
    try:
        with open(path, 'r', encoding='utf-8') as f:
            return json.load(f)
    except Exception:
        return default


def _parse_utc_timestamp(value):
    if value in (None, ''):
        return None
    try:
        return datetime.fromisoformat(str(value).replace('Z', '+00:00')).astimezone(timezone.utc).timestamp()
    except Exception:
        return None

def load_sponsors():
    value = load_json_file(SPONSOR_FILE, [])
    return value if isinstance(value, list) else []

def save_sponsors(items):
    tmp = SPONSOR_FILE + '.tmp'
    with open(tmp, 'w', encoding='utf-8') as handle:
        json.dump(items, handle, indent=2)
    os.replace(tmp, SPONSOR_FILE)

def _sponsor_status(item, now_s=None):
    now_s = time.time() if now_s is None else now_s
    start_s = _parse_utc_timestamp(item.get('startAt'))
    end_s = _parse_utc_timestamp(item.get('endAt'))
    if not item.get('enabled', True):
        return 'disabled'
    if start_s is not None and now_s < start_s:
        return 'upcoming'
    if end_s is not None and now_s >= end_s:
        return 'expired'
    return 'active'

def sponsor_public_view(item, now_s=None):
    view = dict(item)
    view.pop('diskPath', None)
    view['status'] = _sponsor_status(item, now_s)
    filename = os.path.basename(str(item.get('filename') or ''))
    view['url'] = '/sponsor_assets/' + filename if filename else None
    return view

def active_sponsors():
    now_s = time.time()
    items = [sponsor_public_view(item, now_s) for item in load_sponsors() if _sponsor_status(item, now_s) == 'active']
    items.sort(key=lambda item: (_parse_utc_timestamp(item.get('startAt')) or 0, item.get('createdAt') or ''))
    return items


def _first_existing_json(paths, default):
    for candidate in paths:
        if os.path.exists(candidate):
            return load_json_file(candidate, default), candidate
    return default, None


def _timestamp_seconds(value):
    """Normalize seconds, milliseconds, or an ISO timestamp to Unix seconds."""
    if value is None:
        return None
    try:
        number = float(value)
        if number > 1_000_000_000_000:
            number /= 1000.0
        return int(number)
    except Exception:
        pass
    try:
        return int(datetime.fromisoformat(str(value).replace('Z', '+00:00')).timestamp())
    except Exception:
        return None


def _normalize_strike(record, provider):
    if isinstance(record, (list, tuple)) and len(record) >= 3:
        lat, lon, raw_ts = record[0], record[1], record[2]
        strike_id = None
    elif isinstance(record, dict):
        lat = record.get('lat', record.get('la'))
        lon = record.get('lon', record.get('lo'))
        raw_ts = record.get(
            'timestamp',
            record.get('t', record.get('timestamp_ms'))
        )
        strike_id = record.get('id')
    else:
        return None

    try:
        lat = float(lat)
        lon = float(lon)
    except Exception:
        return None

    ts = _timestamp_seconds(raw_ts)
    if ts is None:
        return None

    now_s = int(time.time())
    if ts < now_s - LIGHTNING_MAX_AGE_S or ts > now_s + 300:
        return None

    if not strike_id:
        strike_id = f'{lat:.5f}:{lon:.5f}:{ts}'

    return {
        'id': str(strike_id),
        'lat': lat,
        'lon': lon,
        't': ts,
        'timestamp': ts,
        'timestamp_ms': ts * 1000,
        'age_seconds': max(0, now_s - ts),
        'provider': provider,
    }


def _normalize_provider_payload(payload, provider):
    if isinstance(payload, dict):
        records = payload.get('strikes')
        if not isinstance(records, list):
            records = payload.get('data')
        new_records = payload.get('new_strikes')
        if not isinstance(new_records, list):
            new_records = []
    elif isinstance(payload, list):
        records = payload
        new_records = []
    else:
        records = []
        new_records = []

    normalized = []
    seen = set()
    for record in records:
        strike = _normalize_strike(record, provider)
        if not strike or strike['id'] in seen:
            continue
        seen.add(strike['id'])
        normalized.append(strike)

    new_ids = set()
    for record in new_records:
        strike = _normalize_strike(record, provider)
        if strike:
            new_ids.add(strike['id'])

    normalized.sort(key=lambda item: item['t'], reverse=True)
    return normalized, new_ids


def _spark_is_healthy(status):
    if not isinstance(status, dict):
        return False
    if status.get('healthy') is True:
        age = status.get('capture_age_seconds')
        try:
            return age is None or float(age) <= LIGHTNING_PROVIDER_STALE_S
        except Exception:
            return True
    return str(status.get('status', '')).lower() == 'healthy'


def _glm_is_healthy(status, strikes):
    if not strikes:
        return False
    if not isinstance(status, dict):
        return True

    if status.get('healthy') is False:
        return False
    state = str(status.get('state', status.get('status', ''))).lower()
    if state in {'offline', 'error', 'failed', 'stale'}:
        return False

    age = status.get('capture_age_seconds', status.get('age_seconds'))
    try:
        if age is not None and float(age) > LIGHTNING_PROVIDER_STALE_S:
            return False
    except Exception:
        pass
    return True


def load_lightning_package():
    """Return Hazcams realtime lightning when healthy, then legacy Spark/GLM fallbacks."""
    now_s = int(time.time())

    hazcams = _refresh_hazcams_lightning()
    if _hazcams_is_healthy(hazcams):
        strikes = list(hazcams.get('strikes') or [])
        return {
            'provider': 'hazcams_realtime',
            'provider_label': 'Hazcams Realtime Lightning',
            'provider_mode': 'primary',
            'generated_at': now_s,
            'history_minutes': 15,
            'strike_count': len(strikes),
            'new_strike_count': 0,
            'strikes': strikes,
            'new_strikes': [],
            'feed_healthy': True,
            'last_success_epoch': hazcams.get('last_success'),
        }

    spark_payload = load_json_file(SPARK_LIGHTNING_FILE, {})
    spark_status = load_json_file(SPARK_LIGHTNING_STATUS_FILE, {})
    spark_strikes, spark_new_ids = _normalize_provider_payload(spark_payload, 'weatherbug_spark')
    if _spark_is_healthy(spark_status):
        new_strikes = [strike for strike in spark_strikes if strike['id'] in spark_new_ids]
        package = dict(spark_payload) if isinstance(spark_payload, dict) else {}
        package.update({
            'provider': 'weatherbug_spark', 'provider_label': 'WeatherBug Spark',
            'provider_mode': 'fallback', 'fallback_reason': 'Hazcams realtime feed unavailable or stale',
            'generated_at': now_s, 'strike_count': len(spark_strikes),
            'new_strike_count': len(new_strikes), 'strikes': spark_strikes,
            'new_strikes': new_strikes, 'feed_healthy': True,
        })
        return package

    glm_payload, glm_path = _first_existing_json(GLM_LIGHTNING_FILE_CANDIDATES, [])
    glm_status, _ = _first_existing_json(GLM_LIGHTNING_STATUS_CANDIDATES, {})
    glm_strikes, _ = _normalize_provider_payload(glm_payload, 'goes_glm')
    if _glm_is_healthy(glm_status, glm_strikes):
        return {
            'provider': 'goes_glm', 'provider_label': 'GOES GLM', 'provider_mode': 'fallback',
            'fallback_reason': 'Hazcams and Spark unavailable or stale', 'generated_at': now_s,
            'history_minutes': 15, 'strike_count': len(glm_strikes), 'new_strike_count': 0,
            'strikes': glm_strikes, 'new_strikes': [], 'feed_healthy': True,
            'source_file': os.path.basename(glm_path) if glm_path else None,
        }

    return {
        'provider': 'none', 'provider_label': 'Lightning Data Unavailable',
        'provider_mode': 'offline', 'generated_at': now_s, 'history_minutes': 15,
        'strike_count': 0, 'new_strike_count': 0, 'strikes': [], 'new_strikes': [],
        'feed_healthy': False,
        'error': 'Hazcams realtime, Spark, and GLM lightning sources are unavailable',
        'hazcams_error': hazcams.get('error'),
    }

def load_lightning_status():
    package = load_lightning_package()
    spark_status = load_json_file(SPARK_LIGHTNING_STATUS_FILE, {})
    glm_status, glm_status_path = _first_existing_json(
        GLM_LIGHTNING_STATUS_CANDIDATES,
        {}
    )

    return {
        'provider': package.get('provider'),
        'provider_label': package.get('provider_label'),
        'provider_mode': package.get('provider_mode'),
        'state': (
            'online'
            if package.get('provider') not in {None, 'none'}
            else 'offline'
        ),
        'healthy': bool(package.get('feed_healthy', package.get('provider') not in {None, 'none'})),
        'error': package.get('error'),
        'hazcams_error': package.get('hazcams_error'),
        'strike_count': package.get('strike_count', 0),
        'new_strike_count': package.get('new_strike_count', 0),
        'nearest_strike': package.get('nearest_strike'),
        'radius_counts': package.get('radius_counts'),
        'spark': spark_status,
        'glm': glm_status,
        'glm_status_file': (
            os.path.basename(glm_status_path)
            if glm_status_path
            else None
        ),
        'error': package.get('error'),
    }


def load_calls():
    if not os.path.exists(CALLS_FILE):
        return []
    try:
        with open(CALLS_FILE, 'r') as f:
            calls = json.load(f)
        now = time.time()
        def is_active(c):
            expires_at = c.get('expiresAt')
            if not expires_at:
                return False
            try:
                exp = datetime.fromisoformat(expires_at.replace('Z', '+00:00'))
                return exp.timestamp() > now
            except Exception:
                return False
        active = [c for c in calls if is_active(c)]
        if len(active) < len(calls):
            with open(CALLS_FILE, 'w') as f:
                json.dump(active, f, indent=2)
        return active
    except Exception:
        return []

def save_calls(calls):
    with open(CALLS_FILE, 'w') as f:
        json.dump(calls, f, indent=2)

def algo_severity_to_type(severity, title):
    """Map AlgoTraffic severity/title to a broadcast call type."""
    title_lower = (title or '').lower()
    if 'crash' in title_lower or 'collision' in title_lower:
        return 'WRECK'
    if 'fire' in title_lower:
        return 'VEHICLE FIRE'
    if 'debris' in title_lower or 'hazard' in title_lower:
        return 'ROAD HAZARD'
    if severity == 'Closed':
        return 'ROAD CLOSED'
    return 'TRAFFIC INCIDENT'

def algo_expires_iso(event):
    """Return ISO expiresAt — use event end or 2 hours from now."""
    end = event.get('end')
    if end:
        try:
            exp = datetime.fromisoformat(end.replace('Z', '+00:00'))
            # Cap far-future roadwork at 24h for incidents injected as calls
            now_dt = datetime.now(timezone.utc)
            delta = (exp - now_dt).total_seconds()
            if delta > 86400:
                exp = datetime.fromtimestamp(time.time() + 3600, tz=timezone.utc)
            return exp.strftime('%Y-%m-%dT%H:%M:%SZ')
        except Exception:
            pass
    return datetime.fromtimestamp(time.time() + 7200, tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')


def _alert_is_local(feature):
    if not isinstance(feature, dict):
        return False
    props = feature.get('properties') or {}
    text = ' '.join(str(props.get(key) or '') for key in ('areaDesc', 'headline')).lower()
    if any(county in text for county in NWS_LOCAL_COUNTIES):
        return True
    geocode = props.get('geocode') or {}
    ugc = {str(value).upper() for value in (geocode.get('UGC') or [])}
    same = {str(value) for value in (geocode.get('SAME') or [])}
    return bool(ugc & NWS_LOCAL_UGC or same & NWS_LOCAL_SAME)


def load_local_alerts():
    global _nws_alert_cache, _nws_alert_cache_time
    now = time.time()
    with _nws_alert_lock:
        cached = _nws_alert_cache
        cached_time = _nws_alert_cache_time
    if cached is not None and now - cached_time < NWS_ALERT_CACHE_S:
        return cached

    request = urllib.request.Request(
        NWS_ALERTS_URL,
        headers={
            'Accept': 'application/geo+json',
            'User-Agent': 'NWALScannerWeatherV3/1.0 (operations@nwalscanner.com)'
        }
    )
    try:
        with urllib.request.urlopen(request, timeout=12) as response:
            payload = json.loads(response.read().decode('utf-8'))
        features = [
            feature for feature in (payload.get('features') or [])
            if _alert_is_local(feature)
        ]
        result = {
            'type': 'FeatureCollection',
            'features': features,
            'generated_at': datetime.now(timezone.utc).isoformat(),
            'county_filter': list(_alerts_profile.get('areaNames', ['Lauderdale', 'Colbert'])),
            'cache_seconds': NWS_ALERT_CACHE_S,
            'source': 'api.weather.gov'
        }
        with _nws_alert_lock:
            _nws_alert_cache = result
            _nws_alert_cache_time = time.time()
        return result
    except Exception as exc:
        print(f'[ALERTS] NWS fetch failed: {exc}', flush=True)
        with _nws_alert_lock:
            stale = _nws_alert_cache
        if stale is not None:
            stale_copy = dict(stale)
            stale_copy['stale'] = True
            stale_copy['error'] = str(exc)
            return stale_copy
        return {
            'type': 'FeatureCollection',
            'features': [],
            'generated_at': datetime.now(timezone.utc).isoformat(),
            'stale': True,
            'error': str(exc),
            'source': 'api.weather.gov'
        }

# ── AlgoTraffic Poller ───────────────────────────────────────────────────────

def poll_algotraffic():
    global _roadwork_cache
    while True:
        try:
            req = urllib.request.Request(ALGO_URL, headers={'Accept': 'application/json'})
            with urllib.request.urlopen(req, timeout=10) as r:
                events = json.loads(r.read().decode())

            local_incidents = []
            local_roadwork  = []

            for ev in events:
                if not ev.get('active'):
                    continue
                loc = ev.get('startLocation') or {}
                county = (loc.get('county') or '').lower()
                if county not in ALGO_COUNTIES:
                    continue

                ev_type = ev.get('type', '')
                if ev_type == 'Incident':
                    local_incidents.append(ev)
                elif ev_type in ('Roadwork', 'Facility'):
                    local_roadwork.append(ev)

            # ── Update roadwork ticker cache ──
            roadwork_items = []
            for ev in local_roadwork:
                loc  = ev.get('startLocation') or {}
                city = loc.get('city') or loc.get('county') or ''
                road = loc.get('displayRouteDesignator') or ''
                desc = ev.get('description') or ev.get('title') or ''
                sev  = ev.get('severity', '')
                roadwork_items.append({
                    'id':       ev.get('id'),
                    'county':   loc.get('county', ''),
                    'city':     city,
                    'road':     road,
                    'title':    ev.get('title', ''),
                    'subtitle': ev.get('shortSubTitle') or ev.get('subTitle', ''),
                    'severity': sev,
                    'description': desc,
                    'permLink': ev.get('permLink', ''),
                })
            with _cache_lock:
                _roadwork_cache = roadwork_items

            # ── Inject/update incidents into calls.json ──
            calls = load_calls()
            # Track which algo IDs are already in calls
            existing_algo_ids = {
                c.get('algoId') for c in calls if c.get('algoId')
            }
            active_algo_ids = set()

            for ev in local_incidents:
                algo_id  = str(ev.get('id'))
                active_algo_ids.add(algo_id)
                loc      = ev.get('startLocation') or {}
                county   = loc.get('county', '')
                city     = loc.get('city') or county
                road     = loc.get('displayRouteDesignator') or ''
                cross    = loc.get('displayCrossStreet') or ''
                address  = f"{road} at {cross}".strip(' at') if (road or cross) else ''
                title    = ev.get('title') or ev.get('shortSubTitle') or 'Traffic Incident'
                desc     = ev.get('description') or ''
                severity = ev.get('severity', '')
                sev_map  = {'Closed':'ROAD CLOSED','MajorDelay':'MAJOR DELAY',
                            'ModerateDelay':'MODERATE DELAY','MinorDelay':'MINOR DELAY'}
                sev_label = sev_map.get(severity, severity)

                if algo_id not in existing_algo_ids:
                    # New incident — inject as call
                    call = {
                        'id':          'algo_' + algo_id,
                        'algoId':      algo_id,
                        'source':      'algotraffic',
                        'type':        algo_severity_to_type(severity, title),
                        'title':       f"[TRAFFIC] {title}",
                        'city':        city,
                        'location':    address,
                        'address':     address,
                        'county':      county,
                        'status':      sev_label,
                        'description': desc,
                        'units':       '',
                        'caution':     'PLEASE USE CAUTION IN THIS AREA',
                        'holdSeconds': 30,
                        'ts':          time.time(),
                        'expiresAt':   algo_expires_iso(ev),
                        'received':    time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
                    }
                    calls.insert(0, call)
                    existing_algo_ids.add(algo_id)
                    print(f'[ALGO] New incident: {title} in {county} County')
                else:
                    # Update existing — refresh expiresAt
                    for c in calls:
                        if c.get('algoId') == algo_id:
                            c['expiresAt'] = algo_expires_iso(ev)
                            c['status']    = sev_label
                            c['description'] = desc
                            break

            # Remove algo incidents no longer active
            calls = [
                c for c in calls
                if not c.get('algoId') or c.get('algoId') in active_algo_ids
            ]
            save_calls(calls)

        except Exception as e:
            print(f'[ALGO] Poll error: {e}')

        time.sleep(ALGO_POLL_S)



def _safe_slug(value):
    value = re.sub(r'[^a-zA-Z0-9]+', '-', str(value or 'incident')).strip('-').lower()
    return value[:50] or 'incident'


def incident_render_status():
    playwright_ok = False
    chromium_ok = False
    playwright_error = None
    try:
        from playwright.sync_api import sync_playwright
        playwright_ok = True
        try:
            with sync_playwright() as p:
                browser = p.chromium.launch(headless=True)
                browser.close()
            chromium_ok = True
        except Exception as exc:
            playwright_error = str(exc)
    except Exception as exc:
        playwright_error = str(exc)

    ffmpeg_path = shutil.which('ffmpeg')
    return {
        'ok': bool(playwright_ok and chromium_ok and ffmpeg_path),
        'platform_version': 'v022',
        'incident_motion_version': 'v030',
        'graphic': 'incident_graphic.html',
        'graphic_source': 'incident_graphic_v030.html',
        'port': PORT,
        'playwright_installed': playwright_ok,
        'chromium_installed': chromium_ok,
        'ffmpeg_found': bool(ffmpeg_path),
        'ffmpeg_path': ffmpeg_path,
        'output_directory': INCIDENT_VIDEO_DIR,
        'error': playwright_error,
    }


def render_incident_video(payload):
    """Record the animated incident scene and encode a deterministic 12-second MP4."""
    ffmpeg = shutil.which('ffmpeg')
    if not ffmpeg:
        raise RuntimeError('FFmpeg was not found in PATH. Run ffmpeg -version in PowerShell.')

    try:
        from playwright.sync_api import sync_playwright
    except Exception as exc:
        raise RuntimeError('Playwright is not installed for this Python. Run: py -m pip install playwright') from exc

    from urllib.parse import urlencode
    params = {
        'type': payload.get('type', ''),
        'title': payload.get('title', 'Active Incident'),
        'location': payload.get('location', 'Location unavailable'),
        'city': payload.get('city', 'NW Alabama'),
        'status': payload.get('status', 'Active'),
        'description': payload.get('description', ''),
        'caution': payload.get('caution', ''),
        'reported': payload.get('reported', ''),
        'motion': '1',
        'loop': '1',
        'render': '1',
        'build': 'v030',
    }
    page_url = f'http://127.0.0.1:{PORT}/incident_graphic.html?' + urlencode(params)
    stamp = datetime.now().strftime('%Y%m%d_%H%M%S')
    filename = f"incident_{_safe_slug(payload.get('title'))}_{stamp}.mp4"
    output_path = os.path.join(INCIDENT_VIDEO_DIR, filename)

    # Chromium video recording is not safe to run concurrently in this small server.
    if not _INCIDENT_RENDER_LOCK.acquire(blocking=False):
        raise RuntimeError('Another incident video is already rendering. Wait for it to finish and try again.')

    try:
        with tempfile.TemporaryDirectory(prefix='incident_render_') as tmp:
            webm_path = None
            with sync_playwright() as p:
                try:
                    browser = p.chromium.launch(
                        headless=True,
                        args=['--disable-dev-shm-usage']
                    )
                except Exception as exc:
                    raise RuntimeError('Playwright Chromium is missing. Run: py -m playwright install chromium') from exc

                context = browser.new_context(
                    viewport={'width': 1280, 'height': 720},
                    record_video_dir=tmp,
                    record_video_size={'width': 1280, 'height': 720},
                    device_scale_factor=1,
                )
                capture_opened_at = time.monotonic()
                page = context.new_page()
                print("\n>>> INCIDENT MOTION V025 FREEDOM V LIGHTBAR RENDERER ACTIVE <<<", flush=True)
                print(f">>> EXACT GRAPHIC URL: {page_url}", flush=True)
                print(f">>> OUTPUT MP4: {output_path}", flush=True)
                page.goto(page_url, wait_until='domcontentloaded', timeout=30000)
                page.wait_for_function('window.__INCIDENT_READY === true', timeout=30000)
                page.evaluate('window.startIncidentMotion()')
                page.wait_for_function('window.__INCIDENT_STARTED === true', timeout=5000)
                # Playwright starts recording when the page is created, so the
                # raw WebM also contains navigation and web-font settling. Keep
                # that lead-in out of the exported MP4 to prevent the opening
                # text from snapping when Rajdhani replaces the fallback font.
                stable_motion_offset = max(0.0, time.monotonic() - capture_opened_at)

                # Capture the exact Playwright page used for the encoded video.
                # being recorded, after the animation has visibly started.
                page.wait_for_timeout(700)
                proof_filename = f"render_proof_{_safe_slug(payload.get('title'))}_{stamp}.png"
                proof_path = os.path.join(INCIDENT_VIDEO_DIR, proof_filename)
                page.screenshot(path=proof_path, full_page=False)
                print(f">>> PROOF SCREENSHOT: {proof_path}", flush=True)

                page.wait_for_timeout(INCIDENT_VIDEO_DURATION_MS)
                video = page.video
                page.close()
                context.close()
                # path() is available only after the page/context has closed.
                webm_path = video.path()
                browser.close()

            if not webm_path or not os.path.exists(webm_path):
                raise RuntimeError('Chromium did not produce a video file')

            command = [
                ffmpeg, '-y', '-hide_banner', '-loglevel', 'error',
                '-ss', f'{stable_motion_offset:.3f}', '-i', webm_path,
                '-t', '12.0', '-an',
                '-vf', 'fps=30,scale=1280:720:flags=lanczos',
                '-c:v', 'libx264', '-preset', 'medium', '-crf', '19',
                '-pix_fmt', 'yuv420p', '-movflags', '+faststart',
                output_path,
            ]
            proc = subprocess.run(command, capture_output=True, text=True, timeout=180)
            if proc.returncode != 0:
                raise RuntimeError('FFmpeg failed: ' + (proc.stderr[-1200:] or 'unknown error'))

        if not os.path.exists(output_path) or os.path.getsize(output_path) < 10_000:
            raise RuntimeError('Rendered MP4 was missing or unexpectedly small')
        return filename
    finally:
        _INCIDENT_RENDER_LOCK.release()


# ── Request Handler ──────────────────────────────────────────────────────────



class _CrimeRadarIncidentParser(HTMLParser):
    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.capture_script = False
        self.script_chunks = []
        self.capture_tag = None
        self.text_chunks = {"h1": [], "h2": [], "time": []}
        self.all_text = []
        # CrimeRadar's AI summary lives inside:
        #   <aside aria-label="AI Generated Summary"><h2>...</h2></aside>
        # Track that container so unrelated H2/UI text (for example
        # "Share This Information") never becomes part of the summary.
        self.aside_depth = 0
        self.in_ai_summary_aside = False
        self.capture_ai_summary_h2 = False
        self.ai_summary_chunks = []

    def handle_starttag(self, tag, attrs):
        attrs = dict(attrs)
        if tag == "script" and "data-audio-card-payload" in attrs:
            self.capture_script = True

        if tag == "aside":
            if self.in_ai_summary_aside:
                self.aside_depth += 1
            elif str(attrs.get("aria-label", "")).strip().lower() == "ai generated summary":
                self.in_ai_summary_aside = True
                self.aside_depth = 1

        if tag == "h2" and self.in_ai_summary_aside:
            self.capture_ai_summary_h2 = True

        if tag in self.text_chunks and self.capture_tag is None:
            self.capture_tag = tag

    def handle_endtag(self, tag):
        if tag == "script" and self.capture_script:
            self.capture_script = False
        if tag == "h2" and self.capture_ai_summary_h2:
            self.capture_ai_summary_h2 = False
        if tag == "aside" and self.in_ai_summary_aside:
            self.aside_depth -= 1
            if self.aside_depth <= 0:
                self.in_ai_summary_aside = False
                self.aside_depth = 0
        if tag == self.capture_tag:
            self.capture_tag = None

    def handle_data(self, data):
        if self.capture_script:
            self.script_chunks.append(data)
        clean = " ".join(str(data or "").split())
        if clean:
            self.all_text.append(clean)
            if self.capture_ai_summary_h2:
                self.ai_summary_chunks.append(clean)
            if self.capture_tag in self.text_chunks:
                self.text_chunks[self.capture_tag].append(clean)


def _crimeradar_city_from_url(parsed):
    first = (parsed.path.strip('/').split('/') or [''])[0]
    slug = re.sub(r'-al$', '', first, flags=re.I)
    return ' '.join(word.capitalize() for word in slug.split('-') if word)


def _crimeradar_location_from_title(title):
    title = str(title or '').strip()
    match = re.search(r'\bat\s+(.+)$', title, flags=re.I)
    return match.group(1).strip() if match else ''


def fetch_crimeradar_incident(url):
    parsed = urlparse(str(url or '').strip())
    host = (parsed.hostname or '').lower().rstrip('.')
    allowed = {'crimeradar.us', 'www.crimeradar.us', 'uscrimeradar.com', 'www.uscrimeradar.com'}
    if parsed.scheme not in {'http', 'https'} or host not in allowed:
        raise ValueError('Paste a CrimeRadar incident URL')
    # CrimeRadar currently uses two incident-detail URL shapes:
    #   /city-al/c-42328_1788293557_token-incident-slug
    #   /city-al/46916_1788294043_token-incident-slug
    # Accept either, while continuing to reject bare city/dashboard pages.
    path_parts = [part for part in parsed.path.split('/') if part]
    incident_slug = path_parts[1] if len(path_parts) >= 2 else ''
    if not re.match(r'^(?:c-)?\d+_\d+_[A-Za-z0-9]+(?:-.+)?$', incident_slug, flags=re.I):
        raise ValueError('Use a CrimeRadar incident-detail URL, not a city page')

    request = urllib.request.Request(
        url,
        headers={
            'User-Agent': 'Mozilla/5.0 (NWAL Scanner Incident Command)',
            'Accept': 'text/html,application/xhtml+xml',
        },
    )
    with urllib.request.urlopen(request, timeout=10) as response:
        final_url = response.geturl()
        final_host = (urlparse(final_url).hostname or '').lower().rstrip('.')
        if final_host not in allowed:
            raise ValueError('CrimeRadar redirected to an unsupported host')
        html = response.read(2 * 1024 * 1024).decode('utf-8', errors='replace')

    parser = _CrimeRadarIncidentParser()
    parser.feed(html)
    payload = {}
    raw_payload = ''.join(parser.script_chunks).strip()
    if raw_payload:
        try:
            payload = json.loads(unescape(raw_payload))
        except Exception:
            payload = {}

    title = ' '.join(parser.text_chunks['h1']).strip()
    summary = ' '.join(parser.ai_summary_chunks).strip()
    # Defensive cleanup for older/changed CrimeRadar markup. This phrase is UI,
    # never incident-summary content.
    summary = re.sub(r'\s*Share This Information\s*$', '', summary, flags=re.I).strip()
    city = _crimeradar_city_from_url(parsed)
    location = _crimeradar_location_from_title(title)
    visible = ' '.join(parser.all_text)

    # CrimeRadar currently renders a human-readable timestamp in the article.
    timestamp = ''
    timestamp_candidates = parser.text_chunks.get('time') or []
    if timestamp_candidates:
        timestamp = timestamp_candidates[0]
    if not timestamp:
        m = re.search(r'\b\d{1,2}/\d{1,2}/\d{4}\s+\d{1,2}:\d{2}\s*(?:AM|PM)\b', visible, flags=re.I)
        if m: timestamp = m.group(0)

    category = ''
    for candidate in ('Traffic','Fire','Medical','Police','Weather'):
        if re.search(r'\b' + re.escape(candidate) + r'\b', visible, flags=re.I):
            category = candidate
            break

    if not title and not payload:
        raise ValueError('CrimeRadar incident data was not found on that page')

    return {
        'source': 'crimeradar',
        'source_url': final_url,
        'title': title,
        'summary': summary,
        'city': city,
        'location': location,
        'category': category,
        'displayed_time': timestamp,
        'audio': payload.get('audio') or '',
        'audio_duration': payload.get('audio_duration'),
        'audio_duration_string': payload.get('audio_duration_string') or '',
        'transcript': payload.get('transcript') or '',
        'transcript_segments': payload.get('transcript_segments') or [],
    }

class Handler(SimpleHTTPRequestHandler):

    def do_OPTIONS(self):
        self.send_response(200)
        self._cors()
        self.end_headers()

    def do_GET(self):
        parsed = urlparse(self.path)
        path   = parsed.path.rstrip('/')

        if path == '/location_profile.json':
            try:
                with open(LOCATION_PROFILE_FILE, 'rb') as fh:
                    data = fh.read()
                self.send_response(200)
                self.send_header('Content-Type', 'application/json; charset=utf-8')
                self.send_header('Cache-Control', 'no-store')
                self.send_header('Content-Length', str(len(data)))
                self._cors()
                self.end_headers()
                self.wfile.write(data)
            except Exception as exc:
                self._json_response(500, {'error': f'Location profile unavailable: {exc}'})
            return

        if path == '/api/takeover':
            self._json_response(200, broadcast_takeover_status())
            return

        if path == '/api/render/status':
            self._json_response(200, incident_render_status())
            return

        if path == '/api/sponsors':
            now_s = time.time()
            items = [sponsor_public_view(item, now_s) for item in load_sponsors()]
            items.sort(key=lambda item: item.get('createdAt') or '', reverse=True)
            self._json_response(200, {
                'ok': True,
                'serverNow': datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z'),
                'slides': items,
            })
            return

        if path == '/api/sponsors/active':
            self._json_response(200, {
                'ok': True,
                'serverNow': datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z'),
                'slides': active_sponsors(),
            })
            return

        if path.startswith('/sponsor_assets/'):
            filename = os.path.basename(path)
            file_path = os.path.join(SPONSOR_DIR, filename)
            if not os.path.isfile(file_path):
                self._json_response(404, {'error': 'Sponsor image not found'})
                return
            ext = os.path.splitext(filename)[1].lower()
            content_type = {'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp'}.get(ext, 'application/octet-stream')
            try:
                with open(file_path, 'rb') as fh:
                    data = fh.read()
                self.send_response(200)
                self.send_header('Content-Type', content_type)
                self.send_header('Content-Length', str(len(data)))
                self.send_header('Cache-Control', 'no-cache')
                self._cors()
                self.end_headers()
                self.wfile.write(data)
            except Exception as exc:
                self._json_response(500, {'error': str(exc)})
            return

        if path.startswith('/rendered_incidents/'):
            filename = os.path.basename(path)
            file_path = os.path.join(INCIDENT_VIDEO_DIR, filename)
            if not os.path.isfile(file_path):
                self._json_response(404, {'error': 'Video not found'})
                return
            try:
                with open(file_path, 'rb') as fh:
                    data = fh.read()
                self.send_response(200)
                self.send_header('Content-Type', 'video/mp4')
                self.send_header('Content-Length', str(len(data)))
                self.send_header('Content-Disposition', f'attachment; filename="{filename}"')
                self.send_header('Cache-Control', 'no-store')
                self._cors()
                self.end_headers()
                self.wfile.write(data)
            except Exception as exc:
                self._json_response(500, {'error': str(exc)})
            return

        if path == '/proxy/wxdata':
            try:
                with urllib.request.urlopen(CUMULUS_URL, timeout=3) as r:
                    data = r.read()
                self.send_response(200)
                self.send_header('Content-Type', 'application/json')
                self._cors()
                self.end_headers()
                self.wfile.write(data)
            except Exception:
                self.send_response(502)
                self._cors()
                self.end_headers()
                self.wfile.write(b'{}')
            return

        if path == '/api/outages':
            try:
                self._json_response(200, get_local_outages())
            except Exception as exc:
                print(f'[OUTAGES] Aggregator error: {exc}', flush=True)
                self._json_response(502, {
                    'success': False,
                    'error': str(exc),
                    'counties': []
                })
            return

        if path == '/api/alerts':
            self._json_response(200, load_local_alerts())
            return

        if path == '/api/forecast':
            global _v3_forecast_cache, _v3_forecast_cache_time

            now = time.time()
            with _v3_forecast_lock:
                cached = _v3_forecast_cache
                cached_time = _v3_forecast_cache_time

            if cached is not None and now - cached_time < V3_FORECAST_CACHE_S:
                self._json_response(200, cached)
                return

            try:
                request = urllib.request.Request(
                    V3_WEATHERCOM_FORECAST_URL,
                    headers={
                        'Accept': 'application/json',
                        'User-Agent': 'NWALScannerWeatherV3/1.0'
                    }
                )
                with urllib.request.urlopen(request, timeout=10) as response:
                    raw = response.read()
                    data = json.loads(raw.decode('utf-8'))

                daypart = ((data.get('daypart') or [None])[0] or {})
                if not isinstance(daypart.get('daypartName'), list):
                    raise ValueError('Weather.com response did not contain daypart data')

                with _v3_forecast_lock:
                    _v3_forecast_cache = data
                    _v3_forecast_cache_time = time.time()

                self._json_response(200, data)
            except Exception as exc:
                print(f'[FORECAST] Weather.com fetch failed: {exc}', flush=True)

                with _v3_forecast_lock:
                    stale = _v3_forecast_cache

                if stale is not None:
                    self._json_response(200, stale)
                else:
                    self._json_response(
                        502,
                        {'error': f'Weather.com forecast unavailable: {exc}'}
                    )
            return


        if path == '/api/lightning':
            self._json_response(200, load_lightning_package())
            return

        if path == '/api/lightning/status':
            self._json_response(200, load_lightning_status())
            return

        if path == '/api/calls':
            calls = load_calls()
            body  = json.dumps(calls).encode()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.send_header('Cache-Control', 'no-store')
            self._cors()
            self.end_headers()
            self.wfile.write(body)
            return

        if path == '/api/traffic':
            with _cache_lock:
                data = list(_roadwork_cache)
            body = json.dumps(data).encode()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.send_header('Cache-Control', 'no-store')
            self._cors()
            self.end_headers()
            self.wfile.write(body)
            return

        super().do_GET()

    def do_POST(self):
        parsed = urlparse(self.path)
        path   = parsed.path.rstrip('/')
        parts  = path.strip('/').split('/')

        # Broadcast Takeover Orchestrator control endpoint.
        # This must live in do_POST; v082 accidentally placed it in do_GET,
        # so the automation page could read status but its buttons could not
        # change takeover state.
        if path == '/api/takeover':
            length = int(self.headers.get('Content-Length', 0))
            try:
                payload = json.loads(self.rfile.read(length) or b'{}') if length else {}
                state = _takeover_load()
                action = str(payload.get('action') or '').lower()
                if action == 'arm':
                    state['armed'] = True
                elif action == 'disarm':
                    state['armed'] = False
                    state['manual_takeover'] = False
                    state['last_auto_trigger_at'] = 0
                elif action == 'takeover':
                    state['manual_takeover'] = True
                    state['last_reason'] = 'Manual weather takeover'
                elif action == 'return':
                    state['manual_takeover'] = False
                    state['last_auto_trigger_at'] = 0
                    state['last_reason'] = ''
                elif action == 'config':
                    allowed = {'enabled','lightning_enabled','warnings_enabled','nearby_radius_miles','immediate_radius_miles','nearby_min_strikes','lookback_minutes','return_delay_minutes','warning_types'}
                    for key in allowed:
                        if key in payload:
                            state[key] = payload[key]
                elif action:
                    raise ValueError('Unknown takeover action')
                _takeover_save(state)
                self._json_response(200, broadcast_takeover_status())
            except Exception as exc:
                self._json_response(400, {'ok': False, 'error': str(exc)})
            return

        if path == '/api/crimeradar/import':
            length = int(self.headers.get('Content-Length', 0))
            raw = self.rfile.read(length)
            try:
                payload = json.loads(raw or b'{}')
                incident = fetch_crimeradar_incident(payload.get('url'))
                self._json_response(200, {'ok': True, 'incident': incident})
            except Exception as exc:
                print(f'[CRIMERADAR] Import failed: {exc}', flush=True)
                self._json_response(400, {'ok': False, 'error': str(exc)})
            return

        if path == '/api/sponsors':
            length = int(self.headers.get('Content-Length', 0))
            if length <= 0 or length > (SPONSOR_MAX_BYTES * 2):
                self._json_response(413, {'ok': False, 'error': 'Sponsor upload is empty or too large'})
                return
            raw = self.rfile.read(length)
            try:
                payload = json.loads(raw or b'{}')
                mime_type = str(payload.get('mimeType') or '').lower()
                extension = SPONSOR_ALLOWED_MIME.get(mime_type)
                if not extension:
                    raise ValueError('Use a PNG, JPG, or WEBP image')
                encoded = str(payload.get('base64') or '')
                if ',' in encoded and encoded.strip().lower().startswith('data:'):
                    encoded = encoded.split(',', 1)[1]
                import base64
                image_bytes = base64.b64decode(encoded, validate=True)
                if not image_bytes or len(image_bytes) > SPONSOR_MAX_BYTES:
                    raise ValueError('Sponsor image must be 12 MB or smaller')
                start_at = payload.get('startAt') or datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')
                end_at = payload.get('endAt') or None
                start_s = _parse_utc_timestamp(start_at)
                end_s = _parse_utc_timestamp(end_at)
                if start_s is None:
                    raise ValueError('Invalid start date/time')
                if end_at and end_s is None:
                    raise ValueError('Invalid expiration date/time')
                if end_s is not None and end_s <= start_s:
                    raise ValueError('Expiration must be after the start time')
                sponsor_id = str(uuid.uuid4())
                filename = sponsor_id + extension
                file_path = os.path.join(SPONSOR_DIR, filename)
                with open(file_path, 'wb') as fh:
                    fh.write(image_bytes)
                duration_ms = int(payload.get('durationMs') or 10000)
                duration_ms = max(4000, min(30000, duration_ms))
                item = {
                    'id': sponsor_id,
                    'title': str(payload.get('title') or payload.get('originalName') or 'Sponsor Slide')[:120],
                    'originalName': str(payload.get('originalName') or '')[:180],
                    'filename': filename,
                    'mimeType': mime_type,
                    'createdAt': datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z'),
                    'startAt': datetime.fromtimestamp(start_s, timezone.utc).isoformat().replace('+00:00', 'Z'),
                    'endAt': datetime.fromtimestamp(end_s, timezone.utc).isoformat().replace('+00:00', 'Z') if end_s is not None else None,
                    'durationMs': duration_ms,
                    'enabled': True,
                }
                items = load_sponsors()
                items.append(item)
                save_sponsors(items)
                self._json_response(200, {'ok': True, 'slide': sponsor_public_view(item)})
            except Exception as exc:
                self._json_response(400, {'ok': False, 'error': str(exc)})
            return

        if path == '/api/render/incident-video':
            length = int(self.headers.get('Content-Length', 0))
            raw = self.rfile.read(length)
            try:
                payload = json.loads(raw or b'{}')
                filename = render_incident_video(payload)
                self._json_response(200, {
                    'ok': True,
                    'filename': filename,
                    'url': '/rendered_incidents/' + filename,
                    'duration_seconds': 12,
                    'width': 1280,
                    'height': 720,
                    'bytes': os.path.getsize(os.path.join(INCIDENT_VIDEO_DIR, filename)),
                })
            except Exception as exc:
                print(f'[VIDEO] Render failed: {exc}', flush=True)
                self._json_response(500, {'ok': False, 'error': str(exc)})
            return

        # POST /api/calls/<id>/extend
        if len(parts) == 4 and parts[0] == 'api' and parts[1] == 'calls' and parts[3] == 'extend':
            call_id = parts[2]
            length  = int(self.headers.get('Content-Length', 0))
            raw     = self.rfile.read(length)
            try:
                body = json.loads(raw)
            except Exception:
                self._json_response(400, {'error': 'Invalid JSON'})
                return
            calls = load_calls()
            updated = False
            for c in calls:
                if c.get('id') == call_id:
                    c['expiresAt'] = body.get('expiresAt', c.get('expiresAt'))
                    updated = True
                    break
            if updated:
                save_calls(calls)
                self._json_response(200, {'ok': True})
            else:
                self._json_response(404, {'error': 'Call not found'})
            return

        if path == '/api/calls':
            length = int(self.headers.get('Content-Length', 0))
            raw    = self.rfile.read(length)
            try:
                call = json.loads(raw)
            except Exception:
                self._json_response(400, {'error': 'Invalid JSON'})
                return
            if 'id' not in call:
                call['id'] = str(uuid.uuid4())
            if 'ts' not in call:
                call['ts'] = time.time()
            call['received'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
            calls = load_calls()
            calls.insert(0, call)
            save_calls(calls)
            self._json_response(200, {'ok': True, 'id': call['id']})
            return

        self._json_response(404, {'error': 'Not found'})

    def do_DELETE(self):
        parsed = urlparse(self.path)
        parts  = parsed.path.strip('/').split('/')
        if len(parts) == 3 and parts[0] == 'api' and parts[1] == 'sponsors':
            sponsor_id = parts[2]
            items = load_sponsors()
            removed = None
            kept = []
            for item in items:
                if item.get('id') == sponsor_id and removed is None:
                    removed = item
                else:
                    kept.append(item)
            if removed is None:
                self._json_response(404, {'ok': False, 'error': 'Sponsor slide not found'})
                return
            save_sponsors(kept)
            filename = os.path.basename(str(removed.get('filename') or ''))
            if filename:
                try:
                    os.remove(os.path.join(SPONSOR_DIR, filename))
                except FileNotFoundError:
                    pass
                except Exception as exc:
                    print(f'[SPONSOR] Could not remove {filename}: {exc}', flush=True)
            self._json_response(200, {'ok': True, 'removed': sponsor_id})
            return
        if len(parts) == 3 and parts[0] == 'api' and parts[1] == 'calls':
            call_id = parts[2]
            calls   = load_calls()
            before  = len(calls)
            calls   = [c for c in calls if c.get('id') != call_id]
            save_calls(calls)
            self._json_response(200, {'ok': True, 'removed': before - len(calls)})
            return
        self._json_response(404, {'error': 'Not found'})

    def _cors(self):
        self.send_header('Access-Control-Allow-Origin',  '*')
        self.send_header('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS')
        self.send_header('Access-Control-Allow-Headers', 'Content-Type')

    def _json_response(self, code, obj):
        body = json.dumps(obj).encode()
        self.send_response(code)
        self.send_header('Content-Type', 'application/json')
        self._cors()
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, fmt, *args):
        if args and len(args) >= 2 and '/api/' in str(args[0]):
            print(f'[SERVER] {args[0]} → {args[1]}')

# ── Entry Point ──────────────────────────────────────────────────────────────

class NWALThreadingHTTPServer(ThreadingHTTPServer):
    # Helps when restarting quickly after killing the old server.
    allow_reuse_address = True
    daemon_threads = True

def run_server():
    os.chdir(os.path.dirname(os.path.abspath(__file__)))

    # Start AlgoTraffic poller in background.
    t = threading.Thread(target=poll_algotraffic, daemon=True)
    t.start()
    print(f'  [ALGO] AlgoTraffic poller started — polling every {ALGO_POLL_S}s', flush=True)

    print('', flush=True)
    print('  NWALScanner Broadcast Server', flush=True)
    print('  -----------------------------------------', flush=True)
    print(f'  Broadcast map : http://localhost:{PORT}/stormtracker_outagewatch.html', flush=True)
    print(f'  Call entry    : http://localhost:{PORT}/nwals_combined_admin.html', flush=True)
    print(f'  Sponsors      : http://localhost:{PORT}/sponsor_manager.html', flush=True)
    print(f'  Calls API     : http://localhost:{PORT}/api/calls', flush=True)
    print(f'  Traffic API   : http://localhost:{PORT}/api/traffic', flush=True)
    print(f'  Forecast API  : http://localhost:{PORT}/api/forecast', flush=True)
    print(f'  Alerts API    : http://localhost:{PORT}/api/alerts  [20-second NWS cache]', flush=True)
    print(f'  Outages API   : http://localhost:{PORT}/api/outages  [Florence + Sheffield]', flush=True)
    print(f'  Lightning API : http://localhost:{PORT}/api/lightning  [Hazcams primary / Spark + GLM fallback]', flush=True)
    print(f'  Video status  : http://localhost:{PORT}/api/render/status', flush=True)
    print(f'  Incident video: POST http://localhost:{PORT}/api/render/incident-video', flush=True)
    print(f'  ─────────────────────────────────────────', flush=True)
    print(f'  Tailscale: http://100.77.235.11:{PORT}/nwals_combined_admin.html', flush=True)
    print('', flush=True)

    try:
        server = NWALThreadingHTTPServer(('0.0.0.0', PORT), Handler)
    except OSError as e:
        print('  SERVER FAILED TO START', flush=True)
        print(f'  Port {PORT} is already in use or blocked: {e}', flush=True)
        print(f'  Fix: close the other process using port {PORT}, or set NWALS_PORT to another port.', flush=True)
        print('       set NWALS_PORT=9010', flush=True)
        print('       python serve.py', flush=True)
        raise

    print(f'  SERVER LISTENING ON 0.0.0.0:{PORT}', flush=True)
    server.serve_forever()

if __name__ == '__main__':
    run_server()
