#!/usr/bin/env python3
"""
=============================================================================
  AUSTRALIAN EMERGENCY INCIDENTS — PROXY SERVER
=============================================================================
  Proxies external data feeds (incidents + BOM weather) to the browser,
  bypassing CORS restrictions. Serves the static HTML file on the same port.

  USAGE:
    python3 serve.py [port]          # default port 8000
    python3 serve.py 9000            # run on port 9000

  ENDPOINTS:
    GET /                            → vic_emergency_map.html
    GET /vic_emergency_map.html      → main HTML page
    GET /proxy/vic                   → VIC incident feed
    GET /proxy/nsw                   → NSW RFS feed
    GET /proxy/qld                   → QLD QFES feed
    GET /proxy/sa                    → SA CFS feed
    GET /proxy/bom-obs               → BOM weather station observations (cached)
    GET /proxy/bom-refresh           → trigger immediate BOM cache refresh
    GET /proxy/bom-fdr-vic           → BOM VIC fire danger ratings
    GET /proxy/bom-test              → debug: test single BOM station fetch
    GET /health                      → {"status":"ok"} — for uptime monitors

  SECURITY NOTES:
    - Binds to 0.0.0.0 — accessible from the local network
    - For internet exposure, put nginx/caddy in front and restrict to localhost
    - Only serves files from SCRIPT_DIR (no path traversal)
    - All proxy URLs are whitelisted — arbitrary URL proxying is rejected
    - Debug endpoints (/proxy/bom-test etc.) should be removed in production
    - No authentication — intended to sit behind a reverse proxy (nginx/caddy)

  REVERSE PROXY EXAMPLE (nginx):
    location /emergency/ {
        proxy_pass http://127.0.0.1:9000/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

  PERFORMANCE NOTES:
    - BOM stations fetched in parallel threads on startup (pre-loads first 20)
    - Background thread refreshes all stations every 10 minutes
    - Feed responses cached for 60 seconds to avoid hammering upstream
    - ThreadingHTTPServer handles concurrent requests without blocking
=============================================================================
"""
import urllib.request, json, http.server, os, threading, time, sys, gzip, zlib
from datetime import datetime

# ── CONFIGURATION ────────────────────────────────────────────────────────────

SCRIPT_DIR   = os.path.dirname(os.path.abspath(__file__))
DEFAULT_PORT = 8000
# Bind to localhost only — put a reverse proxy in front for external access
BIND_HOST    = '0.0.0.0'   # Listen on all interfaces — accessible from network

# Incident feed URLs — whitelisted, only these are ever proxied
FEEDS = {
    '/proxy/vic':         'https://data.emergency.vic.gov.au/Show?pageId=getIncidentJSON',
    '/proxy/nsw':         'https://www.rfs.nsw.gov.au/feeds/majorIncidents.json',
    '/proxy/qld':         'https://publiccontent-gis-psba-qld-gov-au.s3.amazonaws.com/content/Feeds/BushfireCurrentIncidents/bushfireAlert.json',
    '/proxy/sa':          'https://data.eso.sa.gov.au/prod/cfs/criimson/cfs_current_incidents.json',
    '/proxy/nt':          'https://www.pfes.nt.gov.au/incidentmap/json/incidents.json',
    '/proxy/wa':          'https://api.emergency.wa.gov.au/v1/incidents',
    '/proxy/tas-raw':     'https://alert.tas.gov.au/data/incidents-and-alerts.xml',
    '/proxy/act-raw':     'https://www.esa.act.gov.au/feeds/currentincidents.xml',
    '/proxy/tas-geojson': 'https://alert.tas.gov.au/data/data.geojson',
    '/proxy/bom-fdr-vic': 'https://www.bom.gov.au/fwo/IDV18550/IDV18550.json',
}

