#!/usr/bin/env python3
"""
NWALScanner Broadcast Server v070
-----------------------------
Endpoints:
  GET  /proxy/wxdata            — proxies CumulusMX wx data
  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
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
from urllib.parse import urlparse
from datetime import datetime, timezone
from outage_sources import get_local_outages

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

BASE_DIR = os.path.dirname(os.path.abspath(__file__))

# 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.join(BASE_DIR, 'spark_lightning.json')
SPARK_LIGHTNING_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

# V3 Weather.com forecast proxy.
V3_WEATHERCOM_FORECAST_URL = (
    'https://api.weather.com/v3/wx/forecast/daily/5day'
    '?geocode=34.7998%2C-87.6773'
    '&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.
NWS_ALERTS_URL = 'https://api.weather.gov/alerts/active?area=AL&status=actual&message_type=alert,update'
NWS_ALERT_CACHE_S = 20
NWS_LOCAL_COUNTIES = {'lauderdale', 'colbert'}
NWS_LOCAL_UGC = {'ALC077', 'ALC033'}
NWS_LOCAL_SAME = {'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 _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 Spark when healthy, otherwise dormant GLM when usable."""
    now_s = int(time.time())

    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):
        original_new = (
            spark_payload.get('new_strikes', [])
            if isinstance(spark_payload, dict)
            else []
        )
        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': 'primary',
            'fallback_available': True,
            'generated_at': now_s,
            'strike_count': len(spark_strikes),
            'new_strike_count': len(new_strikes),
            'strikes': spark_strikes,
            'new_strikes': new_strikes,
        })
        return package

    glm_payload, glm_path = _first_existing_json(
        GLM_LIGHTNING_FILE_CANDIDATES,
        []
    )
    glm_status, glm_status_path = _first_existing_json(
        GLM_LIGHTNING_STATUS_CANDIDATES,
        {}
    )
    glm_strikes, glm_new_ids = _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': 'Spark bridge unavailable or stale',
            'generated_at': now_s,
            'history_minutes': 15,
            'strike_count': len(glm_strikes),
            'new_strike_count': 0,
            'strikes': glm_strikes,
            'new_strikes': [],
            'source_file': os.path.basename(glm_path) if glm_path else None,
        }

    return {
        'provider': 'none',
        'provider_label': 'Lightning Unavailable',
        'provider_mode': 'offline',
        'generated_at': now_s,
        'history_minutes': 15,
        'strike_count': 0,
        'new_strike_count': 0,
        'strikes': [],
        'new_strikes': [],
        'error': 'Spark is unavailable and no healthy GLM fallback was found',
    }


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': package.get('provider') not in {None, 'none'},
        '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': ['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)

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

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 == '/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('/')

        # 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] == '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'  Calls API     : http://localhost:{PORT}/api/calls', flush=True)
    print(f'  Traffic API   : http://localhost:{PORT}/api/traffic', flush=True)
    print(f'  Outages API   : http://localhost:{PORT}/api/outages  [Florence + Sheffield]', 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'  Lightning API : http://localhost:{PORT}/api/lightning  [Spark primary / GLM fallback]', 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('  Fix: close the other python.exe using port 9000, or run this temporarily with:', 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()