# ── BOM STATION LIST ─────────────────────────────────────────────────────────
# Scraped from BOM all-observations pages (vicall.shtml, nswall.shtml etc.)
# Format: (wmo_id, product_code)
# To refresh: scrape /XXX/observations/XXXall.shtml and extract IDX60801.NNNNN links
BOM_STATIONS = [
    # VIC - 101 stations
    (94693,'IDV60801'),
    (94826,'IDV60801'),
    (94827,'IDV60801'),
    (94828,'IDV60801'),
    (94829,'IDV60801'),
    (94830,'IDV60801'),
    (94833,'IDV60801'),
    (94834,'IDV60801'),
    (94835,'IDV60801'),
    (94836,'IDV60801'),
    (94837,'IDV60801'),
    (94838,'IDV60801'),
    (94839,'IDV60801'),
    (94840,'IDV60801'),
    (94842,'IDV60801'),
    (94843,'IDV60801'),
    (94844,'IDV60801'),
    (94846,'IDV60801'),
    (94847,'IDV60801'),
    (94849,'IDV60801'),
    (94852,'IDV60801'),
    (94853,'IDV60801'),
    (94854,'IDV60801'),
    (94856,'IDV60801'),
    (94857,'IDV60801'),
    (94858,'IDV60801'),
    (94859,'IDV60801'),
    (94860,'IDV60801'),
    (94861,'IDV60801'),
    (94862,'IDV60801'),
    (94863,'IDV60801'),
    (94864,'IDV60801'),
    (94865,'IDV60801'),
    (94866,'IDV60801'),
    (94870,'IDV60801'),
    (94871,'IDV60801'),
    (94872,'IDV60801'),
    (94874,'IDV60801'),
    (94875,'IDV60801'),
    (94876,'IDV60801'),
    (94878,'IDV60801'),
    (94881,'IDV60801'),
    (94882,'IDV60801'),
    (94884,'IDV60801'),
    (94886,'IDV60801'),
    (94889,'IDV60801'),
    (94891,'IDV60801'),
    (94892,'IDV60801'),
    (94893,'IDV60801'),
    (94894,'IDV60801'),
    (94898,'IDV60801'),
    (94903,'IDV60801'),
    (94905,'IDV60801'),
    (94906,'IDV60801'),
    (94908,'IDV60801'),
    (94912,'IDV60801'),
    (94913,'IDV60801'),
    (94914,'IDV60801'),
    (94920,'IDV60801'),
    (94930,'IDV60801'),
    (94933,'IDV60801'),
    (94935,'IDV60801'),
    (94949,'IDV60801'),
    (95822,'IDV60801'),
    (95825,'IDV60801'),
    (95826,'IDV60801'),
    (95831,'IDV60801'),
    (95832,'IDV60801'),
    (95833,'IDV60801'),
    (95835,'IDV60801'),
    (95836,'IDV60801'),
    (95837,'IDV60801'),
    (95839,'IDV60801'),
    (95840,'IDV60801'),
    (95843,'IDV60801'),
    (95845,'IDV60801'),
    (95864,'IDV60801'),
    (95866,'IDV60801'),
    (95867,'IDV60801'),
    (95872,'IDV60801'),
    (95874,'IDV60801'),
    (95890,'IDV60801'),
    (95896,'IDV60801'),
    (95901,'IDV60801'),
    (95904,'IDV60801'),
    (95907,'IDV60801'),
    (95913,'IDV60801'),
    (95918,'IDV60801'),
    (95936,'IDV60801'),
    (95941,'IDV60801'),
    (99795,'IDV60801'),
    (99796,'IDV60801'),
    (99806,'IDV60801'),
    (99813,'IDV60801'),
    (99815,'IDV60801'),
    (99820,'IDV60801'),
    (99821,'IDV60801'),
    (99822,'IDV60801'),
    (99826,'IDV60801'),
    (99827,'IDV60801'),
    (99901,'IDV60801'),
    # NSW - 190 stations
    (94158,'IDN60801'),
    (94407,'IDN60801'),
    (94497,'IDN60801'),
    (94498,'IDN60801'),
    (94520,'IDN60801'),
    (94541,'IDN60801'),
    (94553,'IDN60801'),
    (94556,'IDN60801'),
    (94572,'IDN60801'),
    (94573,'IDN60801'),
    (94582,'IDN60801'),
    (94587,'IDN60801'),
    (94588,'IDN60801'),
    (94589,'IDN60801'),
    (94592,'IDN60801'),
    (94596,'IDN60801'),
    (94598,'IDN60801'),
    (94599,'IDN60801'),
    (94650,'IDN60801'),
    (94686,'IDN60801'),
    (94691,'IDN60801'),
    (94692,'IDN60801'),
    (94693,'IDN60801'),
    (94694,'IDN60801'),
    (94702,'IDN60801'),
    (94703,'IDN60801'),
    (94710,'IDN60801'),
    (94711,'IDN60801'),
    (94712,'IDN60801'),
    (94714,'IDN60801'),
    (94715,'IDN60801'),
    (94716,'IDN60801'),
    (94723,'IDN60801'),
    (94725,'IDN60801'),
    (94727,'IDN60801'),
    (94728,'IDN60801'),
    (94729,'IDN60801'),
    (94740,'IDN60801'),
    (94741,'IDN60801'),
    (94743,'IDN60801'),
    (94744,'IDN60801'),
    (94746,'IDN60801'),
    (94749,'IDN60801'),
    (94750,'IDN60801'),
    (94751,'IDN60801'),
    (94752,'IDN60801'),
    (94754,'IDN60801'),
    (94755,'IDN60801'),
    (94757,'IDN60801'),
    (94758,'IDN60801'),
    (94759,'IDN60801'),
    (94760,'IDN60801'),
    (94763,'IDN60801'),
    (94764,'IDN60801'),
    (94765,'IDN60801'),
    (94766,'IDN60801'),
    (94767,'IDN60801'),
    (94768,'IDN60801'),
    (94769,'IDN60801'),
    (94772,'IDN60801'),
    (94773,'IDN60801'),
    (94774,'IDN60801'),
    (94775,'IDN60801'),
    (94776,'IDN60801'),
    (94780,'IDN60801'),
    (94782,'IDN60801'),
    (94783,'IDN60801'),
    (94785,'IDN60801'),
    (94789,'IDN60801'),
    (94792,'IDN60801'),
    (94793,'IDN60801'),
    (94794,'IDN60801'),
    (94796,'IDN60801'),
    (94798,'IDN60801'),
    (94799,'IDN60801'),
    (94843,'IDN60801'),
    (94862,'IDN60801'),
    (94901,'IDN60801'),
    (94910,'IDN60801'),
    (94915,'IDN60801'),
    (94918,'IDN60801'),
    (94919,'IDN60801'),
    (94921,'IDN60801'),
    (94923,'IDN60801'),
    (94925,'IDN60801'),
    (94926,'IDN60801'),
    (94927,'IDN60801'),
    (94928,'IDN60801'),
    (94929,'IDN60801'),
    (94933,'IDN60801'),
    (94934,'IDN60801'),
    (94937,'IDN60801'),
    (94938,'IDN60801'),
    (94939,'IDN60801'),
    (94943,'IDN60801'),
    (94944,'IDN60801'),
    (94995,'IDN60801'),
    (94996,'IDN60801'),
    (95485,'IDN60801'),
    (95512,'IDN60801'),
    (95527,'IDN60801'),
    (95541,'IDN60801'),
    (95570,'IDN60801'),
    (95571,'IDN60801'),
    (95682,'IDN60801'),
    (95684,'IDN60801'),
    (95686,'IDN60801'),
    (95692,'IDN60801'),
    (95695,'IDN60801'),
    (95697,'IDN60801'),
    (95699,'IDN60801'),
    (95704,'IDN60801'),
    (95705,'IDN60801'),
    (95706,'IDN60801'),
    (95708,'IDN60801'),
    (95709,'IDN60801'),
    (95710,'IDN60801'),
    (95715,'IDN60801'),
    (95716,'IDN60801'),
    (95717,'IDN60801'),
    (95718,'IDN60801'),
    (95719,'IDN60801'),
    (95721,'IDN60801'),
    (95722,'IDN60801'),
    (95726,'IDN60801'),
    (95728,'IDN60801'),
    (95729,'IDN60801'),
    (95734,'IDN60801'),
    (95740,'IDN60801'),
    (95745,'IDN60801'),
    (95747,'IDN60801'),
    (95748,'IDN60801'),
    (95749,'IDN60801'),
    (95752,'IDN60801'),
    (95753,'IDN60801'),
    (95754,'IDN60801'),
    (95756,'IDN60801'),
    (95757,'IDN60801'),
    (95758,'IDN60801'),
    (95761,'IDN60801'),
    (95762,'IDN60801'),
    (95765,'IDN60801'),
    (95766,'IDN60801'),
    (95767,'IDN60801'),
    (95768,'IDN60801'),
    (95770,'IDN60801'),
    (95771,'IDN60801'),
    (95773,'IDN60801'),
    (95774,'IDN60801'),
    (95784,'IDN60801'),
    (95869,'IDN60801'),
    (95895,'IDN60801'),
    (95896,'IDN60801'),
    (95908,'IDN60801'),
    (95909,'IDN60801'),
    (95916,'IDN60801'),
    (95925,'IDN60801'),
    (95929,'IDN60801'),
    (95931,'IDN60801'),
    (95935,'IDN60801'),
    (95937,'IDN60801'),
    (95940,'IDN60801'),
    (95995,'IDN60801'),
    (99071,'IDN60801'),
    (99072,'IDN60801'),
    (99129,'IDN60801'),
    (99134,'IDN60801'),
    (99143,'IDN60801'),
    (99145,'IDN60801'),
    (99146,'IDN60801'),
    (99152,'IDN60801'),
    (99468,'IDN60801'),
    (99597,'IDN60801'),
    (99598,'IDN60801'),
    (99738,'IDN60801'),
    (99742,'IDN60801'),
    (99753,'IDN60801'),
    (99762,'IDN60801'),
    (99763,'IDN60801'),
    (99764,'IDN60801'),
    (99765,'IDN60801'),
    (99786,'IDN60801'),
    (99790,'IDN60801'),
    (99791,'IDN60801'),
    (99792,'IDN60801'),
    (99793,'IDN60801'),
    (99825,'IDN60801'),
    (99946,'IDN60801'),
    (99998,'IDN60801'),
    (99999,'IDN60801'),
    # QLD - 149 stations
    (94004,'IDQ60801'),
    (94115,'IDQ60801'),
    (94170,'IDQ60801'),
    (94171,'IDQ60801'),
    (94174,'IDQ60801'),
    (94181,'IDQ60801'),
    (94182,'IDQ60801'),
    (94183,'IDQ60801'),
    (94186,'IDQ60801'),
    (94188,'IDQ60801'),
    (94254,'IDQ60801'),
    (94255,'IDQ60801'),
    (94257,'IDQ60801'),
    (94260,'IDQ60801'),
    (94261,'IDQ60801'),
    (94266,'IDQ60801'),
    (94268,'IDQ60801'),
    (94271,'IDQ60801'),
    (94272,'IDQ60801'),
    (94273,'IDQ60801'),
    (94274,'IDQ60801'),
    (94276,'IDQ60801'),
    (94280,'IDQ60801'),
    (94284,'IDQ60801'),
    (94285,'IDQ60801'),
    (94287,'IDQ60801'),
    (94288,'IDQ60801'),
    (94290,'IDQ60801'),
    (94292,'IDQ60801'),
    (94293,'IDQ60801'),
    (94294,'IDQ60801'),
    (94295,'IDQ60801'),
    (94298,'IDQ60801'),
    (94299,'IDQ60801'),
    (94332,'IDQ60801'),
    (94335,'IDQ60801'),
    (94336,'IDQ60801'),
    (94337,'IDQ60801'),
    (94338,'IDQ60801'),
    (94341,'IDQ60801'),
    (94342,'IDQ60801'),
    (94343,'IDQ60801'),
    (94344,'IDQ60801'),
    (94345,'IDQ60801'),
    (94346,'IDQ60801'),
    (94347,'IDQ60801'),
    (94348,'IDQ60801'),
    (94349,'IDQ60801'),
    (94350,'IDQ60801'),
    (94352,'IDQ60801'),
    (94355,'IDQ60801'),
    (94356,'IDQ60801'),
    (94360,'IDQ60801'),
    (94363,'IDQ60801'),
    (94365,'IDQ60801'),
    (94367,'IDQ60801'),
    (94368,'IDQ60801'),
    (94370,'IDQ60801'),
    (94371,'IDQ60801'),
    (94372,'IDQ60801'),
    (94373,'IDQ60801'),
    (94374,'IDQ60801'),
    (94376,'IDQ60801'),
    (94378,'IDQ60801'),
    (94379,'IDQ60801'),
    (94380,'IDQ60801'),
    (94381,'IDQ60801'),
    (94383,'IDQ60801'),
    (94384,'IDQ60801'),
    (94386,'IDQ60801'),
    (94387,'IDQ60801'),
    (94388,'IDQ60801'),
    (94393,'IDQ60801'),
    (94394,'IDQ60801'),
    (94395,'IDQ60801'),
    (94396,'IDQ60801'),
    (94397,'IDQ60801'),
    (94398,'IDQ60801'),
    (94399,'IDQ60801'),
    (94418,'IDQ60801'),
    (94419,'IDQ60801'),
    (94420,'IDQ60801'),
    (94489,'IDQ60801'),
    (94500,'IDQ60801'),
    (94510,'IDQ60801'),
    (94511,'IDQ60801'),
    (94514,'IDQ60801'),
    (94515,'IDQ60801'),
    (94517,'IDQ60801'),
    (94521,'IDQ60801'),
    (94525,'IDQ60801'),
    (94542,'IDQ60801'),
    (94549,'IDQ60801'),
    (94550,'IDQ60801'),
    (94552,'IDQ60801'),
    (94553,'IDQ60801'),
    (94555,'IDQ60801'),
    (94561,'IDQ60801'),
    (94562,'IDQ60801'),
    (94566,'IDQ60801'),
    (94567,'IDQ60801'),
    (94568,'IDQ60801'),
    (94569,'IDQ60801'),
    (94570,'IDQ60801'),
    (94575,'IDQ60801'),
    (94576,'IDQ60801'),
    (94578,'IDQ60801'),
    (94580,'IDQ60801'),
    (94584,'IDQ60801'),
    (94590,'IDQ60801'),
    (94591,'IDQ60801'),
    (94592,'IDQ60801'),
    (94593,'IDQ60801'),
    (94594,'IDQ60801'),
    (95283,'IDQ60801'),
    (95286,'IDQ60801'),
    (95288,'IDQ60801'),
    (95292,'IDQ60801'),
    (95293,'IDQ60801'),
    (95295,'IDQ60801'),
    (95296,'IDQ60801'),
    (95298,'IDQ60801'),
    (95351,'IDQ60801'),
    (95362,'IDQ60801'),
    (95367,'IDQ60801'),
    (95369,'IDQ60801'),
    (95370,'IDQ60801'),
    (95482,'IDQ60801'),
    (95487,'IDQ60801'),
    (95492,'IDQ60801'),
    (95529,'IDQ60801'),
    (95533,'IDQ60801'),
    (95543,'IDQ60801'),
    (95551,'IDQ60801'),
    (95565,'IDQ60801'),
    (95566,'IDQ60801'),
    (95572,'IDQ60801'),
    (95575,'IDQ60801'),
    (99082,'IDQ60801'),
    (99122,'IDQ60801'),
    (99123,'IDQ60801'),
    (99124,'IDQ60801'),
    (99125,'IDQ60801'),
    (99128,'IDQ60801'),
    (99218,'IDQ60801'),
    (99435,'IDQ60801'),
    (99468,'IDQ60801'),
    (99496,'IDQ60801'),
    (99497,'IDQ60801'),
    # SA - 75 stations
    (94146,'IDS60801'),
    (94474,'IDS60801'),
    (94476,'IDS60801'),
    (94648,'IDS60801'),
    (94651,'IDS60801'),
    (94653,'IDS60801'),
    (94654,'IDS60801'),
    (94655,'IDS60801'),
    (94657,'IDS60801'),
    (94659,'IDS60801'),
    (94661,'IDS60801'),
    (94662,'IDS60801'),
    (94666,'IDS60801'),
    (94668,'IDS60801'),
    (94673,'IDS60801'),
    (94674,'IDS60801'),
    (94676,'IDS60801'),
    (94677,'IDS60801'),
    (94678,'IDS60801'),
    (94680,'IDS60801'),
    (94681,'IDS60801'),
    (94682,'IDS60801'),
    (94683,'IDS60801'),
    (94684,'IDS60801'),
    (94685,'IDS60801'),
    (94690,'IDS60801'),
    (94804,'IDS60801'),
    (94806,'IDS60801'),
    (94807,'IDS60801'),
    (94808,'IDS60801'),
    (94809,'IDS60801'),
    (94811,'IDS60801'),
    (94813,'IDS60801'),
    (94814,'IDS60801'),
    (94816,'IDS60801'),
    (94817,'IDS60801'),
    (94820,'IDS60801'),
    (94821,'IDS60801'),
    (94822,'IDS60801'),
    (95458,'IDS60801'),
    (95480,'IDS60801'),
    (95481,'IDS60801'),
    (95649,'IDS60801'),
    (95652,'IDS60801'),
    (95654,'IDS60801'),
    (95658,'IDS60801'),
    (95659,'IDS60801'),
    (95660,'IDS60801'),
    (95661,'IDS60801'),
    (95662,'IDS60801'),
    (95663,'IDS60801'),
    (95664,'IDS60801'),
    (95666,'IDS60801'),
    (95667,'IDS60801'),
    (95668,'IDS60801'),
    (95670,'IDS60801'),
    (95671,'IDS60801'),
    (95675,'IDS60801'),
    (95676,'IDS60801'),
    (95677,'IDS60801'),
    (95678,'IDS60801'),
    (95679,'IDS60801'),
    (95683,'IDS60801'),
    (95687,'IDS60801'),
    (95805,'IDS60801'),
    (95806,'IDS60801'),
    (95807,'IDS60801'),
    (95811,'IDS60801'),
    (95812,'IDS60801'),
    (95813,'IDS60801'),
    (95815,'IDS60801'),
    (95816,'IDS60801'),
    (95818,'IDS60801'),
    (95823,'IDS60801'),
    (99749,'IDS60801'),
]

# ── SHARED STATE ─────────────────────────────────────────────────────────────

_obs_cache        = {}      # wmo_id -> observation dict (BOM weather stations)
_feed_cache       = {}      # path -> (timestamp, bytes) (incident feed cache)
_cache_lock       = threading.Lock()
_feed_lock        = threading.Lock()
FEED_TTL          = 60      # seconds to cache incident feed responses
_last_bom_refresh = 0       # timestamp of last BOM refresh (rate limiting)
BOM_REFRESH_MIN   = 30      # minimum seconds between manual BOM refreshes

# ── HTTP HELPERS ─────────────────────────────────────────────────────────────

# Standard browser-like headers — required by BOM and some state feeds
_HEADERS = {
    'User-Agent':      'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
    'Accept':          'application/json, text/javascript, */*; q=0.01',
    'Accept-Language': 'en-AU,en;q=0.9',
    'Accept-Encoding': 'gzip, deflate',
    'Referer':         'https://www.bom.gov.au/',
    'Connection':      'keep-alive',
}

def fetch_url(url, timeout=10):
    """
    Fetch a URL with browser-like headers.
    Handles gzip/deflate decompression and HTTP->HTTPS redirects.
    Returns raw bytes (UTF-8 or latin-1 encoded JSON/HTML).
    """
    for _ in range(5):
        req = urllib.request.Request(url, headers=_HEADERS)
        try:
            import ssl
            ctx = ssl.create_default_context()
            with urllib.request.urlopen(req, timeout=timeout, context=ctx) as r:
                raw = r.read()
                enc = r.info().get('Content-Encoding', '')
                # Decompress if server sent compressed response
                if enc == 'gzip' or raw[:2] == b'\x1f\x8b':
                    raw = gzip.decompress(raw)
                elif enc == 'deflate':
                    raw = zlib.decompress(raw)
                return raw
        except urllib.error.HTTPError as e:
            # Follow redirects (urllib doesn't auto-follow http->https)
            if e.code in (301, 302, 303, 307, 308):
                location = e.headers.get('Location', '')
                if location.startswith('/'):
                    location = 'https://www.bom.gov.au' + location
                url = location
            else:
                raise
    raise Exception(f'Too many redirects fetching {url}')

def decode_feed(raw):
    """
    Decode raw feed bytes to UTF-8.
    Falls back to latin-1 for feeds with non-UTF-8 characters (e.g. NSW RFS).
    """
    try:
        return raw.decode('utf-8')
    except UnicodeDecodeError:
        return raw.decode('latin-1')

def fetch_feed_cached(path):
    """
    Fetch an incident feed URL, using a 60-second cache to avoid
    hammering upstream servers on every page reload.
    Returns raw bytes ready to send to the browser.
    """
    now = time.time()
    with _feed_lock:
        cached = _feed_cache.get(path)
        if cached and (now - cached[0]) < FEED_TTL:
            return cached[1]

    # Cache miss — fetch fresh
    raw = fetch_url(FEEDS[path])
    # Normalise to UTF-8
    text = decode_feed(raw)
    out  = text.encode('utf-8')

    with _feed_lock:
        _feed_cache[path] = (now, out)
    return out

# ── BOM STATION FETCHING ─────────────────────────────────────────────────────

def fetch_one(wmo, prod):
    """
    Fetch latest observation for a single BOM station.
    Retries up to 3 times with 2-second back-off.
    Stores result in _obs_cache keyed by WMO id.
    """
    url = f'https://www.bom.gov.au/fwo/{prod}/{prod}.{wmo}.json'
    for attempt in range(3):
        try:
            raw  = fetch_url(url, timeout=12)
            data = json.loads(raw)
            obs  = data.get('observations', {}).get('data', [])
            if obs:
                s = obs[0]
                # Only cache if station has valid coordinates
                if s.get('lat') and s.get('lon'):
                    with _cache_lock:
                        _obs_cache[str(wmo)] = {
                            'id':           str(wmo),
                            'name':         s.get('name', ''),
                            'lat':          s.get('lat'),
                            'lon':          s.get('lon'),
                            'air_temp':     s.get('air_temp'),
                            'apparent_t':   s.get('apparent_t'),
                            'wind_dir':     s.get('wind_dir'),
                            'wind_spd_kt':  s.get('wind_spd_kt'),
                            'wind_spd_kmh': s.get('wind_spd_kmh'),
                            'gust_kt':      s.get('gust_kt'),
                            'gust_kmh':     s.get('gust_kmh'),
                            'rel_hum':      s.get('rel_hum'),
                            'press':        s.get('press_msl'),
                            'local_time':   s.get('local_date_time', ''),
                            'source':       'BOM',
                            '_fetched':     time.time(),  # for cache age tracking
                        }
            return  # success — exit retry loop
        except Exception:
            if attempt < 2:
                time.sleep(2)

# Semaphore caps concurrent BOM fetches to avoid overwhelming the server
_fetch_sem = threading.Semaphore(15)

def _fetch_one_guarded(wmo, prod):
    """fetch_one wrapped in semaphore to limit concurrent requests."""
    with _fetch_sem:
        fetch_one(wmo, prod)

def _run_batch(stations):
    """Fetch a batch of stations in parallel threads, wait for completion."""
    threads = [
        threading.Thread(target=_fetch_one_guarded, args=(wmo, prod), daemon=True)
        for wmo, prod in stations
    ]
    for t in threads: t.start()
    for t in threads: t.join()

def refresh_obs():
    """
    Refresh all BOM station observations.
    Fetches in batches of 10 with 1-second gaps to avoid rate-limiting.
    Also clears the FDR feed cache so it is re-fetched on next request.
    """
    stations = list(BOM_STATIONS)
    batch_size = 10
    for i in range(0, len(stations), batch_size):
        _run_batch(stations[i:i+batch_size])
        if i + batch_size < len(stations):
            time.sleep(1)
    # Clear FDR feed cache so it's re-fetched fresh after weather refresh
    with _feed_lock:
        _feed_cache.pop('/proxy/bom-fdr-vic', None)
    # Prune stale obs (stations that haven't reported in 2+ hours)
    cutoff = time.time() - 7200
    with _cache_lock:
        stale = [k for k, v in _obs_cache.items() if v.get('_fetched', 0) < cutoff]
        for k in stale:
            del _obs_cache[k]
        n = len(_obs_cache)
    if stale:
        _log(f'Pruned {len(stale)} stale stations')
    _log(f'BOM obs refreshed: {n}/{len(BOM_STATIONS)} stations')

def background():
    """
    Background thread: fast first pass to populate cache quickly,
    then full refresh every 10 minutes.
    """
    _log('Quick first pass (batch=20)...')
    stations   = list(BOM_STATIONS)
    batch_size = 20
    for i in range(0, len(stations), batch_size):
        _run_batch(stations[i:i+batch_size])
        with _cache_lock:
            n = len(_obs_cache)
        _log(f'Quick pass: {n} stations so far...')
        time.sleep(0.5)
    with _cache_lock:
        n = len(_obs_cache)
    _log(f'Quick pass complete: {n}/{len(BOM_STATIONS)} stations')

    while True:
        time.sleep(600)
        refresh_obs()

# ── LOGGING ──────────────────────────────────────────────────────────────────

def _log(msg):
    ts = datetime.now().strftime('%H:%M:%S')
    print(f'  [{ts}] {msg}')

# ── REQUEST HANDLER ───────────────────────────────────────────────────────────

class Handler(http.server.SimpleHTTPRequestHandler):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, directory=SCRIPT_DIR, **kwargs)

    def do_GET(self):
        # Strip query string for routing (e.g. ?v=1 cache busters)
        path = self.path.split('?')[0]

        # ── Health check (for uptime monitors / load balancers) ──────────
        if path == '/health':
            self._json({'status': 'ok', 'stations': len(_obs_cache),
                        'uptime': int(time.time() - _start_time)})

        # ── BOM weather observations (cached in memory) ──────────────────
        elif path == '/proxy/bom-obs':
            with _cache_lock:
                stations = list(_obs_cache.values())
            self._json({'observations': {'data': stations}})
            _log(f'Served {len(stations)} BOM stations')

        # ── Trigger BOM cache refresh (rate limited to once per 30s) ────
        elif path == '/proxy/bom-refresh':
            global _last_bom_refresh
            now = time.time()
            with _cache_lock:
                since = now - _last_bom_refresh
                if since < BOM_REFRESH_MIN:
                    self._json({'status': 'rate_limited',
                                'retry_after': int(BOM_REFRESH_MIN - since)})
                    return
                _last_bom_refresh = now
            threading.Thread(target=refresh_obs, daemon=True).start()
            self._json({'status': 'refresh started'})

        # ── Incident feeds (VIC/NSW/QLD/SA/FDR) — whitelisted URLs only ──
        elif path in FEEDS:
            try:
                raw = fetch_feed_cached(path)
                self._raw(raw)
            except Exception as e:
                _log(f'Feed error {path}: {e}')
                self._error(str(e))

        # ── Debug: test single BOM station fetch ─────────────────────────
        # REMOVE THIS IN PRODUCTION
        elif path == '/proxy/sa-debug':
            try:
                raw = fetch_feed_cached('/proxy/sa')
                data = json.loads(raw)
                if isinstance(data, list):
                    first = data[0] if data else {}
                    self._json({'type':'array','length':len(data),'first_keys':list(first.keys()),'first':first})
                else:
                    items = data.get('results') or data.get('incidents') or data.get('features') or []
                    first = items[0] if items else {}
                    self._json({'type':'dict','top_keys':list(data.keys()),'length':len(items),'first_keys':list(first.keys()),'first':first})
            except Exception as e:
                self._json({'error': str(e)})

        elif path == '/proxy/tas':
            # Parse TasALERT GeoJSON feed — https://alert.tas.gov.au/data/data.geojson
            # Properties: id, title, status, type.name, address, location.lat/lng,
            #             number, feedType, changed, callTime
            try:
                raw      = fetch_feed_cached('/proxy/tas-geojson')
                data     = json.loads(raw)
                features = data.get('features', [])
                results  = []
                for f in features:
                    p    = f.get('properties', {})
                    loc  = p.get('location', {})
                    lat  = loc.get('lat')  or loc.get('latitude')
                    lng  = loc.get('lng')  or loc.get('longitude')

                    # If no point in properties, extract from GeometryCollection
                    if lat is None:
                        geom = f.get('geometry', {})
                        def find_point(g):
                            if not g: return None, None
                            if g.get('type') == 'Point':
                                c = g.get('coordinates', [])
                                return (c[1], c[0]) if len(c) >= 2 else (None, None)
                            for sub in g.get('geometries', []):
                                r = find_point(sub)
                                if r[0] is not None: return r
                            return None, None
                        lat, lng = find_point(geom)

                    inc_type   = (p.get('type') or {}).get('name', '') or p.get('title', '')
                    inc_status = p.get('status', '')
                    address    = p.get('address', '')
                    agency     = p.get('agency', {})
                    if isinstance(agency, dict): agency = agency.get('name', 'TFS')
                    if not agency: agency = 'TFS'

                    # Status normalisation
                    st = inc_status.lower()
                    if any(w in st for w in ['going', 'active', 'respond']):
                        status = 'Not Yet Under Control'
                    elif any(w in st for w in ['patrol', 'contained', 'under control']):
                        status = 'Under Control'
                    elif any(w in st for w in ['safe', 'closed', 'out']):
                        status = 'Safe'
                    else:
                        status = inc_status or 'Not Yet Under Control'

                    # Category
                    tl = inc_type.lower()
                    if any(w in tl for w in ['fire', 'burn', 'smoke']): cat = 'Fire'
                    elif 'flood' in tl:  cat = 'Flood'
                    elif 'storm' in tl:  cat = 'Weather'
                    else:                cat = 'Other'

                    # Format ISO date to readable string e.g. "13 Apr 2026 08:24"
                    raw_date = p.get('changed') or p.get('callTime') or ''
                    try:
                        from datetime import datetime, timezone
                        dt = datetime.fromisoformat(raw_date.replace('Z','+00:00'))
                        fmt_date = dt.astimezone().strftime('%-d %b %Y %I:%M %p')
                    except Exception:
                        fmt_date = raw_date[:16].replace('T', ' ')

                    # Clean up address — remove postcode and format nicely
                    addr_parts = [a.strip().title() for a in address.split(',') if a.strip()]
                    # Remove 4-digit postcode from end if present
                    if addr_parts and addr_parts[-1].isdigit() and len(addr_parts[-1]) == 4:
                        addr_parts = addr_parts[:-1]
                    clean_addr = ', '.join(addr_parts) if addr_parts else inc_type

                    results.append({
                        'incidentNo':       p.get('number') or p.get('id'),
                        'name':             clean_addr,
                        'incidentLocation': clean_addr,
                        'incidentType':     inc_type,
                        'incidentStatus':   status,
                        'incidentSizeFmt':  '—',
                        'category1':        cat,
                        'agency':           str(agency)[:30],
                        'fireDistrict':     '',
                        'municipality':     '',
                        'lastUpdatedDtStr': fmt_date,
                        'latitude':         lat,
                        'longitude':        lng,
                        'state':            'TAS',
                    })
                self._json({'results': results})
                _log(f'TAS: {len(results)} incidents served')
            except Exception as e:
                _log(f'TAS parse error: {e}')
                self._error(str(e))

        elif path == '/proxy/tas-geojson-debug':
            try:
                raw  = fetch_feed_cached('/proxy/tas-geojson')
                data = json.loads(raw)
                features = data.get('features', [])
                if not features:
                    self._json({'error': 'no features', 'keys': list(data.keys())})
                    return
                first = features[0]
                self._json({
                    'feature_count': len(features),
                    'first_properties': first.get('properties', {}),
                    'first_geometry_type': first.get('geometry', {}).get('type'),
                    'first_geometry_types': [g.get('type') for g in
                        first.get('geometry', {}).get('geometries', [{}])]
                        if first.get('geometry', {}).get('type') == 'GeometryCollection'
                        else [first.get('geometry', {}).get('type')]
                })
            except Exception as e:
                self._json({'error': str(e)})

        elif path == '/proxy/tas-debug':
            try:
                import xml.etree.ElementTree as ET
                raw  = fetch_feed_cached('/proxy/tas-raw')
                root = ET.fromstring(raw)
                channel = root.find('channel') or root
                items = channel.findall('item')
                if not items:
                    self._json({'error': 'no items found', 'root_tag': root.tag,
                                'children': [c.tag for c in root]})
                    return
                # Show first item in full detail
                first = items[0]
                info = {
                    'item_count': len(items),
                    'first_tags': [c.tag for c in first],
                    'first_text': {c.tag: c.text for c in first},
                    'first_attribs': {c.tag: c.attrib for c in first},
                }
                self._json(info)
            except Exception as e:
                self._json({'error': str(e)})

        elif path == '/proxy/act':
            # Parse ACT ESA GeoRSS feed — georss:point has "lat lon" coordinates
            # Fields in description: Status, Type, Agency, Incident Number, Updated, Time of Call
            try:
                import xml.etree.ElementTree as ET
                raw  = fetch_feed_cached('/proxy/act-raw')
                root = ET.fromstring(raw)
                ns   = {'georss': 'http://www.georss.org/georss'}
                channel  = root.find('channel') or root
                results  = []
                for item in channel.findall('item'):
                    title    = (item.findtext('title')   or '').strip()
                    pub_date = (item.findtext('pubDate') or '').strip()
                    guid     = (item.findtext('guid')    or '').strip()
                    status   = (item.findtext('status')  or '').strip()
                    inc_type = (item.findtext('type')    or '').strip()
                    agency   = (item.findtext('agency')  or 'ESA').strip()
                    desc     = (item.findtext('description') or '').strip()

                    # Coordinates from georss:point "lat lon"
                    lat, lon = None, None
                    pt = item.find('georss:point', ns)
                    if pt is not None and pt.text:
                        parts = pt.text.strip().split()
                        if len(parts) == 2:
                            try: lat, lon = float(parts[0]), float(parts[1])
                            except ValueError: pass

                    # Parse updated time from description "Updated: DD Mon YYYY HH:MM:SS"
                    updated = ''
                    for line in desc.replace('\r\n', '\n').split('\n'):
                        line = line.strip()
                        if line.startswith('Updated:'):
                            updated = line[8:].strip()[:20]
                            break

                    # Normalise status
                    st = status.lower()
                    if any(w in st for w in ['responding', 'assigned', 'en route', 'on scene']):
                        norm_status = 'Not Yet Under Control'
                    elif any(w in st for w in ['pending', 'allocation']):
                        norm_status = 'Not Yet Under Control'
                    elif any(w in st for w in ['under control', 'contained']):
                        norm_status = 'Under Control'
                    elif any(w in st for w in ['safe', 'closed', 'completed']):
                        norm_status = 'Safe'
                    else:
                        norm_status = status or 'Not Yet Under Control'

                    # Category
                    tl = inc_type.lower()
                    if any(w in tl for w in ['fire', 'burn']): cat = 'Fire'
                    elif any(w in tl for w in ['flood', 'water']): cat = 'Flood'
                    elif any(w in tl for w in ['accident', 'crash', 'rescue', 'ambulance', 'medical']): cat = 'Accident / Rescue'
                    elif any(w in tl for w in ['storm', 'wind', 'weather']): cat = 'Weather'
                    else: cat = 'Other'

                    # Location from title e.g. "AMBULANCE RESPONSE - O'CONNOR"
                    loc = title
                    if ' - ' in title:
                        loc = title.split(' - ', 1)[1].strip().title()

                    results.append({
                        'incidentNo':       guid,
                        'name':             loc,
                        'incidentLocation': loc,
                        'incidentType':     inc_type.title(),
                        'incidentStatus':   norm_status,
                        'incidentSizeFmt':  '—',
                        'category1':        cat,
                        'agency':           agency,
                        'fireDistrict':     '',
                        'municipality':     'ACT',
                        'lastUpdatedDtStr': updated,
                        'latitude':         lat,
                        'longitude':        lon,
                        'state':            'ACT',
                    })
                self._json({'results': results})
                _log(f'ACT: {len(results)} incidents served')
            except Exception as e:
                _log(f'ACT parse error: {e}')
                self._error(str(e))

        elif path == '/proxy/bom-test':
            try:
                url  = 'https://www.bom.gov.au/fwo/IDV60801/IDV60801.95936.json'
                raw  = fetch_url(url, timeout=12)
                data = json.loads(raw)
                obs  = data.get('observations', {}).get('data', [])
                result = {'url': url, 'status': 'ok', 'obs_count': len(obs)}
                if obs:
                    result['sample'] = {k: obs[0].get(k)
                                        for k in ['name', 'air_temp', 'wind_dir', 'wind_spd_kmh']}
                self._json(result)
            except Exception as e:
                self._json({'status': 'error', 'error': str(e)})

        # ── Static file serving (HTML, CSS, JS, images) ──────────────────
        else:
            # Prevent path traversal — only serve files within SCRIPT_DIR
            import posixpath
            rel = posixpath.normpath(path.lstrip('/'))
            if rel.startswith('..') or os.path.isabs(rel):
                self.send_error(403, 'Forbidden')
                return
            super().do_GET()

    # ── Response helpers ─────────────────────────────────────────────────────

    def _json(self, obj):
        """Send a JSON response with CORS header."""
        out = json.dumps(obj).encode('utf-8')
        self.send_response(200)
        self.send_header('Content-Type',   'application/json; charset=utf-8')
        self.send_header('Content-Length', str(len(out)))
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Cache-Control',  'no-cache')
        self.end_headers()
        self.wfile.write(out)

    def _raw(self, data):
        """Send pre-encoded bytes as JSON with CORS header."""
        self.send_response(200)
        self.send_header('Content-Type',   'application/json; charset=utf-8')
        self.send_header('Content-Length', str(len(data)))
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Cache-Control',  'no-cache')
        self.end_headers()
        self.wfile.write(data)

    def _error(self, msg):
        """Send a 500 error response."""
        out = json.dumps({'error': msg}).encode('utf-8')
        self.send_response(500)
        self.send_header('Content-Type',   'application/json; charset=utf-8')
        self.send_header('Content-Length', str(len(out)))
        self.send_header('Access-Control-Allow-Origin', '*')
        self.end_headers()
        self.wfile.write(out)

    def log_message(self, fmt, *args):
        """Minimal access log — shows method, path, status and real IP if proxied."""
        # Uncomment to enable per-request logging:
        # real_ip = self.headers.get('X-Forwarded-For', self.client_address[0])
        # _log(f'{real_ip} {fmt % args if args else fmt}')
        pass

# ── STARTUP ───────────────────────────────────────────────────────────────────

# Allow port override via command line: python3 serve.py 9000
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_PORT

_start_time = time.time()

print(f'')
print(f'  Australian Emergency Incidents — Proxy Server')
print(f'  ─────────────────────────────────────────────')
print(f'  URL  : http://{BIND_HOST}:{PORT}/vic_emergency_map.html')
print(f'  Port : {PORT}  (override: python3 serve.py <port>)')
print(f'  Dir  : {SCRIPT_DIR}')
print(f'  BOM  : {len(BOM_STATIONS)} stations to fetch')
print(f'')

# Pre-load first 20 stations synchronously so data is available on first request
_log(f'Pre-loading first 20 BOM stations...')
_run_batch(BOM_STATIONS[:20])
with _cache_lock:
    n = len(_obs_cache)
_log(f'Pre-loaded {n} stations. Starting server on port {PORT}...')
print('')

# Start background thread for remaining stations + periodic refresh
threading.Thread(target=background, daemon=True).start()

print(f'  Press Ctrl+C to stop')
print('')

try:
    http.server.ThreadingHTTPServer((BIND_HOST, PORT), Handler).serve_forever()
except KeyboardInterrupt:
    print('\n  Stopped.')
