/**
 * OpenHamClock Server
 * 
 * Express server that:
 * 1. Serves the static web application
 * 2. Proxies API requests to avoid CORS issues
 * 3. Provides hybrid HF propagation predictions (ITURHFProp + real-time ionosonde)
 * 4. Provides WebSocket support for future real-time features
 * 
 * Configuration:
 * - Copy .env.example to .env and customize
 * - Environment variables override .env file
 * 
 * Usage:
 *   node server.js
 *   PORT=8080 node server.js
 */

const express = require('express');
const cors = require('cors');
const compression = require('compression');
const path = require('path');
const fetch = require('node-fetch');
const net = require('net');
const dgram = require('dgram');
const fs = require('fs');

// Read version from package.json as single source of truth
const APP_VERSION = (() => {
  try {
    const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
    return pkg.version || '0.0.0';
  } catch { return '0.0.0'; }
})();

// Auto-create .env from .env.example on first run
const envPath = path.join(__dirname, '.env');
const envExamplePath = path.join(__dirname, '.env.example');

if (!fs.existsSync(envPath) && fs.existsSync(envExamplePath)) {
  fs.copyFileSync(envExamplePath, envPath);
  console.log('[Config] Created .env from .env.example');
  console.log('[Config] ⚠️  Please edit .env with your callsign and locator, then restart');
}

// Load .env file if it exists
if (fs.existsSync(envPath)) {
  const envContent = fs.readFileSync(envPath, 'utf8');
  envContent.split('\n').forEach(line => {
    const trimmed = line.trim();
    if (trimmed && !trimmed.startsWith('#')) {
      const [key, ...valueParts] = trimmed.split('=');
      const value = valueParts.join('=');
      if (key && value !== undefined && !process.env[key]) {
        process.env[key] = value;
      }
    }
  });
  console.log('[Config] Loaded configuration from .env file');
}

const app = express();
const PORT = process.env.PORT || 3000;
const HOST = process.env.HOST || '0.0.0.0';

// ============================================
// CONFIGURATION FROM ENVIRONMENT
// ============================================

// Convert Maidenhead grid locator to lat/lon
function gridToLatLon(grid) {
  if (!grid || grid.length < 4) return null;
  
  grid = grid.toUpperCase();
  const lon = (grid.charCodeAt(0) - 65) * 20 - 180;
  const lat = (grid.charCodeAt(1) - 65) * 10 - 90;
  const lon2 = parseInt(grid[2]) * 2;
  const lat2 = parseInt(grid[3]);
  
  let longitude = lon + lon2 + 1; // Center of grid
  let latitude = lat + lat2 + 0.5;
  
  // 6-character grid for more precision
  if (grid.length >= 6) {
    const lon3 = (grid.charCodeAt(4) - 65) * (2/24);
    const lat3 = (grid.charCodeAt(5) - 65) * (1/24);
    longitude = lon + lon2 + lon3 + (1/24);
    latitude = lat + lat2 + lat3 + (0.5/24);
  }
  
  return { latitude, longitude };
}

// Get locator from env (support both LOCATOR and GRID_SQUARE)
const locator = process.env.LOCATOR || process.env.GRID_SQUARE || '';

// Also load config.json if it exists (for user preferences)
let jsonConfig = {};
const configJsonPath = path.join(__dirname, 'config.json');
if (fs.existsSync(configJsonPath)) {
  try {
    jsonConfig = JSON.parse(fs.readFileSync(configJsonPath, 'utf8'));
    console.log('[Config] Loaded user preferences from config.json');
  } catch (e) {
    console.error('[Config] Error parsing config.json:', e.message);
  }
}

// Calculate lat/lon from locator if not explicitly set
let stationLat = parseFloat(process.env.LATITUDE);
let stationLon = parseFloat(process.env.LONGITUDE);

if ((!stationLat || !stationLon) && locator) {
  const coords = gridToLatLon(locator);
  if (coords) {
    stationLat = stationLat || coords.latitude;
    stationLon = stationLon || coords.longitude;
  }
}

// Fallback to config.json location if no env
if (!stationLat && jsonConfig.location?.lat) stationLat = jsonConfig.location.lat;
if (!stationLon && jsonConfig.location?.lon) stationLon = jsonConfig.location.lon;

const CONFIG = {
  // Station info (env takes precedence over config.json)
  callsign: process.env.CALLSIGN || jsonConfig.callsign || 'N0CALL',
  gridSquare: locator || jsonConfig.locator || '',
  latitude: stationLat || 40.7128,
  longitude: stationLon || -74.0060,
  
  // Display preferences
  units: process.env.UNITS || jsonConfig.units || 'imperial',
  timeFormat: process.env.TIME_FORMAT || jsonConfig.timeFormat || '12',
  theme: process.env.THEME || jsonConfig.theme || 'dark',
  layout: process.env.LAYOUT || jsonConfig.layout || 'modern',
  
  // DX target
  dxLatitude: parseFloat(process.env.DX_LATITUDE) || jsonConfig.defaultDX?.lat || 51.5074,
  dxLongitude: parseFloat(process.env.DX_LONGITUDE) || jsonConfig.defaultDX?.lon || -0.1278,
  
  // Feature toggles
  showSatellites: process.env.SHOW_SATELLITES !== 'false' && jsonConfig.features?.showSatellites !== false,
  showPota: process.env.SHOW_POTA !== 'false' && jsonConfig.features?.showPOTA !== false,
  showDxPaths: process.env.SHOW_DX_PATHS !== 'false' && jsonConfig.features?.showDXPaths !== false,
  showContests: jsonConfig.features?.showContests !== false,
  showDXpeditions: jsonConfig.features?.showDXpeditions !== false,
  
  // DX Cluster settings
  spotRetentionMinutes: parseInt(process.env.SPOT_RETENTION_MINUTES) || jsonConfig.dxCluster?.spotRetentionMinutes || 30,
  dxClusterSource: process.env.DX_CLUSTER_SOURCE || jsonConfig.dxCluster?.source || 'auto',
  
  // API keys (don't expose to frontend)
  _openWeatherApiKey: process.env.OPENWEATHER_API_KEY || '',
  _qrzUsername: process.env.QRZ_USERNAME || '',
  _qrzPassword: process.env.QRZ_PASSWORD || ''
};

// Check if required config is missing
const configMissing = CONFIG.callsign === 'N0CALL' || !CONFIG.gridSquare;
if (configMissing) {
  console.log('[Config] ⚠️  Station configuration incomplete!');
  console.log('[Config] Copy .env.example to .env OR config.example.json to config.json');
  console.log('[Config] Set your CALLSIGN and LOCATOR/grid square');
  console.log('[Config] Settings popup will appear in browser');
}

// ITURHFProp service URL (optional - enables hybrid mode)
// Must be a full URL like https://iturhfprop.example.com
const ITURHFPROP_URL = process.env.ITURHFPROP_URL && process.env.ITURHFPROP_URL.trim().startsWith('http') 
  ? process.env.ITURHFPROP_URL.trim() 
  : null;

// Log configuration
console.log(`[Config] Station: ${CONFIG.callsign} @ ${CONFIG.gridSquare || 'No grid'}`);
console.log(`[Config] Location: ${CONFIG.latitude.toFixed(4)}, ${CONFIG.longitude.toFixed(4)}`);
console.log(`[Config] Units: ${CONFIG.units}, Time: ${CONFIG.timeFormat}h`);
if (ITURHFPROP_URL) {
  console.log(`[Propagation] Hybrid mode enabled - ITURHFProp service: ${ITURHFPROP_URL}`);
} else {
  console.log('[Propagation] Standalone mode - using built-in calculations');
}

// Middleware
app.use(cors());
app.use(express.json());

// GZIP compression - reduces response sizes by 70-90%
// This is critical for reducing bandwidth/egress costs
app.use(compression({
  level: 6, // Balanced compression level (1-9)
  threshold: 1024, // Only compress responses > 1KB
  filter: (req, res) => {
    // Compress everything except already-compressed formats
    if (req.headers['x-no-compression']) return false;
    return compression.filter(req, res);
  }
}));

// API response caching middleware
// Sets Cache-Control headers based on endpoint to reduce client polling
app.use('/api', (req, res, next) => {
  // Determine cache duration based on endpoint
  let cacheDuration = 30; // Default: 30 seconds
  
  const path = req.path.toLowerCase();
  
  if (path.includes('/satellites/tle')) {
    cacheDuration = 3600; // 1 hour (TLE data is static)
  } else if (path.includes('/contests') || path.includes('/dxpeditions')) {
    cacheDuration = 1800; // 30 minutes (contests/expeditions change slowly)
  } else if (path.includes('/solar-indices') || path.includes('/noaa')) {
    cacheDuration = 300; // 5 minutes (space weather updates every 5 min)
  } else if (path.includes('/propagation')) {
    cacheDuration = 600; // 10 minutes
  } else if (path.includes('/pota') || path.includes('/sota')) {
    cacheDuration = 120; // 2 minutes
  } else if (path.includes('/pskreporter')) {
    cacheDuration = 300; // 5 minutes (PSKReporter rate limits aggressively)
  } else if (path.includes('/dxcluster') || path.includes('/myspots')) {
    cacheDuration = 30; // 30 seconds (DX spots need to be relatively fresh)
  } else if (path.includes('/config')) {
    cacheDuration = 3600; // 1 hour (config rarely changes)
  }
  
  res.setHeader('Cache-Control', `public, max-age=${cacheDuration}`);
  res.setHeader('Vary', 'Accept-Encoding');
  next();
});

// ============================================
// LOGGING SYSTEM
// ============================================
// LOG_LEVEL: 'debug' = verbose, 'info' = normal, 'warn' = warnings+errors, 'error' = errors only
const LOG_LEVEL = (process.env.LOG_LEVEL || 'warn').toLowerCase();
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
const currentLogLevel = LOG_LEVELS[LOG_LEVEL] ?? LOG_LEVELS.warn;

function logDebug(...args) {
  if (currentLogLevel <= LOG_LEVELS.debug) console.log(...args);
}
function logInfo(...args) {
  if (currentLogLevel <= LOG_LEVELS.info) console.log(...args);
}
function logWarn(...args) {
  if (currentLogLevel <= LOG_LEVELS.warn) console.warn(...args);
}

// Rate-limited error logging - prevents log spam when services are down
const errorLogState = {};
const ERROR_LOG_INTERVAL = 5 * 60 * 1000; // Only log same error once per 5 minutes

function logErrorOnce(category, message) {
  const key = `${category}:${message}`;
  const now = Date.now();
  const lastLogged = errorLogState[key] || 0;
  
  if (now - lastLogged >= ERROR_LOG_INTERVAL) {
    errorLogState[key] = now;
    console.error(`[${category}] ${message}`);
    return true;
  }
  return false;
}

// ============================================
// VISITOR TRACKING
// ============================================
// Lightweight in-memory visitor counter — tracks unique IPs per day
// No cookies, no external analytics, no persistent storage
// Resets on server restart; logs daily summary

const visitorStats = {
  today: new Date().toISOString().slice(0, 10),  // YYYY-MM-DD
  uniqueIPs: new Set(),
  totalRequests: 0,
  allTimeVisitors: 0,    // Cumulative unique visitors since server start
  allTimeRequests: 0,    // Cumulative requests since server start
  serverStarted: new Date().toISOString(),
  history: []  // Last 30 days of { date, uniqueVisitors, totalRequests }
};

function rolloverVisitorStats() {
  const now = new Date().toISOString().slice(0, 10);
  if (now !== visitorStats.today) {
    // Save yesterday's stats to history
    visitorStats.history.push({
      date: visitorStats.today,
      uniqueVisitors: visitorStats.uniqueIPs.size,
      totalRequests: visitorStats.totalRequests
    });
    // Keep only last 30 days
    if (visitorStats.history.length > 30) {
      visitorStats.history = visitorStats.history.slice(-30);
    }
    const avg = visitorStats.history.length > 0
      ? Math.round(visitorStats.history.reduce((sum, d) => sum + d.uniqueVisitors, 0) / visitorStats.history.length)
      : 0;
    console.log(`[Visitors] Daily summary for ${visitorStats.today}: ${visitorStats.uniqueIPs.size} unique visitors, ${visitorStats.totalRequests} requests | All-time: ${visitorStats.allTimeVisitors} visitors, ${visitorStats.allTimeRequests} requests | ${visitorStats.history.length}-day avg: ${avg}/day`);
    // Reset daily counters for new day
    visitorStats.today = now;
    visitorStats.uniqueIPs = new Set();
    visitorStats.totalRequests = 0;
  }
}

// Visitor tracking middleware — only counts page loads and API config fetches
// (not every API poll, which would inflate the count)
app.use((req, res, next) => {
  rolloverVisitorStats();
  
  // Only count meaningful "visits" — initial page load or config fetch
  // This avoids counting every 5-second DX cluster poll as a "visit"
  const countableRoutes = ['/', '/index.html', '/api/config'];
  if (countableRoutes.includes(req.path)) {
    const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip || req.connection?.remoteAddress || 'unknown';
    const isNew = !visitorStats.uniqueIPs.has(ip);
    visitorStats.uniqueIPs.add(ip);
    visitorStats.totalRequests++;
    visitorStats.allTimeRequests++;
    
    if (isNew) {
      visitorStats.allTimeVisitors++;
      logInfo(`[Visitors] New visitor today (#${visitorStats.uniqueIPs.size}, #${visitorStats.allTimeVisitors} all-time) from ${ip.replace(/\d+$/, 'x')}`);
    }
  }
  
  next();
});

// Log visitor count every hour
setInterval(() => {
  rolloverVisitorStats();
  if (visitorStats.uniqueIPs.size > 0 || visitorStats.allTimeVisitors > 0) {
    const avg = visitorStats.history.length > 0
      ? Math.round(visitorStats.history.reduce((sum, d) => sum + d.uniqueVisitors, 0) / visitorStats.history.length)
      : visitorStats.uniqueIPs.size;
    console.log(`[Visitors] Today so far: ${visitorStats.uniqueIPs.size} unique, ${visitorStats.totalRequests} requests | All-time: ${visitorStats.allTimeVisitors} visitors | Avg: ${avg}/day`);
  }
}, 60 * 60 * 1000);

// Serve static files
// dist/ contains the built React app (from npm run build)
// public/ contains the fallback page if build hasn't run
const distDir = path.join(__dirname, 'dist');
const publicDir = path.join(__dirname, 'public');

// Check if dist/ exists (has index.html from build)
const distExists = fs.existsSync(path.join(distDir, 'index.html'));

// Static file caching options
const staticOptions = {
  maxAge: '1d', // Cache static files for 1 day
  etag: true,
  lastModified: true
};

// Long-term caching for hashed assets (Vite adds hash to filenames)
const assetOptions = {
  maxAge: '1y', // Cache hashed assets for 1 year
  immutable: true
};

if (distExists) {
  // Serve built React app from dist/
  // Hashed assets (with content hash in filename) can be cached forever
  app.use('/assets', express.static(path.join(distDir, 'assets'), assetOptions));
  app.use(express.static(distDir, staticOptions));
  console.log('[Server] Serving React app from dist/');
} else {
  // No build found - serve placeholder from public/
  console.log('[Server] ⚠️  No build found! Run: npm run build');
}

// Always serve public folder (for fallback and assets)
app.use(express.static(publicDir, staticOptions));

// ============================================
// API PROXY ENDPOINTS
// ============================================

// Centralized cache for NOAA data (5-minute cache)
const noaaCache = {
  flux: { data: null, timestamp: 0 },
  kindex: { data: null, timestamp: 0 },
  sunspots: { data: null, timestamp: 0 },
  xray: { data: null, timestamp: 0 },
  aurora: { data: null, timestamp: 0 },
  solarIndices: { data: null, timestamp: 0 }
};
const NOAA_CACHE_TTL = 5 * 60 * 1000; // 5 minutes

// NOAA Space Weather - Solar Flux
app.get('/api/noaa/flux', async (req, res) => {
  try {
    if (noaaCache.flux.data && (Date.now() - noaaCache.flux.timestamp) < NOAA_CACHE_TTL) {
      return res.json(noaaCache.flux.data);
    }
    const response = await fetch('https://services.swpc.noaa.gov/json/f107_cm_flux.json');
    const data = await response.json();
    noaaCache.flux = { data, timestamp: Date.now() };
    res.json(data);
  } catch (error) {
    logErrorOnce('NOAA Flux', error.message);
    if (noaaCache.flux.data) return res.json(noaaCache.flux.data);
    res.status(500).json({ error: 'Failed to fetch solar flux data' });
  }
});

// NOAA Space Weather - K-Index
app.get('/api/noaa/kindex', async (req, res) => {
  try {
    if (noaaCache.kindex.data && (Date.now() - noaaCache.kindex.timestamp) < NOAA_CACHE_TTL) {
      return res.json(noaaCache.kindex.data);
    }
    const response = await fetch('https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json');
    const data = await response.json();
    noaaCache.kindex = { data, timestamp: Date.now() };
    res.json(data);
  } catch (error) {
    logErrorOnce('NOAA K-Index', error.message);
    if (noaaCache.kindex.data) return res.json(noaaCache.kindex.data);
    res.status(500).json({ error: 'Failed to fetch K-index data' });
  }
});

// NOAA Space Weather - Sunspots
app.get('/api/noaa/sunspots', async (req, res) => {
  try {
    if (noaaCache.sunspots.data && (Date.now() - noaaCache.sunspots.timestamp) < NOAA_CACHE_TTL) {
      return res.json(noaaCache.sunspots.data);
    }
    const response = await fetch('https://services.swpc.noaa.gov/json/solar-cycle/observed-solar-cycle-indices.json');
    const data = await response.json();
    noaaCache.sunspots = { data, timestamp: Date.now() };
    res.json(data);
  } catch (error) {
    logErrorOnce('NOAA Sunspots', error.message);
    if (noaaCache.sunspots.data) return res.json(noaaCache.sunspots.data);
    res.status(500).json({ error: 'Failed to fetch sunspot data' });
  }
});

// Solar Indices with History and Kp Forecast
app.get('/api/solar-indices', async (req, res) => {
  try {
    // Check cache first
    if (noaaCache.solarIndices.data && (Date.now() - noaaCache.solarIndices.timestamp) < NOAA_CACHE_TTL) {
      return res.json(noaaCache.solarIndices.data);
    }
    
    const [fluxRes, kIndexRes, kForecastRes, sunspotRes] = await Promise.allSettled([
      fetch('https://services.swpc.noaa.gov/json/f107_cm_flux.json'),
      fetch('https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json'),
      fetch('https://services.swpc.noaa.gov/products/noaa-planetary-k-index-forecast.json'),
      fetch('https://services.swpc.noaa.gov/json/solar-cycle/observed-solar-cycle-indices.json')
    ]);

    const result = {
      sfi: { current: null, history: [] },
      kp: { current: null, history: [], forecast: [] },
      ssn: { current: null, history: [] },
      timestamp: new Date().toISOString()
    };

    // Process SFI data (last 30 days)
    if (fluxRes.status === 'fulfilled' && fluxRes.value.ok) {
      const data = await fluxRes.value.json();
      if (data?.length) {
        // Get last 30 entries
        const recent = data.slice(-30);
        result.sfi.history = recent.map(d => ({
          date: d.time_tag || d.date,
          value: Math.round(d.flux || d.value || 0)
        }));
        result.sfi.current = result.sfi.history[result.sfi.history.length - 1]?.value || null;
      }
    }

    // Process Kp history (last 3 days, data comes in 3-hour intervals)
    if (kIndexRes.status === 'fulfilled' && kIndexRes.value.ok) {
      const data = await kIndexRes.value.json();
      if (data?.length > 1) {
        // Skip header row, get last 24 entries (3 days)
        const recent = data.slice(1).slice(-24);
        result.kp.history = recent.map(d => ({
          time: d[0],
          value: parseFloat(d[1]) || 0
        }));
        result.kp.current = result.kp.history[result.kp.history.length - 1]?.value || null;
      }
    }

    // Process Kp forecast
    if (kForecastRes.status === 'fulfilled' && kForecastRes.value.ok) {
      const data = await kForecastRes.value.json();
      if (data?.length > 1) {
        // Skip header row
        result.kp.forecast = data.slice(1).map(d => ({
          time: d[0],
          value: parseFloat(d[1]) || 0
        }));
      }
    }

    // Process Sunspot data (last 12 months)
    if (sunspotRes.status === 'fulfilled' && sunspotRes.value.ok) {
      const data = await sunspotRes.value.json();
      if (data?.length) {
        // Get last 12 entries (monthly data)
        const recent = data.slice(-12);
        result.ssn.history = recent.map(d => ({
          date: `${d['time-tag'] || d.time_tag || ''}`,
          value: Math.round(d.ssn || 0)
        }));
        result.ssn.current = result.ssn.history[result.ssn.history.length - 1]?.value || null;
      }
    }

    // Cache the result
    noaaCache.solarIndices = { data: result, timestamp: Date.now() };
    
    res.json(result);
  } catch (error) {
    logErrorOnce('Solar Indices', error.message);
    // Return stale cache on error
    if (noaaCache.solarIndices.data) return res.json(noaaCache.solarIndices.data);
    res.status(500).json({ error: 'Failed to fetch solar indices' });
  }
});

// DXpedition Calendar - fetches from NG3K ADXO plain text version
let dxpeditionCache = { data: null, timestamp: 0, maxAge: 30 * 60 * 1000 }; // 30 min cache

app.get('/api/dxpeditions', async (req, res) => {
  try {
    const now = Date.now();
    logDebug('[DXpeditions] API called');
    
    // Return cached data if fresh
    if (dxpeditionCache.data && (now - dxpeditionCache.timestamp) < dxpeditionCache.maxAge) {
      logDebug('[DXpeditions] Returning cached data:', dxpeditionCache.data.dxpeditions?.length, 'entries');
      return res.json(dxpeditionCache.data);
    }
    
    // Fetch NG3K ADXO plain text version
    logDebug('[DXpeditions] Fetching from NG3K...');
    const response = await fetch('https://www.ng3k.com/Misc/adxoplain.html');
    if (!response.ok) {
      logDebug('[DXpeditions] NG3K fetch failed:', response.status);
      throw new Error('Failed to fetch NG3K: ' + response.status);
    }
    
    let text = await response.text();
    logDebug('[DXpeditions] Received', text.length, 'bytes raw');
    
    // Strip HTML tags and decode entities - the "plain" page is actually HTML!
    text = text
      .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '') // Remove scripts
      .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '') // Remove styles  
      .replace(/<br\s*\/?>/gi, '\n') // Convert br to newlines
      .replace(/<[^>]+>/g, ' ') // Remove all HTML tags
      .replace(/&nbsp;/g, ' ')
      .replace(/&amp;/g, '&')
      .replace(/&lt;/g, '<')
      .replace(/&gt;/g, '>')
      .replace(/&quot;/g, '"')
      .replace(/&#39;/g, "'")
      .replace(/\s+/g, ' ') // Normalize whitespace
      .trim();
    
    logDebug('[DXpeditions] Cleaned text length:', text.length);
    logDebug('[DXpeditions] First 500 chars:', text.substring(0, 500));
    
    const dxpeditions = [];
    
    // Each entry starts with a date pattern like "Jan 1-Feb 16, 2026 DXCC:"
    // Split on date patterns that are followed by DXCC
    const entryPattern = /((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2}[^D]*?DXCC:[^·]+?)(?=(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2}|$)/gi;
    const entries = text.match(entryPattern) || [];
    
    logDebug('[DXpeditions] Found', entries.length, 'potential entries');
    
    // Log first 3 entries for debugging
    entries.slice(0, 3).forEach((e, i) => {
      logDebug(`[DXpeditions] Entry ${i}:`, e.substring(0, 150));
    });
    
    for (const entry of entries) {
      if (!entry.trim()) continue;
      
      // Skip header/footer/legend content
      if (entry.includes('ADXB=') || entry.includes('OPDX=') || entry.includes('425DX=') ||
          entry.includes('Last updated') || entry.includes('Copyright') || 
          entry.includes('Expired Announcements') || entry.includes('Table Version') ||
          entry.includes('About ADXO') || entry.includes('Search ADXO') ||
          entry.includes('GazDX=') || entry.includes('LNDX=') || entry.includes('TDDX=') ||
          entry.includes('DXW.Net=') || entry.includes('DXMB=')) continue;
      
      // Try multiple parsing strategies
      let callsign = null;
      let entity = null;
      let qsl = null;
      let info = null;
      let dateStr = null;
      
      // Strategy 1: "DXCC: xxx Callsign: xxx" format
      const dxccMatch = entry.match(/DXCC:\s*([^C\n]+?)(?=Callsign:|QSL:|Source:|Info:|$)/i);
      const callMatch = entry.match(/Callsign:\s*([A-Z0-9\/]+)/i);
      
      if (callMatch && dxccMatch) {
        callsign = callMatch[1].trim().toUpperCase();
        entity = dxccMatch[1].trim();
      }
      
      // Strategy 2: Look for callsign patterns directly (like "3Y0K" or "VP8/G3ABC")
      if (!callsign) {
        const directCallMatch = entry.match(/\b([A-Z]{1,2}\d[A-Z0-9]*[A-Z](?:\/[A-Z0-9]+)?)\b/);
        if (directCallMatch) {
          callsign = directCallMatch[1];
        }
      }
      
      // Strategy 3: Parse "Entity - Callsign" or similar patterns
      if (!callsign) {
        const altMatch = entry.match(/([A-Za-z\s&]+?)\s*[-–:]\s*([A-Z]{1,2}\d[A-Z0-9]*)/);
        if (altMatch) {
          entity = altMatch[1].trim();
          callsign = altMatch[2].trim();
        }
      }
      
      // Extract other fields
      const qslMatch = entry.match(/QSL:\s*([A-Za-z0-9]+)/i);
      const infoMatch = entry.match(/Info:\s*(.+)/i);
      // Date is at the start of entry: "Jan 1-Feb 16, 2026"
      const dateMatch = entry.match(/^([A-Za-z]{3}\s+\d{1,2}[^D]*?)(?=DXCC:)/i);
      
      qsl = qslMatch ? qslMatch[1].trim() : '';
      info = infoMatch ? infoMatch[1].trim() : '';
      dateStr = dateMatch ? dateMatch[1].trim() : '';
      
      // Skip if we couldn't find a callsign
      if (!callsign || callsign.length < 3) continue;
      
      // Skip obviously wrong matches
      if (/^(DXCC|QSL|INFO|SOURCE|THE|AND|FOR)$/i.test(callsign)) continue;
      
      // Log first few successful parses
      if (dxpeditions.length < 3) {
        logDebug(`[DXpeditions] Parsed: ${callsign} - ${entity} - ${dateStr}`);
      }
      
      // Try to extract entity from context if not found
      if (!entity && info) {
        // Look for "from Entity" or "fm Entity" patterns
        const fromMatch = info.match(/(?:from|fm)\s+([A-Za-z\s]+?)(?:;|,|$)/i);
        if (fromMatch) entity = fromMatch[1].trim();
      }
      
      // Parse dates
      let startDate = null;
      let endDate = null;
      let isActive = false;
      let isUpcoming = false;
      
      if (dateStr) {
        const monthNames = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
        const datePattern = /([A-Za-z]{3})\s+(\d{1,2})(?:,?\s*(\d{4}))?(?:\s*[-–]\s*([A-Za-z]{3})?\s*(\d{1,2})(?:,?\s*(\d{4}))?)?/i;
        const dateParsed = dateStr.match(datePattern);
        
        if (dateParsed) {
          const currentYear = new Date().getFullYear();
          const startMonth = monthNames.indexOf(dateParsed[1].toLowerCase());
          const startDay = parseInt(dateParsed[2]);
          const startYear = dateParsed[3] ? parseInt(dateParsed[3]) : currentYear;
          
          const endMonthStr = dateParsed[4] || dateParsed[1];
          const endMonth = monthNames.indexOf(endMonthStr.toLowerCase());
          const endDay = parseInt(dateParsed[5]) || startDay + 14;
          const endYear = dateParsed[6] ? parseInt(dateParsed[6]) : startYear;
          
          if (startMonth >= 0) {
            startDate = new Date(startYear, startMonth, startDay);
            endDate = new Date(endYear, endMonth >= 0 ? endMonth : startMonth, endDay);
            
            if (endDate < startDate && !dateParsed[6]) {
              endDate.setFullYear(endYear + 1);
            }
            
            const today = new Date();
            today.setHours(0, 0, 0, 0);
            
            isActive = startDate <= today && endDate >= today;
            isUpcoming = startDate > today;
          }
        }
      }
      
      // Extract bands and modes
      const bandsMatch = entry.match(/(\d+(?:-\d+)?m)/g);
      const bands = bandsMatch ? [...new Set(bandsMatch)].join(' ') : '';
      
      const modesMatch = entry.match(/\b(CW|SSB|FT8|FT4|RTTY|PSK|FM|AM|DIGI)\b/gi);
      const modes = modesMatch ? [...new Set(modesMatch.map(m => m.toUpperCase()))].join(' ') : '';
      
      dxpeditions.push({
        callsign,
        entity: entity || 'Unknown',
        dates: dateStr,
        qsl,
        info: (info || '').substring(0, 100),
        bands,
        modes,
        startDate: startDate?.toISOString(),
        endDate: endDate?.toISOString(),
        isActive,
        isUpcoming
      });
    }
    
    // Remove duplicates by callsign
    const seen = new Set();
    const uniqueDxpeditions = dxpeditions.filter(d => {
      if (seen.has(d.callsign)) return false;
      seen.add(d.callsign);
      return true;
    });
    
    // Sort: active first, then upcoming by start date
    uniqueDxpeditions.sort((a, b) => {
      if (a.isActive && !b.isActive) return -1;
      if (!a.isActive && b.isActive) return 1;
      if (a.isUpcoming && !b.isUpcoming) return -1;
      if (!a.isUpcoming && b.isUpcoming) return 1;
      if (a.startDate && b.startDate) return new Date(a.startDate) - new Date(b.startDate);
      return 0;
    });
    
    logDebug('[DXpeditions] Parsed', uniqueDxpeditions.length, 'unique entries');
    if (uniqueDxpeditions.length > 0) {
      logDebug('[DXpeditions] First entry:', JSON.stringify(uniqueDxpeditions[0]));
    }
    
    const result = {
      dxpeditions: uniqueDxpeditions.slice(0, 50),
      active: uniqueDxpeditions.filter(d => d.isActive).length,
      upcoming: uniqueDxpeditions.filter(d => d.isUpcoming).length,
      source: 'NG3K ADXO',
      timestamp: new Date().toISOString()
    };
    
    logDebug('[DXpeditions] Result:', result.active, 'active,', result.upcoming, 'upcoming');
    
    dxpeditionCache.data = result;
    dxpeditionCache.timestamp = now;
    
    res.json(result);
  } catch (error) {
    logErrorOnce('DXpeditions', error.message);
    
    if (dxpeditionCache.data) {
      logDebug('[DXpeditions] Returning stale cache');
      return res.json({ ...dxpeditionCache.data, stale: true });
    }
    
    res.status(500).json({ error: 'Failed to fetch DXpedition data' });
  }
});

// NOAA Space Weather - X-Ray Flux
app.get('/api/noaa/xray', async (req, res) => {
  try {
    if (noaaCache.xray.data && (Date.now() - noaaCache.xray.timestamp) < NOAA_CACHE_TTL) {
      return res.json(noaaCache.xray.data);
    }
    const response = await fetch('https://services.swpc.noaa.gov/json/goes/primary/xrays-6-hour.json');
    const data = await response.json();
    noaaCache.xray = { data, timestamp: Date.now() };
    res.json(data);
  } catch (error) {
    logErrorOnce('NOAA X-Ray', error.message);
    if (noaaCache.xray.data) return res.json(noaaCache.xray.data);
    res.status(500).json({ error: 'Failed to fetch X-ray data' });
  }
});

// NOAA OVATION Aurora Forecast
const AURORA_CACHE_TTL = 10 * 60 * 1000; // 10 minutes (NOAA updates every ~30 min)
app.get('/api/noaa/aurora', async (req, res) => {
  try {
    if (noaaCache.aurora.data && (Date.now() - noaaCache.aurora.timestamp) < AURORA_CACHE_TTL) {
      return res.json(noaaCache.aurora.data);
    }
    const response = await fetch('https://services.swpc.noaa.gov/json/ovation_aurora_latest.json');
    const data = await response.json();
    noaaCache.aurora = { data, timestamp: Date.now() };
    res.json(data);
  } catch (error) {
    logErrorOnce('NOAA Aurora', error.message);
    if (noaaCache.aurora.data) return res.json(noaaCache.aurora.data);
    res.status(500).json({ error: 'Failed to fetch aurora data' });
  }
});

// DX News from dxnews.com
let dxNewsCache = { data: null, timestamp: 0 };
const DXNEWS_CACHE_TTL = 30 * 60 * 1000; // 30 minutes

app.get('/api/dxnews', async (req, res) => {
  try {
    if (dxNewsCache.data && (Date.now() - dxNewsCache.timestamp) < DXNEWS_CACHE_TTL) {
      return res.json(dxNewsCache.data);
    }

    const response = await fetch('https://dxnews.com/', {
      headers: { 'User-Agent': 'OpenHamClock/1.0 (amateur radio dashboard)' }
    });
    const html = await response.text();

    // Parse news items from HTML
    const items = [];
    // Match pattern: <h3><a href="URL" title="TITLE">TITLE</a></h3> followed by date and description
    const articleRegex = /<h3[^>]*>\s*<a\s+href="([^"]+)"\s+title="([^"]+)"[^>]*>[^<]*<\/a>\s*<\/h3>\s*[\s\S]*?(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s*[\s\S]*?<\/li>\s*<\/ul>\s*([\s\S]*?)(?:<ul|<div\s+class="more"|<\/div>)/g;

    // Simpler approach: split by article blocks
    const blocks = html.split(/<h3[^>]*>\s*<a\s+href="/);
    for (let i = 1; i < blocks.length && items.length < 20; i++) {
      try {
        const block = blocks[i];
        // Extract URL
        const urlMatch = block.match(/^([^"]+)"/);
        // Extract title
        const titleMatch = block.match(/title="([^"]+)"/);
        // Extract date
        const dateMatch = block.match(/(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/);
        // Extract description - text after the date, before stats
        const descParts = block.split(/\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}/);
        let desc = '';
        if (descParts[1]) {
          // Get text content, strip HTML tags, then remove stats/junk
          desc = descParts[1]
            .replace(/<[^>]+>/g, ' ')
            .replace(/\s+/g, ' ')
            .replace(/Views\s*\d+.*/i, '')
            .replace(/Comments\s*\d+.*/i, '')
            .replace(/\d+%/, '')
            .replace(/More\.\.\..*/i, '')
            .trim()
            .substring(0, 200);
        }

        if (titleMatch && urlMatch) {
          items.push({
            title: titleMatch[1],
            url: 'https://dxnews.com/' + urlMatch[1],
            date: dateMatch ? dateMatch[1] : null,
            description: desc || titleMatch[1]
          });
        }
      } catch (e) {
        // Skip malformed entries
      }
    }

    const result = { items, fetched: new Date().toISOString() };
    dxNewsCache = { data: result, timestamp: Date.now() };
    res.json(result);
  } catch (error) {
    logErrorOnce('DX News', error.message);
    if (dxNewsCache.data) return res.json(dxNewsCache.data);
    res.status(500).json({ error: 'Failed to fetch DX news', items: [] });
  }
});

// POTA Spots
// POTA cache (1 minute)
let potaCache = { data: null, timestamp: 0 };
const POTA_CACHE_TTL = 60 * 1000; // 1 minute

app.get('/api/pota/spots', async (req, res) => {
  try {
    // Return cached data if fresh
    if (potaCache.data && (Date.now() - potaCache.timestamp) < POTA_CACHE_TTL) {
      return res.json(potaCache.data);
    }
    
    const response = await fetch('https://api.pota.app/spot/activator');
    const data = await response.json();
    
    // Log diagnostic info about the response
    if (Array.isArray(data) && data.length > 0) {
      const sample = data[0];
      logDebug('[POTA] API returned', data.length, 'spots. Sample fields:', Object.keys(sample).join(', '));
      
      // Count coordinate coverage
      const withLatLon = data.filter(s => s.latitude && s.longitude).length;
      const withGrid6 = data.filter(s => s.grid6).length;
      const withGrid4 = data.filter(s => s.grid4).length;
      const noCoords = data.filter(s => !s.latitude && !s.longitude && !s.grid6 && !s.grid4).length;
      logDebug(`[POTA] Coords: ${withLatLon} lat/lon, ${withGrid6} grid6, ${withGrid4} grid4, ${noCoords} no coords`);
    }
    
    // Cache the response
    potaCache = { data, timestamp: Date.now() };
    
    res.json(data);
  } catch (error) {
    logErrorOnce('POTA', error.message);
    // Return stale cache on error
    if (potaCache.data) return res.json(potaCache.data);
    res.status(500).json({ error: 'Failed to fetch POTA spots' });
  }
});

// SOTA cache (2 minutes)
let sotaCache = { data: null, timestamp: 0 };
const SOTA_CACHE_TTL = 2 * 60 * 1000; // 2 minutes

// SOTA Spots
app.get('/api/sota/spots', async (req, res) => {
  try {
    // Return cached data if fresh
    if (sotaCache.data && (Date.now() - sotaCache.timestamp) < SOTA_CACHE_TTL) {
      return res.json(sotaCache.data);
    }
    
    const response = await fetch('https://api2.sota.org.uk/api/spots/50/all');
    const data = await response.json();
    
    // Cache the response
    sotaCache = { data, timestamp: Date.now() };
    
    res.json(data);
  } catch (error) {
    logErrorOnce('SOTA', error.message);
    if (sotaCache.data) return res.json(sotaCache.data);
    res.status(500).json({ error: 'Failed to fetch SOTA spots' });
  }
});

// HamQSL cache (5 minutes)
let hamqslCache = { data: null, timestamp: 0 };
const HAMQSL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes

// HamQSL Band Conditions
app.get('/api/hamqsl/conditions', async (req, res) => {
  try {
    // Return cached data if fresh
    if (hamqslCache.data && (Date.now() - hamqslCache.timestamp) < HAMQSL_CACHE_TTL) {
      res.set('Content-Type', 'application/xml');
      return res.send(hamqslCache.data);
    }
    
    const response = await fetch('https://www.hamqsl.com/solarxml.php');
    const text = await response.text();
    
    // Cache the response
    hamqslCache = { data: text, timestamp: Date.now() };
    
    res.set('Content-Type', 'application/xml');
    res.send(text);
  } catch (error) {
    logErrorOnce('HamQSL', error.message);
    if (hamqslCache.data) {
      res.set('Content-Type', 'application/xml');
      return res.send(hamqslCache.data);
    }
    res.status(500).json({ error: 'Failed to fetch band conditions' });
  }
});

// DX Cluster proxy - fetches from selectable sources
// Query param: ?source=hamqth|dxspider|proxy|auto (default: auto)
// Note: DX Spider uses telnet - works locally but may be blocked on cloud hosting
// The 'proxy' source uses our DX Spider Proxy microservice

// DX Spider Proxy URL (sibling service on Railway or external)
const DXSPIDER_PROXY_URL = process.env.DXSPIDER_PROXY_URL || 'https://dxspider-proxy-production-1ec7.up.railway.app';

// Cache for DX Spider telnet spots (to avoid excessive connections)
let dxSpiderCache = { spots: [], timestamp: 0 };
const DXSPIDER_CACHE_TTL = 90000; // 90 seconds cache - reduces reconnection frequency

app.get('/api/dxcluster/spots', async (req, res) => {
  const source = (req.query.source || CONFIG.dxClusterSource || 'auto').toLowerCase();
  
  // Helper function for HamQTH (HTTP-based, works everywhere)
  async function fetchHamQTH() {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 10000);
    
    try {
      const response = await fetch('https://www.hamqth.com/dxc_csv.php?limit=25', {
        headers: { 'User-Agent': 'OpenHamClock/3.5' },
        signal: controller.signal
      });
      clearTimeout(timeout);
      
      if (response.ok) {
        const text = await response.text();
        // HamQTH CSV format: Spotter^Frequency^DXCall^Comment^TimeDate^^^Continent^Band^Country^DXCC
        // Example: KF0NYM^18070.0^TX5U^Correction, Good Sig MO, 73^2149 2025-05-27^^^EU^17M^France^227
        const lines = text.trim().split('\n').filter(line => line.includes('^'));
        
        if (lines.length > 0) {
          const spots = lines.slice(0, 25).map(line => {
            const parts = line.split('^');
            const spotter = parts[0] || '';
            const freqKhz = parseFloat(parts[1]) || 0;
            const dxCall = parts[2] || 'UNKNOWN';
            const comment = parts[3] || '';
            const timeDate = parts[4] || '';
            
            // Frequency: convert from kHz to MHz
            const freqMhz = freqKhz > 1000 ? (freqKhz / 1000).toFixed(3) : String(freqKhz);
            
            // Time: extract HHMM from "2149 2025-05-27" format
            let time = '';
            if (timeDate && timeDate.length >= 4) {
              const timeStr = timeDate.substring(0, 4);
              time = timeStr.substring(0, 2) + ':' + timeStr.substring(2, 4) + 'z';
            }
            
            return {
              freq: freqMhz,
              call: dxCall,
              comment: comment,
              time: time,
              spotter: spotter,
              source: 'HamQTH'
            };
          });
          logDebug('[DX Cluster] HamQTH:', spots.length, 'spots');
          return spots;
        }
      }
    } catch (error) {
      clearTimeout(timeout);
      if (error.name !== 'AbortError') {
        logErrorOnce('DX Cluster', `HamQTH: ${error.message}`);
      }
    }
    return null;
  }
  
  // Helper function for DX Spider Proxy (our microservice)
  async function fetchDXSpiderProxy() {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 10000);
    
    try {
      const response = await fetch(`${DXSPIDER_PROXY_URL}/api/dxcluster/spots?limit=50`, {
        headers: { 'User-Agent': 'OpenHamClock/3.5' },
        signal: controller.signal
      });
      clearTimeout(timeout);
      
      if (response.ok) {
        const spots = await response.json();
        if (Array.isArray(spots) && spots.length > 0) {
          logDebug('[DX Cluster] DX Spider Proxy:', spots.length, 'spots');
          return spots;
        }
      }
    } catch (error) {
      clearTimeout(timeout);
      if (error.name !== 'AbortError') {
        logErrorOnce('DX Cluster', `Proxy: ${error.message}`);
      }
    }
    return null;
  }
  
  // Helper function for DX Spider (telnet-based, works locally/Pi)
  // Multiple nodes for failover
  // DX Spider nodes - dxspider.co.uk primary per G6NHU
  // SSID -56 for OpenHamClock (HamClock uses -55)
  const DXSPIDER_NODES = [
    { host: 'dxspider.co.uk', port: 7300 },
    { host: 'dxc.nc7j.com', port: 7373 },
    { host: 'dxc.ai9t.com', port: 7373 },
    { host: 'dxc.w6cua.org', port: 7300 }
  ];
  const DXSPIDER_SSID = '-56'; // OpenHamClock SSID
  
  async function fetchDXSpider() {
    // Check cache first (use longer cache to reduce connection attempts)
    if (Date.now() - dxSpiderCache.timestamp < DXSPIDER_CACHE_TTL && dxSpiderCache.spots.length > 0) {
      logDebug('[DX Cluster] DX Spider: returning', dxSpiderCache.spots.length, 'cached spots');
      return dxSpiderCache.spots;
    }
    
    // Try each node until one succeeds
    for (const node of DXSPIDER_NODES) {
      const result = await tryDXSpiderNode(node);
      if (result && result.length > 0) {
        return result;
      }
    }
    
    logDebug('[DX Cluster] DX Spider: all nodes failed');
    return null;
  }
  
  function tryDXSpiderNode(node) {
    return new Promise((resolve) => {
      const spots = [];
      let buffer = '';
      let loginSent = false;
      let commandSent = false;
      let resolved = false;
      
      const client = new net.Socket();
      client.setTimeout(12000);
      
      const cleanup = () => {
        if (!resolved) {
          resolved = true;
          try { client.destroy(); } catch(e) {}
        }
      };
      
      // Try connecting to DX Spider node
      client.connect(node.port, node.host, () => {
        logDebug(`[DX Cluster] DX Spider: connected to ${node.host}:${node.port}`);
      });
      
      client.on('data', (data) => {
        buffer += data.toString();
        
        // Wait for login prompt
        if (!loginSent && (buffer.includes('login:') || buffer.includes('Please enter your call') || buffer.includes('enter your callsign'))) {
          loginSent = true;
          client.write('GUEST\r\n');
          return;
        }
        
        // Wait for prompt after login, then send command
        if (loginSent && !commandSent && (buffer.includes('Hello') || buffer.includes('de ') || buffer.includes('>') || buffer.includes('GUEST'))) {
          commandSent = true;
          setTimeout(() => {
            if (!resolved) {
              client.write('sh/dx 25\r\n');
            }
          }, 1000);
          return;
        }
        
        // Parse DX spots from the output
        const lines = buffer.split('\n');
        for (const line of lines) {
          if (line.includes('DX de ')) {
            const match = line.match(/DX de ([A-Z0-9\/\-]+):\s+(\d+\.?\d*)\s+([A-Z0-9\/\-]+)\s+(.+?)\s+(\d{4})Z/i);
            if (match) {
              const spotter = match[1].replace(':', '');
              const freqKhz = parseFloat(match[2]);
              const dxCall = match[3];
              const comment = match[4].trim();
              const timeStr = match[5];
              
              if (!isNaN(freqKhz) && freqKhz > 0 && dxCall) {
                const freqMhz = (freqKhz / 1000).toFixed(3);
                const time = timeStr.substring(0, 2) + ':' + timeStr.substring(2, 4) + 'z';
                
                // Avoid duplicates
                if (!spots.find(s => s.call === dxCall && s.freq === freqMhz)) {
                  spots.push({
                    freq: freqMhz,
                    call: dxCall,
                    comment: comment,
                    time: time,
                    spotter: spotter,
                    source: 'DX Spider'
                  });
                }
              }
            }
          }
        }
        
        // If we have enough spots, close connection
        if (spots.length >= 20) {
          client.write('bye\r\n');
          setTimeout(cleanup, 500);
        }
      });
      
      client.on('timeout', () => {
        cleanup();
      });
      
      client.on('error', (err) => {
        // Only log unexpected errors, not connection issues (they're common)
        if (!err.message.includes('ECONNRESET') && !err.message.includes('ETIMEDOUT') && !err.message.includes('ENOTFOUND') && !err.message.includes('ECONNREFUSED')) {
          logErrorOnce('DX Cluster', `DX Spider ${node.host}: ${err.message}`);
        }
        cleanup();
      });
      
      client.on('close', () => {
        if (!resolved) {
          resolved = true;
          if (spots.length > 0) {
            logDebug('[DX Cluster] DX Spider:', spots.length, 'spots from', node.host);
            dxSpiderCache = { spots: spots, timestamp: Date.now() };
            resolve(spots);
          } else {
            resolve(null);
          }
        }
      });
      
      // Fallback timeout - close after 15 seconds regardless
      setTimeout(() => {
        if (!resolved) {
          if (spots.length > 0) {
            resolved = true;
            logDebug('[DX Cluster] DX Spider:', spots.length, 'spots from', node.host);
            dxSpiderCache = { spots: spots, timestamp: Date.now() };
            resolve(spots);
          }
          cleanup();
          if (!resolved) {
            resolved = true;
            resolve(null);
          }
        }
      }, 15000);
    });
  }
  
  // Fetch based on selected source
  let spots = null;
  
  if (source === 'hamqth') {
    spots = await fetchHamQTH();
  } else if (source === 'proxy') {
    spots = await fetchDXSpiderProxy();
    // Fallback to HamQTH if proxy fails
    if (!spots) {
      logDebug('[DX Cluster] Proxy failed, falling back to HamQTH');
      spots = await fetchHamQTH();
    }
  } else if (source === 'dxspider') {
    spots = await fetchDXSpider();
    // Fallback to HamQTH if DX Spider fails
    if (!spots) {
      logDebug('[DX Cluster] DX Spider failed, falling back to HamQTH');
      spots = await fetchHamQTH();
    }
  } else {
    // Auto mode - try Proxy first (best for Railway), then HamQTH, then DX Spider
    spots = await fetchDXSpiderProxy();
    if (!spots) {
      spots = await fetchHamQTH();
    }
    if (!spots) {
      spots = await fetchDXSpider();
    }
  }
  
  res.json(spots || []);
});

// Get available DX cluster sources
app.get('/api/dxcluster/sources', (req, res) => {
  res.json([
    { id: 'auto', name: 'Auto (Best Available)', description: 'Tries Proxy first, then HamQTH, then direct telnet' },
    { id: 'proxy', name: 'DX Spider Proxy ⭐', description: 'Our dedicated proxy service - real-time telnet feed via HTTP' },
    { id: 'hamqth', name: 'HamQTH', description: 'HamQTH.com CSV feed (HTTP, works everywhere)' },
    { id: 'dxspider', name: 'DX Spider Direct', description: 'Direct telnet to dxspider.co.uk (G6NHU) - works locally/Pi' }
  ]);
});

// ============================================
// DX SPOT PATHS API - Get spots with locations for map visualization
// Returns spots from the last 5 minutes with spotter and DX locations
// ============================================

// Cache for DX spot paths to avoid excessive lookups
let dxSpotPathsCache = { paths: [], allPaths: [], timestamp: 0 };
const DXPATHS_CACHE_TTL = 5000; // 5 seconds cache between fetches
const DXPATHS_RETENTION = 30 * 60 * 1000; // 30 minute spot retention

app.get('/api/dxcluster/paths', async (req, res) => {
  // Check cache first
  if (Date.now() - dxSpotPathsCache.timestamp < DXPATHS_CACHE_TTL && dxSpotPathsCache.paths.length > 0) {
    logDebug('[DX Paths] Returning', dxSpotPathsCache.paths.length, 'cached paths');
    return res.json(dxSpotPathsCache.paths);
  }
  
  try {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 10000);
    const now = Date.now();
    
    // Try proxy first for better real-time data
    let newSpots = [];
    let usedSource = 'none';
    
    try {
      const proxyResponse = await fetch(`${DXSPIDER_PROXY_URL}/api/spots?limit=100`, {
        headers: { 'User-Agent': 'OpenHamClock/3.7' },
        signal: controller.signal
      });
      
      if (proxyResponse.ok) {
        const proxyData = await proxyResponse.json();
        if (proxyData.spots && proxyData.spots.length > 0) {
          usedSource = 'proxy';
          newSpots = proxyData.spots.map(s => ({
            spotter: s.spotter,
            spotterGrid: s.spotterGrid || null,
            dxCall: s.call,
            dxGrid: s.dxGrid || null,
            freq: s.freq,
            comment: s.comment || '',
            time: s.time || '',
            id: `${s.call}-${s.freqKhz || s.freq}-${s.spotter}`
          }));
          logDebug('[DX Paths] Got', newSpots.length, 'spots from proxy');
        }
      }
    } catch (proxyErr) {
      logDebug('[DX Paths] Proxy failed, trying HamQTH');
    }
    
    // Fallback to HamQTH if proxy failed
    if (newSpots.length === 0) {
      try {
        const response = await fetch('https://www.hamqth.com/dxc_csv.php?limit=50', {
          headers: { 'User-Agent': 'OpenHamClock/3.7' },
          signal: controller.signal
        });
        
        if (response.ok) {
          const text = await response.text();
          const lines = text.trim().split('\n').filter(line => line.includes('^'));
          usedSource = 'hamqth';
          
          for (const line of lines) {
            const parts = line.split('^');
            if (parts.length < 5) continue;
            
            const spotter = parts[0]?.trim().toUpperCase();
            const freqKhz = parseFloat(parts[1]) || 0;
            const dxCall = parts[2]?.trim().toUpperCase();
            const comment = parts[3]?.trim() || '';
            const timeDate = parts[4]?.trim() || '';
            
            if (!spotter || !dxCall || freqKhz <= 0) continue;
            
            // Extract grids from comment for HamQTH data too
            const grids = extractGridsFromComment(comment);
            
            newSpots.push({
              spotter,
              spotterGrid: grids.spotterGrid,
              dxCall,
              dxGrid: grids.dxGrid,
              freq: (freqKhz / 1000).toFixed(3),
              comment,
              time: timeDate.length >= 4 ? timeDate.substring(0, 2) + ':' + timeDate.substring(2, 4) + 'z' : '',
              id: `${dxCall}-${freqKhz}-${spotter}`
            });
          }
          logDebug('[DX Paths] Got', newSpots.length, 'spots from HamQTH');
        }
      } catch (hamqthErr) {
        logDebug('[DX Paths] HamQTH also failed');
      }
    }
    
    clearTimeout(timeout);
    
    if (newSpots.length === 0) {
      // Return existing paths if fetch failed
      const validPaths = dxSpotPathsCache.allPaths.filter(p => (now - p.timestamp) < DXPATHS_RETENTION);
      return res.json(validPaths.slice(0, 50));
    }
    
    // Get unique callsigns to look up
    const allCalls = new Set();
    newSpots.forEach(s => {
      allCalls.add(s.spotter);
      allCalls.add(s.dxCall);
    });
    
    // Look up prefix-based locations for all callsigns (includes grid squares!)
    const prefixLocations = {};
    const callsToLookup = [...allCalls].slice(0, 100);
    
    for (const call of callsToLookup) {
      const loc = estimateLocationFromPrefix(call);
      if (loc) {
        prefixLocations[call] = { 
          lat: loc.lat, 
          lon: loc.lon, 
          country: loc.country, 
          grid: loc.grid || null,  // Include grid from prefix mapping!
          source: loc.grid ? 'prefix-grid' : 'prefix' 
        };
      }
    }
    
    // Build new paths with locations - try grid first, fall back to prefix
    const newPaths = newSpots
      .map(spot => {
        // DX station location - try grid from spot data first, then comment, then prefix
        let dxLoc = null;
        let dxGridSquare = null;
        
        // Check if spot already has dxGrid from proxy
        if (spot.dxGrid) {
          const gridLoc = maidenheadToLatLon(spot.dxGrid);
          if (gridLoc) {
            dxLoc = { lat: gridLoc.lat, lon: gridLoc.lon, country: '', source: 'grid' };
            dxGridSquare = spot.dxGrid;
          }
        }
        
        // If no grid yet, try extracting from comment
        if (!dxLoc && spot.comment) {
          const extractedGrids = extractGridsFromComment(spot.comment);
          if (extractedGrids.dxGrid) {
            const gridLoc = maidenheadToLatLon(extractedGrids.dxGrid);
            if (gridLoc) {
              dxLoc = { lat: gridLoc.lat, lon: gridLoc.lon, country: '', source: 'grid' };
              dxGridSquare = extractedGrids.dxGrid;
            }
          }
        }
        
        // Fall back to prefix location (now includes grid-based coordinates!)
        if (!dxLoc) {
          dxLoc = prefixLocations[spot.dxCall];
          if (dxLoc && dxLoc.grid) {
            dxGridSquare = dxLoc.grid;
          }
        }
        
        // Spotter location - try grid first, then prefix
        let spotterLoc = null;
        let spotterGridSquare = null;
        
        // Check if spot already has spotterGrid from proxy
        if (spot.spotterGrid) {
          const gridLoc = maidenheadToLatLon(spot.spotterGrid);
          if (gridLoc) {
            spotterLoc = { lat: gridLoc.lat, lon: gridLoc.lon, country: '', source: 'grid' };
            spotterGridSquare = spot.spotterGrid;
          }
        }
        
        // If no grid yet, try extracting from comment (in case of dual grid format)
        if (!spotterLoc && spot.comment) {
          const extractedGrids = extractGridsFromComment(spot.comment);
          if (extractedGrids.spotterGrid) {
            const gridLoc = maidenheadToLatLon(extractedGrids.spotterGrid);
            if (gridLoc) {
              spotterLoc = { lat: gridLoc.lat, lon: gridLoc.lon, country: '', source: 'grid' };
              spotterGridSquare = extractedGrids.spotterGrid;
            }
          }
        }
        
        // Fall back to prefix location for spotter (now includes grid-based coordinates!)
        if (!spotterLoc) {
          spotterLoc = prefixLocations[spot.spotter];
          if (spotterLoc && spotterLoc.grid) {
            spotterGridSquare = spotterLoc.grid;
          }
        }
        
        if (spotterLoc && dxLoc) {
          return {
            spotter: spot.spotter,
            spotterLat: spotterLoc.lat,
            spotterLon: spotterLoc.lon,
            spotterCountry: spotterLoc.country || '',
            spotterGrid: spotterGridSquare,
            spotterLocSource: spotterLoc.source,
            dxCall: spot.dxCall,
            dxLat: dxLoc.lat,
            dxLon: dxLoc.lon,
            dxCountry: dxLoc.country || '',
            dxGrid: dxGridSquare,
            dxLocSource: dxLoc.source,
            freq: spot.freq,
            comment: spot.comment,
            time: spot.time,
            id: spot.id,
            timestamp: now
          };
        }
        return null;
      })
      .filter(p => p !== null);
    
    // Merge with existing paths, removing expired and duplicates
    const existingValidPaths = dxSpotPathsCache.allPaths.filter(p => 
      (now - p.timestamp) < DXPATHS_RETENTION
    );
    
    // Add new paths, avoiding duplicates (same dxCall+freq within 2 minutes)
    const mergedPaths = [...existingValidPaths];
    for (const newPath of newPaths) {
      const isDuplicate = mergedPaths.some(existing => 
        existing.dxCall === newPath.dxCall && 
        existing.freq === newPath.freq &&
        (now - existing.timestamp) < 120000 // 2 minute dedup window
      );
      if (!isDuplicate) {
        mergedPaths.push(newPath);
      }
    }
    
    // Sort by timestamp (newest first) and limit
    const sortedPaths = mergedPaths.sort((a, b) => b.timestamp - a.timestamp).slice(0, 100);
    
    logDebug('[DX Paths]', sortedPaths.length, 'total paths (', newPaths.length, 'new from', newSpots.length, 'spots)');
    
    // Update cache
    dxSpotPathsCache = { 
      paths: sortedPaths.slice(0, 50), // Return 50 for display
      allPaths: sortedPaths, // Keep all for accumulation
      timestamp: now 
    };
    
    res.json(dxSpotPathsCache.paths);
  } catch (error) {
    logErrorOnce('DX Paths', error.message);
    // Return cached data on error
    res.json(dxSpotPathsCache.paths || []);
  }
});

// ============================================
// CALLSIGN LOOKUP API (for getting location from callsign)
// ============================================

// Simple callsign to grid/location lookup using HamQTH
app.get('/api/callsign/:call', async (req, res) => {
  const callsign = req.params.call.toUpperCase();
  logDebug('[Callsign Lookup] Looking up:', callsign);
  
  try {
    // Try HamQTH XML API (no auth needed for basic lookup)
    const response = await fetch(`https://www.hamqth.com/dxcc.php?callsign=${callsign}`);
    if (response.ok) {
      const text = await response.text();
      
      // Parse basic info from response
      const latMatch = text.match(/<lat>([^<]+)<\/lat>/);
      const lonMatch = text.match(/<lng>([^<]+)<\/lng>/);
      const countryMatch = text.match(/<name>([^<]+)<\/name>/);
      const cqMatch = text.match(/<cq>([^<]+)<\/cq>/);
      const ituMatch = text.match(/<itu>([^<]+)<\/itu>/);
      
      if (latMatch && lonMatch) {
        const result = {
          callsign,
          lat: parseFloat(latMatch[1]),
          lon: parseFloat(lonMatch[1]),
          country: countryMatch ? countryMatch[1] : 'Unknown',
          cqZone: cqMatch ? cqMatch[1] : '',
          ituZone: ituMatch ? ituMatch[1] : ''
        };
        logDebug('[Callsign Lookup] Found:', result);
        return res.json(result);
      }
    }
    
    // Fallback: estimate location from callsign prefix
    const estimated = estimateLocationFromPrefix(callsign);
    if (estimated) {
      logDebug('[Callsign Lookup] Estimated from prefix:', estimated);
      return res.json(estimated);
    }
    
    res.status(404).json({ error: 'Callsign not found' });
  } catch (error) {
    logErrorOnce('Callsign Lookup', error.message);
    res.status(500).json({ error: 'Lookup failed' });
  }
});

// Convert Maidenhead grid locator to lat/lon (center of grid square)
function maidenheadToLatLon(grid) {
  if (!grid || typeof grid !== 'string') return null;
  
  grid = grid.toUpperCase().trim();
  
  // Validate grid format (2, 4, 6, or 8 characters)
  if (!/^[A-R]{2}([0-9]{2}([A-X]{2}([0-9]{2})?)?)?$/.test(grid)) return null;
  
  let lon = -180;
  let lat = -90;
  
  // Field (2 chars): 20° lon x 10° lat
  lon += (grid.charCodeAt(0) - 65) * 20;
  lat += (grid.charCodeAt(1) - 65) * 10;
  
  if (grid.length >= 4) {
    // Square (2 digits): 2° lon x 1° lat
    lon += parseInt(grid[2]) * 2;
    lat += parseInt(grid[3]) * 1;
  }
  
  if (grid.length >= 6) {
    // Subsquare (2 chars): 5' lon x 2.5' lat
    lon += (grid.charCodeAt(4) - 65) * (5 / 60);
    lat += (grid.charCodeAt(5) - 65) * (2.5 / 60);
  }
  
  if (grid.length >= 8) {
    // Extended square (2 digits): 0.5' lon x 0.25' lat
    lon += parseInt(grid[6]) * (0.5 / 60);
    lat += parseInt(grid[7]) * (0.25 / 60);
  }
  
  // Add offset to center of the grid square
  if (grid.length === 2) {
    lon += 10; lat += 5;
  } else if (grid.length === 4) {
    lon += 1; lat += 0.5;
  } else if (grid.length === 6) {
    lon += 2.5 / 60; lat += 1.25 / 60;
  } else if (grid.length === 8) {
    lon += 0.25 / 60; lat += 0.125 / 60;
  }
  
  return { lat, lon, grid };
}

// Try to extract grid locators from a comment string
// Returns { spotterGrid, dxGrid } - may have one, both, or neither
function extractGridsFromComment(comment) {
  if (!comment || typeof comment !== 'string') return { spotterGrid: null, dxGrid: null };
  
  // Check for dual grid format: FN20<>EM79 or FN20->EM79 or FN20/EM79
  const dualGridMatch = comment.match(/\b([A-Ra-r]{2}[0-9]{2}(?:[A-Xa-x]{2})?)\s*(?:<>|->|\/|<)\s*([A-Ra-r]{2}[0-9]{2}(?:[A-Xa-x]{2})?)\b/);
  if (dualGridMatch) {
    const grid1 = dualGridMatch[1].toUpperCase();
    const grid2 = dualGridMatch[2].toUpperCase();
    // Validate both are real grids
    if (isValidGrid(grid1) && isValidGrid(grid2)) {
      return { spotterGrid: grid1, dxGrid: grid2 };
    }
  }
  
  // Look for all grids in the comment
  const gridPattern = /\b([A-Ra-r]{2}[0-9]{2}(?:[A-Xa-x]{2})?)\b/g;
  const grids = [];
  let match;
  while ((match = gridPattern.exec(comment)) !== null) {
    const grid = match[1].toUpperCase();
    if (isValidGrid(grid)) {
      grids.push(grid);
    }
  }
  
  // If we found two grids, assume first is spotter, second is DX
  if (grids.length >= 2) {
    return { spotterGrid: grids[0], dxGrid: grids[1] };
  }
  
  // If we found one grid, assume it's the DX station
  if (grids.length === 1) {
    return { spotterGrid: null, dxGrid: grids[0] };
  }
  
  return { spotterGrid: null, dxGrid: null };
}

// Validate a grid square is realistic (not "CQ00", "DE12", etc)
function isValidGrid(grid) {
  if (!grid || grid.length < 4) return false;
  const firstChar = grid.charCodeAt(0);
  const secondChar = grid.charCodeAt(1);
  // First char should be A-R, second char should be A-R
  return firstChar >= 65 && firstChar <= 82 && secondChar >= 65 && secondChar <= 82;
}

// Legacy single-grid extraction (kept for compatibility)
function extractGridFromComment(comment) {
  const grids = extractGridsFromComment(comment);
  return grids.dxGrid;
}

// Estimate location from callsign prefix using grid squares
// This gives much better precision than country centers
function estimateLocationFromPrefix(callsign) {
  if (!callsign) return null;
  
  // Comprehensive prefix to grid mapping
  // Uses typical/central grid for each prefix area
  const prefixGrids = {
    // USA - by call district
    'W1': 'FN41', 'K1': 'FN41', 'N1': 'FN41', 'AA1': 'FN41', // New England
    'W2': 'FN20', 'K2': 'FN20', 'N2': 'FN20', 'AA2': 'FN20', // NY/NJ
    'W3': 'FM19', 'K3': 'FM19', 'N3': 'FM19', 'AA3': 'FM19', // PA/MD/DE
    'W4': 'EM73', 'K4': 'EM73', 'N4': 'EM73', 'AA4': 'EM73', // SE USA
    'W5': 'EM12', 'K5': 'EM12', 'N5': 'EM12', 'AA5': 'EM12', // TX/OK/LA/AR/MS
    'W6': 'CM97', 'K6': 'CM97', 'N6': 'CM97', 'AA6': 'CM97', // California
    'W7': 'DN31', 'K7': 'DN31', 'N7': 'DN31', 'AA7': 'DN31', // Pacific NW/Mountain
    'W8': 'EN81', 'K8': 'EN81', 'N8': 'EN81', 'AA8': 'EN81', // MI/OH/WV
    'W9': 'EN52', 'K9': 'EN52', 'N9': 'EN52', 'AA9': 'EN52', // IL/IN/WI
    'W0': 'EN31', 'K0': 'EN31', 'N0': 'EN31', 'AA0': 'EN31', // Central USA
    // Generic USA (no district) - AA through AL are all US prefixes
    'W': 'EM79', 'K': 'EM79', 'N': 'EM79', 
    'AA': 'EM79', 'AB': 'EM79', 'AC': 'EM79', 'AD': 'EM79', 'AE': 'EM79', 'AF': 'EM79',
    'AG': 'EM79', 'AH': 'EM79', 'AI': 'EM79', 'AJ': 'EM79', 'AK': 'EM79', 'AL': 'EM79',
    // US A-prefixes by call district
    'AE0': 'EN31', 'AE1': 'FN41', 'AE2': 'FN20', 'AE3': 'FM19', 'AE4': 'EM73', 
    'AE5': 'EM12', 'AE6': 'CM97', 'AE7': 'DN31', 'AE8': 'EN81', 'AE9': 'EN52',
    'AC0': 'EN31', 'AC1': 'FN41', 'AC2': 'FN20', 'AC3': 'FM19', 'AC4': 'EM73',
    'AC5': 'EM12', 'AC6': 'CM97', 'AC7': 'DN31', 'AC8': 'EN81', 'AC9': 'EN52',
    'AD0': 'EN31', 'AD1': 'FN41', 'AD2': 'FN20', 'AD3': 'FM19', 'AD4': 'EM73',
    'AD5': 'EM12', 'AD6': 'CM97', 'AD7': 'DN31', 'AD8': 'EN81', 'AD9': 'EN52',
    'AF0': 'EN31', 'AF1': 'FN41', 'AF2': 'FN20', 'AF3': 'FM19', 'AF4': 'EM73',
    'AF5': 'EM12', 'AF6': 'CM97', 'AF7': 'DN31', 'AF8': 'EN81', 'AF9': 'EN52',
    'AG0': 'EN31', 'AG1': 'FN41', 'AG2': 'FN20', 'AG3': 'FM19', 'AG4': 'EM73',
    'AG5': 'EM12', 'AG6': 'CM97', 'AG7': 'DN31', 'AG8': 'EN81', 'AG9': 'EN52',
    'AI0': 'EN31', 'AI1': 'FN41', 'AI2': 'FN20', 'AI3': 'FM19', 'AI4': 'EM73',
    'AI5': 'EM12', 'AI6': 'CM97', 'AI7': 'DN31', 'AI8': 'EN81', 'AI9': 'EN52',
    'AJ0': 'EN31', 'AJ1': 'FN41', 'AJ2': 'FN20', 'AJ3': 'FM19', 'AJ4': 'EM73',
    'AJ5': 'EM12', 'AJ6': 'CM97', 'AJ7': 'DN31', 'AJ8': 'EN81', 'AJ9': 'EN52',
    'AK0': 'EN31', 'AK1': 'FN41', 'AK2': 'FN20', 'AK3': 'FM19', 'AK4': 'EM73',
    'AK5': 'EM12', 'AK6': 'CM97', 'AK7': 'DN31', 'AK8': 'EN81', 'AK9': 'EN52',
    'AL0': 'EN31', 'AL1': 'FN41', 'AL2': 'FN20', 'AL3': 'FM19', 'AL4': 'EM73',
    'AL5': 'EM12', 'AL6': 'CM97', 'AL7': 'BP51', 'AL8': 'EN81', 'AL9': 'EN52', // AL7 = Alaska
    
    // Canada - by province
    'VE1': 'FN74', 'VA1': 'FN74', // Maritime
    'VE2': 'FN35', 'VA2': 'FN35', // Quebec
    'VE3': 'FN03', 'VA3': 'FN03', // Ontario
    'VE4': 'EN19', 'VA4': 'EN19', // Manitoba
    'VE5': 'DO51', 'VA5': 'DO51', // Saskatchewan
    'VE6': 'DO33', 'VA6': 'DO33', // Alberta
    'VE7': 'CN89', 'VA7': 'CN89', // British Columbia
    'VE8': 'DP31', 'VA8': 'DP31', // NWT
    'VE9': 'FN65', 'VA9': 'FN65', // New Brunswick
    'VY1': 'CP28', // Yukon
    'VY2': 'FN86', // PEI
    'VO1': 'GN37', 'VO2': 'GO17', // Newfoundland/Labrador
    'VE': 'FN03', 'VA': 'FN03', // Generic Canada
    
    // UK & Ireland
    'G': 'IO91', 'M': 'IO91', '2E': 'IO91', 'GW': 'IO81', // England/Wales
    'GM': 'IO85', 'MM': 'IO85', '2M': 'IO85', // Scotland
    'GI': 'IO64', 'MI': 'IO64', '2I': 'IO64', // N. Ireland
    'EI': 'IO63', 'EJ': 'IO63', // Ireland
    
    // Germany
    'DL': 'JO51', 'DJ': 'JO51', 'DK': 'JO51', 'DA': 'JO51', 'DB': 'JO51', 'DC': 'JO51', 'DD': 'JO51', 'DF': 'JO51', 'DG': 'JO51', 'DH': 'JO51', 'DO': 'JO51',
    
    // Rest of Europe
    'F': 'JN18', // France
    'I': 'JN61', 'IK': 'JN45', 'IZ': 'JN61', // Italy
    'EA': 'IN80', 'EC': 'IN80', 'EB': 'IN80', // Spain
    'CT': 'IM58', // Portugal
    'PA': 'JO21', 'PD': 'JO21', 'PE': 'JO21', 'PH': 'JO21', // Netherlands
    'ON': 'JO20', 'OO': 'JO20', 'OR': 'JO20', 'OT': 'JO20', // Belgium
    'HB': 'JN47', 'HB9': 'JN47', // Switzerland
    'OE': 'JN78', // Austria
    'OZ': 'JO55', 'OU': 'JO55', // Denmark
    'SM': 'JO89', 'SA': 'JO89', 'SB': 'JO89', 'SE': 'JO89', // Sweden
    'LA': 'JO59', 'LB': 'JO59', // Norway
    'OH': 'KP20', 'OF': 'KP20', 'OG': 'KP20', 'OI': 'KP20', // Finland
    'SP': 'JO91', 'SQ': 'JO91', 'SO': 'JO91', '3Z': 'JO91', // Poland
    'OK': 'JN79', 'OL': 'JN79', // Czech Republic
    'OM': 'JN88', // Slovakia
    'HA': 'JN97', 'HG': 'JN97', // Hungary
    'YO': 'KN34', // Romania
    'LZ': 'KN22', // Bulgaria
    'YU': 'KN04', // Serbia
    '9A': 'JN75', // Croatia
    'S5': 'JN76', // Slovenia
    'SV': 'KM17', 'SX': 'KM17', // Greece
    '9H': 'JM75', // Malta
    'LY': 'KO24', // Lithuania
    'ES': 'KO29', // Estonia
    'YL': 'KO26', // Latvia
    
    // Russia & Ukraine
    'UA': 'KO85', 'RA': 'KO85', 'RU': 'KO85', 'RV': 'KO85', 'RW': 'KO85', 'RX': 'KO85', 'RZ': 'KO85',
    'UA0': 'OO33', 'RA0': 'OO33', 'R0': 'OO33', // Asiatic Russia
    'UA9': 'MO06', 'RA9': 'MO06', 'R9': 'MO06', // Ural
    'UR': 'KO50', 'UT': 'KO50', 'UX': 'KO50', 'US': 'KO50', // Ukraine
    
    // Japan - by call area
    'JA1': 'PM95', 'JH1': 'PM95', 'JR1': 'PM95', 'JE1': 'PM95', 'JF1': 'PM95', 'JG1': 'PM95', 'JI1': 'PM95', 'JJ1': 'PM95', 'JK1': 'PM95', 'JL1': 'PM95', 'JM1': 'PM95', 'JN1': 'PM95', 'JO1': 'PM95', 'JP1': 'PM95', 'JQ1': 'PM95', 'JS1': 'PM95', '7K1': 'PM95', '7L1': 'PM95', '7M1': 'PM95', '7N1': 'PM95',
    'JA2': 'PM84', 'JA3': 'PM74', 'JA4': 'PM64', 'JA5': 'PM63', 'JA6': 'PM53', 'JA7': 'QM07', 'JA8': 'QN02', 'JA9': 'PM86', 'JA0': 'PM97',
    'JA': 'PM95', 'JH': 'PM95', 'JR': 'PM95', 'JE': 'PM95', 'JF': 'PM95', 'JG': 'PM95', // Generic Japan
    
    // Rest of Asia
    'HL': 'PM37', 'DS': 'PM37', '6K': 'PM37', '6L': 'PM37', // South Korea
    'BV': 'PL04', 'BW': 'PL04', 'BX': 'PL04', // Taiwan
    'BY': 'OM92', 'BT': 'OM92', 'BA': 'OM92', 'BD': 'OM92', 'BG': 'OM92', // China
    'VU': 'MK82', 'VU2': 'MK82', 'VU3': 'MK82', // India
    'HS': 'OK03', 'E2': 'OK03', // Thailand
    '9V': 'OJ11', // Singapore
    '9M': 'OJ05', '9W': 'OJ05', // Malaysia
    'DU': 'PK04', 'DV': 'PK04', 'DW': 'PK04', 'DX': 'PK04', 'DY': 'PK04', 'DZ': 'PK04', '4D': 'PK04', '4E': 'PK04', '4F': 'PK04', '4G': 'PK04', '4H': 'PK04', '4I': 'PK04', // Philippines
    'YB': 'OI33', 'YC': 'OI33', 'YD': 'OI33', 'YE': 'OI33', 'YF': 'OI33', 'YG': 'OI33', 'YH': 'OI33', // Indonesia
    
    // Oceania
    'VK': 'QF56', 'VK1': 'QF44', 'VK2': 'QF56', 'VK3': 'QF22', 'VK4': 'QG62', 'VK5': 'PF95', 'VK6': 'OF86', 'VK7': 'QE38', // Australia
    'ZL': 'RF70', 'ZL1': 'RF72', 'ZL2': 'RF70', 'ZL3': 'RE66', 'ZL4': 'RE54', // New Zealand
    'KH6': 'BL01', // Hawaii
    'KH2': 'QK24', // Guam
    'FK': 'RG37', // New Caledonia
    
    // South America
    'LU': 'GF05', 'LW': 'GF05', 'LO': 'GF05', 'L2': 'GF05', 'L3': 'GF05', 'L4': 'GF05', 'L5': 'GF05', 'L6': 'GF05', 'L7': 'GF05', 'L8': 'GF05', 'L9': 'GF05', // Argentina
    'PY': 'GG87', 'PP': 'GG87', 'PQ': 'GG87', 'PR': 'GG87', 'PS': 'GG87', 'PT': 'GG87', 'PU': 'GG87', 'PV': 'GG87', 'PW': 'GG87', 'PX': 'GG87', // Brazil
    'CE': 'FF46', 'CA': 'FF46', 'CB': 'FF46', 'CC': 'FF46', 'CD': 'FF46', 'XQ': 'FF46', 'XR': 'FF46', '3G': 'FF46', // Chile
    'CX': 'GF15', // Uruguay
    'HC': 'FI09', 'HD': 'FI09', // Ecuador
    'OA': 'FH17', 'OB': 'FH17', 'OC': 'FH17', // Peru
    'HK': 'FJ35', 'HJ': 'FJ35', '5J': 'FJ35', '5K': 'FJ35', // Colombia
    'YV': 'FK60', 'YW': 'FK60', 'YX': 'FK60', 'YY': 'FK60', // Venezuela
    
    // Caribbean
    'KP4': 'FK68', 'NP4': 'FK68', 'WP4': 'FK68', // Puerto Rico
    'VP5': 'FL31', // Turks & Caicos
    'HI': 'FK49', // Dominican Republic
    'CO': 'FL10', 'CM': 'FL10', // Cuba
    'FG': 'FK96', // Guadeloupe
    'FM': 'FK94', // Martinique
    'PJ': 'FK52', // Netherlands Antilles
    
    // Africa
    'ZS': 'KG33', 'ZR': 'KG33', 'ZT': 'KG33', 'ZU': 'KG33', // South Africa
    '5N': 'JJ55', // Nigeria
    'CN': 'IM63', // Morocco
    '7X': 'JM16', // Algeria
    'SU': 'KL30', // Egypt
    '5Z': 'KI88', // Kenya
    'ET': 'KJ49', // Ethiopia
    'EA8': 'IL18', 'EA9': 'IM75', // Canary Islands, Ceuta
    
    // Middle East
    'A4': 'LL93', 'A41': 'LL93', 'A45': 'LL93', // Oman
    'A6': 'LL65', 'A61': 'LL65', // UAE
    'A7': 'LL45', 'A71': 'LL45', // Qatar
    'HZ': 'LL24', // Saudi Arabia
    '4X': 'KM72', '4Z': 'KM72', // Israel
    'OD': 'KM73', // Lebanon
    
    // Other
    'VP8': 'GD18', // Falkland Islands
    'CE9': 'FC56', 'DP0': 'IB59', 'KC4': 'FC56', // Antarctica
    'SV5': 'KM46', 'SV9': 'KM25', // Dodecanese, Crete
  };
  
  const upper = callsign.toUpperCase();
  
  // Smart US callsign detection - US prefixes follow specific patterns
  // K, N, W + anything = USA
  // A[A-L] + digit = USA (e.g., AA0, AE5, AL7)
  const usCallPattern = /^([KNW][0-9]?|A[A-L][0-9])/;
  const usMatch = upper.match(usCallPattern);
  if (usMatch) {
    // Extract call district (the digit) for more precise location
    const districtMatch = upper.match(/^[KNWA][A-L]?([0-9])/);
    const district = districtMatch ? districtMatch[1] : null;
    
    const usDistrictGrids = {
      '0': 'EN31', // Central (CO, IA, KS, MN, MO, NE, ND, SD)
      '1': 'FN41', // New England (CT, MA, ME, NH, RI, VT)
      '2': 'FN20', // NY, NJ
      '3': 'FM19', // PA, MD, DE
      '4': 'EM73', // Southeast (AL, FL, GA, KY, NC, SC, TN, VA)
      '5': 'EM12', // TX, OK, LA, AR, MS, NM
      '6': 'CM97', // California
      '7': 'DN31', // Pacific NW/Mountain (AZ, ID, MT, NV, OR, UT, WA, WY)
      '8': 'EN81', // MI, OH, WV
      '9': 'EN52', // IL, IN, WI
    };
    
    const grid = district && usDistrictGrids[district] ? usDistrictGrids[district] : 'EM79';
    const gridLoc = maidenheadToLatLon(grid);
    if (gridLoc) {
      return {
        callsign,
        lat: gridLoc.lat,
        lon: gridLoc.lon,
        grid: grid,
        country: 'USA',
        estimated: true,
        source: 'prefix-grid'
      };
    }
  }
  
  // Try longest prefix match first (up to 4 chars) for non-US calls
  for (let len = 4; len >= 1; len--) {
    const prefix = upper.substring(0, len);
    if (prefixGrids[prefix]) {
      const gridLoc = maidenheadToLatLon(prefixGrids[prefix]);
      if (gridLoc) {
        return { 
          callsign, 
          lat: gridLoc.lat, 
          lon: gridLoc.lon, 
          grid: prefixGrids[prefix],
          country: getCountryFromPrefix(prefix),
          estimated: true,
          source: 'prefix-grid'
        };
      }
    }
  }
  
  // Fallback to first character (most likely country for each letter)
  const firstCharGrids = {
    'A': 'EM79', 'B': 'PL02', 'C': 'FN03', 'D': 'JO51', 'E': 'IO63', // A=USA (AA-AL), B=China, C=Canada, D=Germany, E=Spain/Ireland
    'F': 'JN18', 'G': 'IO91', 'H': 'KM72', 'I': 'JN61', 'J': 'PM95', // F=France, G=UK, H=varies, I=Italy, J=Japan
    'K': 'EM79', 'L': 'GF05', 'M': 'IO91', 'N': 'EM79', 'O': 'KP20', // K=USA, L=Argentina, M=UK, N=USA, O=Finland
    'P': 'GG87', 'R': 'KO85', 'S': 'JO89', 'T': 'KI88', 'U': 'KO85', // P=Brazil, R=Russia, S=Sweden, T=varies, U=Russia
    'V': 'QF56', 'W': 'EM79', 'X': 'EK09', 'Y': 'JO91', 'Z': 'KG33'  // V=Australia, W=USA, X=Mexico, Y=varies, Z=South Africa
  };
  
  const firstChar = upper[0];
  if (firstCharGrids[firstChar]) {
    const gridLoc = maidenheadToLatLon(firstCharGrids[firstChar]);
    if (gridLoc) {
      return {
        callsign,
        lat: gridLoc.lat,
        lon: gridLoc.lon,
        grid: firstCharGrids[firstChar],
        country: 'Unknown',
        estimated: true,
        source: 'prefix-grid'
      };
    }
  }
  
  return null;
}

// Helper to get country name from prefix
function getCountryFromPrefix(prefix) {
  const prefixCountries = {
    'W': 'USA', 'K': 'USA', 'N': 'USA', 'AA': 'USA',
    'VE': 'Canada', 'VA': 'Canada', 'VY': 'Canada', 'VO': 'Canada',
    'G': 'England', 'M': 'England', '2E': 'England', 'GM': 'Scotland', 'GW': 'Wales', 'GI': 'N. Ireland',
    'EI': 'Ireland', 'F': 'France', 'DL': 'Germany', 'I': 'Italy', 'EA': 'Spain', 'CT': 'Portugal',
    'PA': 'Netherlands', 'ON': 'Belgium', 'HB': 'Switzerland', 'OE': 'Austria',
    'OZ': 'Denmark', 'SM': 'Sweden', 'LA': 'Norway', 'OH': 'Finland',
    'SP': 'Poland', 'OK': 'Czech Rep', 'HA': 'Hungary', 'YO': 'Romania', 'LZ': 'Bulgaria',
    'UA': 'Russia', 'UR': 'Ukraine',
    'JA': 'Japan', 'HL': 'S. Korea', 'BV': 'Taiwan', 'BY': 'China', 'VU': 'India', 'HS': 'Thailand',
    'VK': 'Australia', 'ZL': 'New Zealand', 'KH6': 'Hawaii',
    'LU': 'Argentina', 'PY': 'Brazil', 'CE': 'Chile', 'HK': 'Colombia', 'YV': 'Venezuela',
    'ZS': 'South Africa', 'CN': 'Morocco', 'SU': 'Egypt'
  };
  
  for (let len = 3; len >= 1; len--) {
    const p = prefix.substring(0, len);
    if (prefixCountries[p]) return prefixCountries[p];
  }
  return 'Unknown';
}

// ============================================
// MY SPOTS API - Get spots involving a specific callsign
// ============================================

app.get('/api/myspots/:callsign', async (req, res) => {
  const callsign = req.params.callsign.toUpperCase();
  logDebug('[My Spots] Searching for callsign:', callsign);
  
  const mySpots = [];
  
  try {
    // Try HamQTH for spots involving this callsign
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 10000);
    
    const response = await fetch(
      `https://www.hamqth.com/dxc_csv.php?limit=100`,
      {
        headers: { 'User-Agent': 'OpenHamClock/3.3' },
        signal: controller.signal
      }
    );
    clearTimeout(timeout);
    
    if (response.ok) {
      const text = await response.text();
      const lines = text.trim().split('\n');
      
      for (const line of lines) {
        if (!line.trim()) continue;
        const parts = line.split('^');
        if (parts.length < 3) continue;
        
        const spotter = parts[0]?.trim().toUpperCase();
        const dxCall = parts[2]?.trim().toUpperCase();
        const freq = parts[1]?.trim();
        const comment = parts[3]?.trim() || '';
        const timeStr = parts[4]?.trim() || '';
        
        // Check if our callsign is involved (as spotter or spotted)
        if (spotter === callsign || dxCall === callsign || 
            spotter.includes(callsign) || dxCall.includes(callsign)) {
          mySpots.push({
            spotter,
            dxCall,
            freq: freq ? (parseFloat(freq) / 1000).toFixed(3) : '0.000',
            comment,
            time: timeStr ? timeStr.substring(0, 5) + 'z' : '',
            isMySpot: spotter.includes(callsign),
            isSpottedMe: dxCall.includes(callsign)
          });
        }
      }
    }
    
    logDebug('[My Spots] Found', mySpots.length, 'spots involving', callsign);
    
    // Now try to get locations for each unique callsign
    const uniqueCalls = [...new Set(mySpots.map(s => s.isMySpot ? s.dxCall : s.spotter))];
    const locations = {};
    
    for (const call of uniqueCalls.slice(0, 10)) { // Limit to 10 lookups
      try {
        const loc = estimateLocationFromPrefix(call);
        if (loc) {
          locations[call] = { lat: loc.lat, lon: loc.lon, country: loc.country };
        }
      } catch (e) {
        // Ignore lookup errors
      }
    }
    
    // Add locations to spots
    const spotsWithLocations = mySpots.map(spot => {
      const targetCall = spot.isMySpot ? spot.dxCall : spot.spotter;
      const loc = locations[targetCall];
      return {
        ...spot,
        targetCall,
        lat: loc?.lat,
        lon: loc?.lon,
        country: loc?.country
      };
    }).filter(s => s.lat && s.lon); // Only return spots with valid locations
    
    res.json(spotsWithLocations);
  } catch (error) {
    logErrorOnce('My Spots', error.message);
    res.json([]);
  }
});

// ============================================
// PSKREPORTER API (MQTT-based for real-time)
// ============================================

// PSKReporter MQTT feed at mqtt.pskreporter.info provides real-time spots
// WebSocket endpoints: 1885 (ws), 1886 (wss)
// Topic format: pskr/filter/v2/{band}/{mode}/{sendercall}/{receivercall}/{senderlocator}/{receiverlocator}/{sendercountry}/{receivercountry}

// Cache for PSKReporter data - stores recent spots from MQTT
const pskReporterSpots = {
  tx: new Map(), // Map of callsign -> spots where they're being heard
  rx: new Map(), // Map of callsign -> spots they're receiving
  maxAge: 60 * 60 * 1000 // Keep spots for 1 hour max
};

// Clean up old spots periodically
setInterval(() => {
  const cutoff = Date.now() - pskReporterSpots.maxAge;
  for (const [call, spots] of pskReporterSpots.tx) {
    const filtered = spots.filter(s => s.timestamp > cutoff);
    if (filtered.length === 0) {
      pskReporterSpots.tx.delete(call);
    } else {
      pskReporterSpots.tx.set(call, filtered);
    }
  }
  for (const [call, spots] of pskReporterSpots.rx) {
    const filtered = spots.filter(s => s.timestamp > cutoff);
    if (filtered.length === 0) {
      pskReporterSpots.rx.delete(call);
    } else {
      pskReporterSpots.rx.set(call, filtered);
    }
  }
}, 5 * 60 * 1000); // Clean every 5 minutes

// Convert grid square to lat/lon
function gridToLatLonSimple(grid) {
  if (!grid || grid.length < 4) return null;
  
  const g = grid.toUpperCase();
  const lon = (g.charCodeAt(0) - 65) * 20 - 180;
  const lat = (g.charCodeAt(1) - 65) * 10 - 90;
  const lonMin = parseInt(g[2]) * 2;
  const latMin = parseInt(g[3]) * 1;
  
  let finalLon = lon + lonMin + 1;
  let finalLat = lat + latMin + 0.5;
  
  // If 6-character grid, add more precision
  if (grid.length >= 6) {
    const lonSec = (g.charCodeAt(4) - 65) * (2/24);
    const latSec = (g.charCodeAt(5) - 65) * (1/24);
    finalLon = lon + lonMin + lonSec + (1/24);
    finalLat = lat + latMin + latSec + (0.5/24);
  }
  
  return { lat: finalLat, lon: finalLon };
}

// Get band name from frequency in Hz
function getBandFromHz(freqHz) {
  const freq = freqHz / 1000000; // Convert to MHz
  if (freq >= 1.8 && freq <= 2) return '160m';
  if (freq >= 3.5 && freq <= 4) return '80m';
  if (freq >= 5.3 && freq <= 5.4) return '60m';
  if (freq >= 7 && freq <= 7.3) return '40m';
  if (freq >= 10.1 && freq <= 10.15) return '30m';
  if (freq >= 14 && freq <= 14.35) return '20m';
  if (freq >= 18.068 && freq <= 18.168) return '17m';
  if (freq >= 21 && freq <= 21.45) return '15m';
  if (freq >= 24.89 && freq <= 24.99) return '12m';
  if (freq >= 28 && freq <= 29.7) return '10m';
  if (freq >= 50 && freq <= 54) return '6m';
  if (freq >= 144 && freq <= 148) return '2m';
  if (freq >= 420 && freq <= 450) return '70cm';
  return 'Unknown';
}

// PSKReporter endpoint - returns MQTT connection info for frontend
// The frontend connects directly to MQTT via WebSocket for real-time updates
app.get('/api/pskreporter/config', (req, res) => {
  res.json({
    mqtt: {
      host: 'mqtt.pskreporter.info',
      wsPort: 1885,      // WebSocket
      wssPort: 1886,     // WebSocket + TLS (recommended)
      topicPrefix: 'pskr/filter/v2'
    },
    // Topic format: pskr/filter/v2/{band}/{mode}/{sendercall}/{receivercall}/{senderlocator}/{receiverlocator}/{sendercountry}/{receivercountry}
    // Use + for single-level wildcard, # for multi-level
    // Example for TX (being heard): pskr/filter/v2/+/+/{CALLSIGN}/#
    // Example for RX (hearing): Subscribe and filter client-side
    info: 'Connect via WebSocket to mqtt.pskreporter.info:1886 (wss) for real-time spots'
  });
});

// Fallback HTTP endpoint for when MQTT isn't available
// Uses the traditional retrieve API with caching
let pskHttpCache = {};
const PSK_HTTP_CACHE_TTL = 10 * 60 * 1000; // 10 minutes - PSKReporter rate limits aggressively
let psk503Backoff = 0; // Timestamp when we can try again after 503

app.get('/api/pskreporter/http/:callsign', async (req, res) => {
  const callsign = req.params.callsign.toUpperCase();
  const minutes = parseInt(req.query.minutes) || 15;
  const direction = req.query.direction || 'tx'; // tx or rx
  // flowStartSeconds must be NEGATIVE for "last N seconds"
  const flowStartSeconds = -Math.abs(minutes * 60);
  
  const cacheKey = `${direction}:${callsign}:${minutes}`;
  const now = Date.now();
  
  // Check cache first
  if (pskHttpCache[cacheKey] && (now - pskHttpCache[cacheKey].timestamp) < PSK_HTTP_CACHE_TTL) {
    return res.json({ ...pskHttpCache[cacheKey].data, cached: true });
  }
  
  // If we're in 503 backoff period, return cached data or empty result
  if (psk503Backoff > now) {
    if (pskHttpCache[cacheKey]) {
      return res.json({ ...pskHttpCache[cacheKey].data, cached: true, stale: true });
    }
    return res.json({ 
      callsign, 
      direction, 
      count: 0, 
      reports: [],
      backoff: true
    });
  }
  
  try {
    const param = direction === 'tx' ? 'senderCallsign' : 'receiverCallsign';
    // Add appcontact parameter as requested by PSKReporter developer docs
    const url = `https://retrieve.pskreporter.info/query?${param}=${encodeURIComponent(callsign)}&flowStartSeconds=${flowStartSeconds}&rronly=1&appcontact=openhamclock`;
    
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 20000);
    
    const response = await fetch(url, {
      headers: { 
        'User-Agent': 'OpenHamClock/3.11 (Amateur Radio Dashboard)',
        'Accept': '*/*'
      },
      signal: controller.signal
    });
    clearTimeout(timeout);
    
    if (!response.ok) {
      // Back off on rate-limit or access errors to avoid hammering
      if (response.status === 503) {
        psk503Backoff = Date.now() + (15 * 60 * 1000);
        logErrorOnce('PSKReporter HTTP', '503 - backing off for 15 minutes');
      } else if (response.status === 403 || response.status === 429) {
        psk503Backoff = Date.now() + (30 * 60 * 1000);
        logErrorOnce('PSKReporter HTTP', `${response.status} - backing off for 30 minutes (server-side HTTP proxy blocked; users unaffected via MQTT)`);
      }
      throw new Error(`HTTP ${response.status}`);
    }
    
    const xml = await response.text();
    const reports = [];
    
    // Parse XML response
    const reportRegex = /<receptionReport[^>]*>/g;
    let match;
    while ((match = reportRegex.exec(xml)) !== null) {
      const report = match[0];
      const getAttr = (name) => {
        const m = report.match(new RegExp(`${name}="([^"]*)"`));
        return m ? m[1] : null;
      };
      
      const receiverCallsign = getAttr('receiverCallsign');
      const receiverLocator = getAttr('receiverLocator');
      const senderCallsign = getAttr('senderCallsign');
      const senderLocator = getAttr('senderLocator');
      const frequency = getAttr('frequency');
      const mode = getAttr('mode');
      const flowStartSecs = getAttr('flowStartSeconds');
      const sNR = getAttr('sNR');
      
      if (receiverCallsign && senderCallsign) {
        const locator = direction === 'tx' ? receiverLocator : senderLocator;
        const loc = locator ? gridToLatLonSimple(locator) : null;
        
        reports.push({
          sender: senderCallsign,
          senderGrid: senderLocator,
          receiver: receiverCallsign,
          receiverGrid: receiverLocator,
          freq: frequency ? parseInt(frequency) : null,
          freqMHz: frequency ? (parseInt(frequency) / 1000000).toFixed(3) : null,
          band: frequency ? getBandFromHz(parseInt(frequency)) : 'Unknown',
          mode: mode || 'Unknown',
          timestamp: flowStartSecs ? parseInt(flowStartSecs) * 1000 : Date.now(),
          snr: sNR ? parseInt(sNR) : null,
          lat: loc?.lat,
          lon: loc?.lon,
          age: flowStartSecs ? Math.floor((Date.now() / 1000 - parseInt(flowStartSecs)) / 60) : 0
        });
      }
    }
    
    // Sort by timestamp (newest first)
    reports.sort((a, b) => b.timestamp - a.timestamp);
    
    // Clear backoff on success
    psk503Backoff = 0;
    
    const result = {
      callsign,
      direction,
      count: reports.length,
      reports: reports.slice(0, 100),
      timestamp: new Date().toISOString(),
      source: 'http'
    };
    
    // Cache it
    pskHttpCache[cacheKey] = { data: result, timestamp: now };
    
    logDebug(`[PSKReporter HTTP] Found ${reports.length} ${direction} reports for ${callsign}`);
    res.json(result);
    
  } catch (error) {
    // Don't re-log 403/503 errors - already logged above with backoff info
    if (!error.message?.includes('HTTP 403') && !error.message?.includes('HTTP 503')) {
      logErrorOnce('PSKReporter HTTP', error.message);
    }
    
    // Return cached data if available (without error flag)
    if (pskHttpCache[cacheKey]) {
      return res.json({ ...pskHttpCache[cacheKey].data, cached: true, stale: true });
    }
    
    // Return empty result without error flag for rate limiting
    res.json({ 
      callsign, 
      direction, 
      count: 0, 
      reports: []
    });
  }
});

// Combined endpoint that tries MQTT cache first, falls back to HTTP
app.get('/api/pskreporter/:callsign', async (req, res) => {
  const callsign = req.params.callsign.toUpperCase();
  const minutes = parseInt(req.query.minutes) || 15;
  
  // For now, redirect to HTTP endpoint since MQTT requires client-side connection
  // The frontend should connect directly to MQTT for real-time updates
  try {
    const [txRes, rxRes] = await Promise.allSettled([
      fetch(`http://localhost:${PORT}/api/pskreporter/http/${callsign}?minutes=${minutes}&direction=tx`),
      fetch(`http://localhost:${PORT}/api/pskreporter/http/${callsign}?minutes=${minutes}&direction=rx`)
    ]);
    
    let txData = { count: 0, reports: [] };
    let rxData = { count: 0, reports: [] };
    
    if (txRes.status === 'fulfilled' && txRes.value.ok) {
      txData = await txRes.value.json();
    }
    if (rxRes.status === 'fulfilled' && rxRes.value.ok) {
      rxData = await rxRes.value.json();
    }
    
    res.json({
      callsign,
      tx: txData,
      rx: rxData,
      timestamp: new Date().toISOString(),
      mqtt: {
        available: true,
        host: 'wss://mqtt.pskreporter.info:1886',
        hint: 'Connect via WebSocket for real-time updates'
      }
    });
    
  } catch (error) {
    logErrorOnce('PSKReporter', error.message);
    res.json({ 
      callsign, 
      tx: { count: 0, reports: [] }, 
      rx: { count: 0, reports: [] }, 
      error: error.message 
    });
  }
});

// ============================================
// WSPR PROPAGATION HEATMAP API
// ============================================

// WSPR heatmap endpoint - gets global propagation data
// Uses PSK Reporter to fetch WSPR mode spots from the last N minutes
let wsprCache = { data: null, timestamp: 0 };
const WSPR_CACHE_TTL = 5 * 60 * 1000; // 5 minutes cache

app.get('/api/wspr/heatmap', async (req, res) => {
  const minutes = parseInt(req.query.minutes) || 30; // Default 30 minutes
  const band = req.query.band || 'all'; // all, 20m, 40m, etc.
  const now = Date.now();
  
  // Return cached data if fresh
  const cacheKey = `${minutes}:${band}`;
  if (wsprCache.data && 
      wsprCache.data.cacheKey === cacheKey && 
      (now - wsprCache.timestamp) < WSPR_CACHE_TTL) {
    return res.json({ ...wsprCache.data.result, cached: true });
  }
  
  try {
    const flowStartSeconds = -Math.abs(minutes * 60);
    // Query PSK Reporter for WSPR mode spots (no specific callsign filter)
    // Get data from multiple popular WSPR frequencies to build heatmap
    const url = `https://retrieve.pskreporter.info/query?mode=WSPR&flowStartSeconds=${flowStartSeconds}&rronly=1&nolocator=0&appcontact=openhamclock&rptlimit=2000`;
    
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 20000);
    
    const response = await fetch(url, {
      headers: { 
        'User-Agent': 'OpenHamClock/3.12 (Amateur Radio Dashboard)',
        'Accept': '*/*'
      },
      signal: controller.signal
    });
    clearTimeout(timeout);
    
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    
    const xml = await response.text();
    const spots = [];
    
    // Parse XML response
    const reportRegex = /<receptionReport[^>]*>/g;
    let match;
    while ((match = reportRegex.exec(xml)) !== null) {
      const report = match[0];
      const getAttr = (name) => {
        const m = report.match(new RegExp(`${name}="([^"]*)"`));
        return m ? m[1] : null;
      };
      
      const receiverCallsign = getAttr('receiverCallsign');
      const receiverLocator = getAttr('receiverLocator');
      const senderCallsign = getAttr('senderCallsign');
      const senderLocator = getAttr('senderLocator');
      const frequency = getAttr('frequency');
      const mode = getAttr('mode');
      const flowStartSecs = getAttr('flowStartSeconds');
      const sNR = getAttr('sNR');
      
      if (receiverCallsign && senderCallsign && senderLocator && receiverLocator) {
        const freq = frequency ? parseInt(frequency) : null;
        const spotBand = freq ? getBandFromHz(freq) : 'Unknown';
        
        // Filter by band if specified
        if (band !== 'all' && spotBand !== band) continue;
        
        const senderLoc = gridToLatLonSimple(senderLocator);
        const receiverLoc = gridToLatLonSimple(receiverLocator);
        
        if (senderLoc && receiverLoc) {
          spots.push({
            sender: senderCallsign,
            senderGrid: senderLocator,
            senderLat: senderLoc.lat,
            senderLon: senderLoc.lon,
            receiver: receiverCallsign,
            receiverGrid: receiverLocator,
            receiverLat: receiverLoc.lat,
            receiverLon: receiverLoc.lon,
            freq: freq,
            freqMHz: freq ? (freq / 1000000).toFixed(3) : null,
            band: spotBand,
            snr: sNR ? parseInt(sNR) : null,
            timestamp: flowStartSecs ? parseInt(flowStartSecs) * 1000 : Date.now(),
            age: flowStartSecs ? Math.floor((Date.now() / 1000 - parseInt(flowStartSecs)) / 60) : 0
          });
        }
      }
    }
    
    // Sort by timestamp (newest first)
    spots.sort((a, b) => b.timestamp - a.timestamp);
    
    const result = {
      count: spots.length,
      spots: spots,
      minutes: minutes,
      band: band,
      timestamp: new Date().toISOString(),
      source: 'pskreporter'
    };
    
    // Cache it
    wsprCache = { 
      data: { result, cacheKey }, 
      timestamp: now 
    };
    
    console.log(`[WSPR Heatmap] Found ${spots.length} WSPR spots (${minutes}min, band: ${band})`);
    res.json(result);
    
  } catch (error) {
    logErrorOnce('WSPR Heatmap', error.message);
    
    // Return cached data if available
    if (wsprCache.data && wsprCache.data.cacheKey === cacheKey) {
      return res.json({ ...wsprCache.data.result, cached: true, stale: true });
    }
    
    // Return empty result
    res.json({ 
      count: 0, 
      spots: [],
      minutes,
      band,
      error: error.message 
    });
  }
});

// ============================================
// SATELLITE TRACKING API
// ============================================

// Comprehensive ham radio satellites - NORAD IDs
// Updated list of active amateur radio satellites
const HAM_SATELLITES = {
  // High Priority - Popular FM satellites
  'ISS': { norad: 25544, name: 'ISS (ZARYA)', color: '#00ffff', priority: 1, mode: 'FM/APRS/SSTV' },
  'SO-50': { norad: 27607, name: 'SO-50', color: '#00ff00', priority: 1, mode: 'FM' },
  'AO-91': { norad: 43017, name: 'AO-91 (Fox-1B)', color: '#ff6600', priority: 1, mode: 'FM' },
  'AO-92': { norad: 43137, name: 'AO-92 (Fox-1D)', color: '#ff9900', priority: 1, mode: 'FM/L-band' },
  'PO-101': { norad: 43678, name: 'PO-101 (Diwata-2)', color: '#ff3399', priority: 1, mode: 'FM' },
  
  // Linear Transponder Satellites
  'RS-44': { norad: 44909, name: 'RS-44 (DOSAAF)', color: '#ff0066', priority: 1, mode: 'Linear' },
  'AO-7': { norad: 7530, name: 'AO-7', color: '#ffcc00', priority: 2, mode: 'Linear (daylight)' },
  'FO-29': { norad: 24278, name: 'FO-29 (JAS-2)', color: '#ff6699', priority: 2, mode: 'Linear' },
  'FO-99': { norad: 43937, name: 'FO-99 (NEXUS)', color: '#ff99cc', priority: 2, mode: 'Linear' },
  'JO-97': { norad: 43803, name: 'JO-97 (JY1Sat)', color: '#cc99ff', priority: 2, mode: 'Linear/FM' },
  'XW-2A': { norad: 40903, name: 'XW-2A (CAS-3A)', color: '#66ff99', priority: 2, mode: 'Linear' },
  'XW-2B': { norad: 40911, name: 'XW-2B (CAS-3B)', color: '#66ffcc', priority: 2, mode: 'Linear' },
  'XW-2C': { norad: 40906, name: 'XW-2C (CAS-3C)', color: '#99ffcc', priority: 2, mode: 'Linear' },
  'XW-2D': { norad: 40907, name: 'XW-2D (CAS-3D)', color: '#99ff99', priority: 2, mode: 'Linear' },
  'XW-2E': { norad: 40909, name: 'XW-2E (CAS-3E)', color: '#ccff99', priority: 2, mode: 'Linear' },
  'XW-2F': { norad: 40910, name: 'XW-2F (CAS-3F)', color: '#ccffcc', priority: 2, mode: 'Linear' },
  
  // CAS (Chinese Amateur Satellites)
  'CAS-4A': { norad: 42761, name: 'CAS-4A', color: '#9966ff', priority: 2, mode: 'Linear' },
  'CAS-4B': { norad: 42759, name: 'CAS-4B', color: '#9933ff', priority: 2, mode: 'Linear' },
  'CAS-6': { norad: 44881, name: 'CAS-6 (TO-108)', color: '#cc66ff', priority: 2, mode: 'Linear' },
  
  // GreenCube / IO satellites
  'IO-117': { norad: 53106, name: 'IO-117 (GreenCube)', color: '#00ff99', priority: 2, mode: 'Digipeater' },
  
  // TEVEL constellation
  'TEVEL-1': { norad: 50988, name: 'TEVEL-1', color: '#66ccff', priority: 3, mode: 'FM' },
  'TEVEL-2': { norad: 50989, name: 'TEVEL-2', color: '#66ddff', priority: 3, mode: 'FM' },
  'TEVEL-3': { norad: 50994, name: 'TEVEL-3', color: '#66eeff', priority: 3, mode: 'FM' },
  'TEVEL-4': { norad: 50998, name: 'TEVEL-4', color: '#77ccff', priority: 3, mode: 'FM' },
  'TEVEL-5': { norad: 51062, name: 'TEVEL-5', color: '#77ddff', priority: 3, mode: 'FM' },
  'TEVEL-6': { norad: 51063, name: 'TEVEL-6', color: '#77eeff', priority: 3, mode: 'FM' },
  'TEVEL-7': { norad: 51069, name: 'TEVEL-7', color: '#88ccff', priority: 3, mode: 'FM' },
  'TEVEL-8': { norad: 51084, name: 'TEVEL-8', color: '#88ddff', priority: 3, mode: 'FM' },
  
  // OSCAR satellites
  'AO-27': { norad: 22825, name: 'AO-27', color: '#ff9966', priority: 3, mode: 'FM' },
  'AO-73': { norad: 39444, name: 'AO-73 (FUNcube-1)', color: '#ffcc66', priority: 3, mode: 'Linear/Telemetry' },
  'EO-88': { norad: 42017, name: 'EO-88 (Nayif-1)', color: '#ffaa66', priority: 3, mode: 'Linear/Telemetry' },
  
  // Russian satellites
  'RS-15': { norad: 23439, name: 'RS-15', color: '#ff6666', priority: 3, mode: 'Linear' },
  
  // QO-100 (Geostationary - special)
  'QO-100': { norad: 43700, name: 'QO-100 (Es\'hail-2)', color: '#ffff00', priority: 1, mode: 'Linear (GEO)' },
  
  // APRS Digipeaters
  'ARISS': { norad: 25544, name: 'ARISS (ISS)', color: '#00ffff', priority: 1, mode: 'APRS' },
  
  // Cubesats with amateur payloads
  'UVSQ-SAT': { norad: 47438, name: 'UVSQ-SAT', color: '#ff66ff', priority: 4, mode: 'Telemetry' },
  'MEZNSAT': { norad: 46489, name: 'MeznSat', color: '#66ff66', priority: 4, mode: 'Telemetry' },
  
  // SSTV/Slow Scan
  'SSTV-ISS': { norad: 25544, name: 'ISS SSTV', color: '#00ffff', priority: 2, mode: 'SSTV' }
};

// Cache for TLE data (refresh every 6 hours)
let tleCache = { data: null, timestamp: 0 };
const TLE_CACHE_DURATION = 6 * 60 * 60 * 1000; // 6 hours

app.get('/api/satellites/tle', async (req, res) => {
  try {
    const now = Date.now();
    
    // Return cached data if fresh
    if (tleCache.data && (now - tleCache.timestamp) < TLE_CACHE_DURATION) {
      return res.json(tleCache.data);
    }
    
    logDebug('[Satellites] Fetching fresh TLE data...');
    
    // Fetch fresh TLE data from CelesTrak
    const tleData = {};
    
    // Fetch amateur radio satellites TLE
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 15000);
    
    const response = await fetch(
      'https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=tle',
      {
        headers: { 'User-Agent': 'OpenHamClock/3.3' },
        signal: controller.signal
      }
    );
    clearTimeout(timeout);
    
    if (response.ok) {
      const text = await response.text();
      const lines = text.trim().split('\n');
      
      // Parse TLE data (3 lines per satellite: name, line1, line2)
      for (let i = 0; i < lines.length - 2; i += 3) {
        const name = lines[i].trim();
        const line1 = lines[i + 1]?.trim();
        const line2 = lines[i + 2]?.trim();
        
        if (line1 && line2 && line1.startsWith('1 ') && line2.startsWith('2 ')) {
          // Extract NORAD ID from line 1
          const noradId = parseInt(line1.substring(2, 7));
          
          // Check if this is a satellite we care about
          for (const [key, sat] of Object.entries(HAM_SATELLITES)) {
            if (sat.norad === noradId) {
              tleData[key] = {
                ...sat,
                tle1: line1,
                tle2: line2
              };
            }
          }
        }
      }
    }
    
    // Also try to get ISS specifically (it's in the stations group)
    if (!tleData['ISS']) {
      try {
        const issController = new AbortController();
        const issTimeout = setTimeout(() => issController.abort(), 10000);
        
        const issResponse = await fetch(
          'https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&FORMAT=tle',
          { 
            headers: { 'User-Agent': 'OpenHamClock/3.3' },
            signal: issController.signal
          }
        );
        clearTimeout(issTimeout);
        
        if (issResponse.ok) {
          const issText = await issResponse.text();
          const issLines = issText.trim().split('\n');
          if (issLines.length >= 3) {
            tleData['ISS'] = {
              ...HAM_SATELLITES['ISS'],
              tle1: issLines[1].trim(),
              tle2: issLines[2].trim()
            };
            logDebug('[Satellites] Found ISS TLE');
          }
        }
      } catch (e) {
        if (e.name !== 'AbortError') {
          logErrorOnce('Satellites', `ISS TLE fetch: ${e.message}`);
        }
      }
    }
    
    // Cache the result
    tleCache = { data: tleData, timestamp: now };
    
    logDebug('[Satellites] Loaded TLE for', Object.keys(tleData).length, 'satellites');
    res.json(tleData);
    
  } catch (error) {
    // Don't spam logs for timeouts (AbortError) or network issues
    if (error.name !== 'AbortError') {
      logErrorOnce('Satellites', `TLE fetch error: ${error.message}`);
    }
    // Return cached data even if stale, or empty object
    res.json(tleCache.data || {});
  }
});

// ============================================
// IONOSONDE DATA API (Real-time ionospheric data from KC2G/GIRO)
// ============================================

// Cache for ionosonde data (refresh every 10 minutes)
let ionosondeCache = {
  data: null,
  timestamp: 0,
  maxAge: 10 * 60 * 1000 // 10 minutes
};

// Fetch real-time ionosonde data from KC2G (GIRO network)
async function fetchIonosondeData() {
  const now = Date.now();
  
  // Return cached data if fresh
  if (ionosondeCache.data && (now - ionosondeCache.timestamp) < ionosondeCache.maxAge) {
    return ionosondeCache.data;
  }
  
  try {
    const response = await fetch('https://prop.kc2g.com/api/stations.json', {
      headers: { 'User-Agent': 'OpenHamClock/3.5' },
      timeout: 15000
    });
    
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    
    const data = await response.json();
    
    // Filter to only recent data (within last 2 hours) with valid readings
    const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
    const validStations = data.filter(s => {
      if (!s.fof2 || !s.station) return false;
      const stationTime = new Date(s.time);
      return stationTime > twoHoursAgo && s.cs > 0; // confidence score > 0
    }).map(s => ({
      code: s.station.code,
      name: s.station.name,
      lat: parseFloat(s.station.latitude),
      lon: parseFloat(s.station.longitude) > 180 ? parseFloat(s.station.longitude) - 360 : parseFloat(s.station.longitude),
      foF2: s.fof2,
      mufd: s.mufd, // MUF at 3000km
      hmF2: s.hmf2, // Height of F2 layer
      md: parseFloat(s.md) || 3.0, // M(3000)F2 factor
      confidence: s.cs,
      time: s.time
    }));
    
    ionosondeCache = {
      data: validStations,
      timestamp: now
    };
    
    logDebug(`[Ionosonde] Fetched ${validStations.length} valid stations from KC2G`);
    return validStations;
    
  } catch (error) {
    logErrorOnce('Ionosonde', `Fetch error: ${error.message}`);
    return ionosondeCache.data || [];
  }
}

// API endpoint to get ionosonde data
app.get('/api/ionosonde', async (req, res) => {
  try {
    const stations = await fetchIonosondeData();
    res.json({
      count: stations.length,
      timestamp: new Date().toISOString(),
      stations: stations
    });
  } catch (error) {
    logErrorOnce('Ionosonde', `API: ${error.message}`);
    res.status(500).json({ error: 'Failed to fetch ionosonde data' });
  }
});

// Calculate distance between two points in km
function haversineDistance(lat1, lon1, lat2, lon2) {
  const R = 6371;
  const dLat = (lat2 - lat1) * Math.PI / 180;
  const dLon = (lon2 - lon1) * Math.PI / 180;
  const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
            Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
            Math.sin(dLon/2) * Math.sin(dLon/2);
  return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}

// Interpolate foF2 at a given location using inverse distance weighting
function interpolateFoF2(lat, lon, stations) {
  if (!stations || stations.length === 0) return null;
  
  // Maximum distance (km) to consider ionosonde data valid
  // Beyond this, the data is too far away to be representative
  const MAX_VALID_DISTANCE = 3000; // km
  
  // Calculate distances to all stations
  const stationsWithDist = stations.map(s => ({
    ...s,
    distance: haversineDistance(lat, lon, s.lat, s.lon)
  })).filter(s => s.foF2 > 0);
  
  if (stationsWithDist.length === 0) return null;
  
  // Sort by distance and take nearest 5
  stationsWithDist.sort((a, b) => a.distance - b.distance);
  
  // Check if nearest station is within valid range
  if (stationsWithDist[0].distance > MAX_VALID_DISTANCE) {
    logDebug(`[Ionosonde] Nearest station ${stationsWithDist[0].name} is ${Math.round(stationsWithDist[0].distance)}km away - too far, using estimates`);
    return {
      foF2: null,
      mufd: null,
      hmF2: null,
      md: 3.0,
      nearestStation: stationsWithDist[0].name,
      nearestDistance: Math.round(stationsWithDist[0].distance),
      stationsUsed: 0,
      method: 'no-coverage',
      reason: `Nearest ionosonde (${stationsWithDist[0].name}) is ${Math.round(stationsWithDist[0].distance)}km away - no local coverage`
    };
  }
  
  // Filter to only stations within valid range
  const validStations = stationsWithDist.filter(s => s.distance <= MAX_VALID_DISTANCE);
  const nearest = validStations.slice(0, 5);
  
  // If very close to a station, use its value directly
  if (nearest[0].distance < 100) {
    return {
      foF2: nearest[0].foF2,
      mufd: nearest[0].mufd,
      hmF2: nearest[0].hmF2,
      md: nearest[0].md,
      source: nearest[0].name,
      confidence: nearest[0].confidence,
      nearestDistance: Math.round(nearest[0].distance),
      method: 'direct'
    };
  }
  
  // Inverse distance weighted interpolation
  let sumWeights = 0;
  let sumFoF2 = 0;
  let sumMufd = 0;
  let sumHmF2 = 0;
  let sumMd = 0;
  
  nearest.forEach(s => {
    const weight = (s.confidence / 100) / Math.pow(s.distance, 2);
    sumWeights += weight;
    sumFoF2 += s.foF2 * weight;
    if (s.mufd) sumMufd += s.mufd * weight;
    if (s.hmF2) sumHmF2 += s.hmF2 * weight;
    if (s.md) sumMd += s.md * weight;
  });
  
  return {
    foF2: sumFoF2 / sumWeights,
    mufd: sumMufd > 0 ? sumMufd / sumWeights : null,
    hmF2: sumHmF2 > 0 ? sumHmF2 / sumWeights : null,
    md: sumMd > 0 ? sumMd / sumWeights : 3.0,
    nearestStation: nearest[0].name,
    nearestDistance: Math.round(nearest[0].distance),
    stationsUsed: nearest.length,
    method: 'interpolated'
  };
}

// ============================================
// HYBRID PROPAGATION SYSTEM
// Combines ITURHFProp (ITU-R P.533-14) with real-time ionosonde data
// ============================================

// Cache for ITURHFProp predictions (5-minute cache)
let iturhfpropCache = {
  data: null,
  key: null,
  timestamp: 0,
  maxAge: 5 * 60 * 1000  // 5 minutes
};

/**
 * Fetch base prediction from ITURHFProp service
 */
async function fetchITURHFPropPrediction(txLat, txLon, rxLat, rxLon, ssn, month, hour) {
  if (!ITURHFPROP_URL) return null;
  
  const cacheKey = `${txLat.toFixed(1)},${txLon.toFixed(1)}-${rxLat.toFixed(1)},${rxLon.toFixed(1)}-${ssn}-${month}-${hour}`;
  const now = Date.now();
  
  // Check cache
  if (iturhfpropCache.key === cacheKey && (now - iturhfpropCache.timestamp) < iturhfpropCache.maxAge) {
    return iturhfpropCache.data;
  }
  
  try {
    const url = `${ITURHFPROP_URL}/api/bands?txLat=${txLat}&txLon=${txLon}&rxLat=${rxLat}&rxLon=${rxLon}&ssn=${ssn}&month=${month}&hour=${hour}`;
    
    // Create abort controller for timeout
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), 15000); // 15 second timeout
    
    const response = await fetch(url, { signal: controller.signal });
    clearTimeout(timeoutId);
    
    if (!response.ok) {
      logErrorOnce('Hybrid', `ITURHFProp returned ${response.status}`);
      return null;
    }
    
    const data = await response.json();
    // Only log success occasionally to reduce noise
    
    // Cache the result
    iturhfpropCache = {
      data,
      key: cacheKey,
      timestamp: now,
      maxAge: iturhfpropCache.maxAge
    };
    
    return data;
  } catch (err) {
    if (err.name !== 'AbortError') {
      logErrorOnce('Hybrid', `ITURHFProp: ${err.message}`);
    }
    return null;
  }
}

/**
 * Fetch 24-hour predictions from ITURHFProp
 */
async function fetchITURHFPropHourly(txLat, txLon, rxLat, rxLon, ssn, month) {
  if (!ITURHFPROP_URL) return null;
  
  try {
    const url = `${ITURHFPROP_URL}/api/predict/hourly?txLat=${txLat}&txLon=${txLon}&rxLat=${rxLat}&rxLon=${rxLon}&ssn=${ssn}&month=${month}`;
    
    const response = await fetch(url, { timeout: 60000 }); // 60s timeout for 24-hour calc
    if (!response.ok) return null;
    
    const data = await response.json();
    return data;
  } catch (err) {
    if (err.name !== 'AbortError') {
      logErrorOnce('Hybrid', `ITURHFProp hourly: ${err.message}`);
    }
    return null;
  }
}

/**
 * Calculate ionospheric correction factor
 * Compares expected foF2 (from P.533 model) vs actual ionosonde foF2
 * Returns multiplier to adjust reliability predictions
 */
function calculateIonoCorrection(expectedFoF2, actualFoF2, kIndex) {
  if (!expectedFoF2 || !actualFoF2) return { factor: 1.0, confidence: 'low' };
  
  // Ratio of actual to expected ionospheric conditions
  const ratio = actualFoF2 / expectedFoF2;
  
  // Geomagnetic correction (storms reduce reliability)
  const kFactor = kIndex <= 3 ? 1.0 : 1.0 - (kIndex - 3) * 0.1;
  
  // Combined correction factor
  // ratio > 1 means better conditions than predicted
  // ratio < 1 means worse conditions than predicted
  const factor = ratio * kFactor;
  
  // Confidence based on how close actual is to expected
  let confidence;
  if (Math.abs(ratio - 1) < 0.15) {
    confidence = 'high';  // Within 15% - model is accurate
  } else if (Math.abs(ratio - 1) < 0.3) {
    confidence = 'medium'; // Within 30%
  } else {
    confidence = 'low';    // Model significantly off - rely more on ionosonde
  }
  
  logDebug(`[Hybrid] Correction factor: ${factor.toFixed(2)} (expected foF2: ${expectedFoF2.toFixed(1)}, actual: ${actualFoF2.toFixed(1)}, K: ${kIndex})`);
  
  return { factor, confidence, ratio, kFactor };
}

/**
 * Apply ionospheric correction to ITURHFProp predictions
 */
function applyHybridCorrection(iturhfpropData, ionoData, kIndex, sfi) {
  if (!iturhfpropData?.bands) return null;
  
  // Estimate what foF2 ITURHFProp expected (based on SSN/SFI)
  const ssn = Math.max(0, Math.round((sfi - 67) / 0.97));
  const expectedFoF2 = 0.9 * Math.sqrt(ssn + 15) * 1.2; // Rough estimate at solar noon
  
  // Get actual foF2 from ionosonde
  const actualFoF2 = ionoData?.foF2;
  
  // Calculate correction
  const correction = calculateIonoCorrection(expectedFoF2, actualFoF2, kIndex);
  
  // Apply correction to each band
  const correctedBands = {};
  for (const [band, data] of Object.entries(iturhfpropData.bands)) {
    const baseReliability = data.reliability || 50;
    
    // Apply correction factor with bounds
    let correctedReliability = baseReliability * correction.factor;
    correctedReliability = Math.max(0, Math.min(100, correctedReliability));
    
    // For high bands, also check if we're above/below MUF
    const freq = data.freq;
    if (actualFoF2 && freq > actualFoF2 * 3.5) {
      // Frequency likely above MUF - reduce reliability
      correctedReliability *= 0.5;
    }
    
    correctedBands[band] = {
      ...data,
      reliability: Math.round(correctedReliability),
      baseReliability: Math.round(baseReliability),
      correctionApplied: correction.factor !== 1.0,
      status: correctedReliability >= 70 ? 'GOOD' : 
              correctedReliability >= 40 ? 'FAIR' : 'POOR'
    };
  }
  
  // Correct MUF based on actual ionosonde data
  let correctedMuf = iturhfpropData.muf;
  if (actualFoF2 && ionoData?.md) {
    // Use actual foF2 * M-factor for more accurate MUF
    const ionoMuf = actualFoF2 * (ionoData.md || 3.0);
    // Blend ITURHFProp MUF with ionosonde-derived MUF
    correctedMuf = (iturhfpropData.muf * 0.4) + (ionoMuf * 0.6);
  }
  
  return {
    bands: correctedBands,
    muf: Math.round(correctedMuf * 10) / 10,
    correction,
    model: 'Hybrid ITU-R P.533-14'
  };
}

/**
 * Estimate expected foF2 from P.533 model for a given hour
 */
function estimateExpectedFoF2(ssn, lat, hour) {
  // Simplified P.533 foF2 estimation
  // diurnal variation: peak around 14:00 local, minimum around 04:00
  const hourFactor = 0.6 + 0.4 * Math.cos((hour - 14) * Math.PI / 12);
  const latFactor = 1 - Math.abs(lat) / 150;
  const ssnFactor = Math.sqrt(ssn + 15);
  
  return 0.9 * ssnFactor * hourFactor * latFactor;
}

// ============================================
// ENHANCED PROPAGATION PREDICTION API (Hybrid ITU-R P.533)
// ============================================

app.get('/api/propagation', async (req, res) => {
  const { deLat, deLon, dxLat, dxLon } = req.query;
  
  const useHybrid = ITURHFPROP_URL !== null;
  logDebug(`[Propagation] ${useHybrid ? 'Hybrid' : 'Standalone'} calculation for DE:`, deLat, deLon, 'to DX:', dxLat, dxLon);
  
  try {
    // Get current space weather data
    let sfi = 150, ssn = 100, kIndex = 2, aIndex = 10;
    
    try {
      const [fluxRes, kRes] = await Promise.allSettled([
        fetch('https://services.swpc.noaa.gov/json/f107_cm_flux.json'),
        fetch('https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json')
      ]);
      
      if (fluxRes.status === 'fulfilled' && fluxRes.value.ok) {
        const data = await fluxRes.value.json();
        if (data?.length) sfi = Math.round(data[data.length - 1].flux || 150);
      }
      if (kRes.status === 'fulfilled' && kRes.value.ok) {
        const data = await kRes.value.json();
        if (data?.length > 1) kIndex = parseInt(data[data.length - 1][1]) || 2;
      }
      ssn = Math.max(0, Math.round((sfi - 67) / 0.97));
    } catch (e) {
      logDebug('[Propagation] Using default solar values');
    }
    
    // Get real ionosonde data
    const ionosondeStations = await fetchIonosondeData();
    
    // Calculate path geometry
    const de = { lat: parseFloat(deLat) || 40, lon: parseFloat(deLon) || -75 };
    const dx = { lat: parseFloat(dxLat) || 35, lon: parseFloat(dxLon) || 139 };
    
    const distance = haversineDistance(de.lat, de.lon, dx.lat, dx.lon);
    const midLat = (de.lat + dx.lat) / 2;
    let midLon = (de.lon + dx.lon) / 2;
    
    // Handle antimeridian crossing
    if (Math.abs(de.lon - dx.lon) > 180) {
      midLon = (de.lon + dx.lon + 360) / 2;
      if (midLon > 180) midLon -= 360;
    }
    
    // Get ionospheric data at path midpoint
    const ionoData = interpolateFoF2(midLat, midLon, ionosondeStations);
    const hasValidIonoData = !!(ionoData && ionoData.method !== 'no-coverage' && ionoData.foF2);
    
    const currentHour = new Date().getUTCHours();
    const currentMonth = new Date().getMonth() + 1;
    
    logDebug('[Propagation] Distance:', Math.round(distance), 'km');
    logDebug('[Propagation] Solar: SFI', sfi, 'SSN', ssn, 'K', kIndex);
    if (hasValidIonoData) {
      logDebug('[Propagation] Real foF2:', ionoData.foF2?.toFixed(2), 'MHz from', ionoData.nearestStation || ionoData.source);
    }
    
    // ===== HYBRID MODE: Try ITURHFProp first =====
    let hybridResult = null;
    if (useHybrid) {
      const iturhfpropData = await fetchITURHFPropPrediction(
        de.lat, de.lon, dx.lat, dx.lon, ssn, currentMonth, currentHour
      );
      
      if (iturhfpropData && hasValidIonoData) {
        // Full hybrid: ITURHFProp + ionosonde correction
        hybridResult = applyHybridCorrection(iturhfpropData, ionoData, kIndex, sfi);
        logDebug('[Propagation] Using HYBRID mode (ITURHFProp + ionosonde correction)');
      } else if (iturhfpropData) {
        // ITURHFProp only (no ionosonde coverage)
        hybridResult = {
          bands: iturhfpropData.bands,
          muf: iturhfpropData.muf,
          model: 'ITU-R P.533-14 (ITURHFProp)'
        };
        logDebug('[Propagation] Using ITURHFProp only (no ionosonde coverage)');
      }
    }
    
    // ===== FALLBACK: Built-in calculations =====
    const bands = ['160m', '80m', '40m', '30m', '20m', '17m', '15m', '12m', '11m', '10m', '6m'];
    const bandFreqs = [1.8, 3.5, 7, 10, 14, 18, 21, 24, 27, 28, 50];
    
    // Generate predictions (hybrid or fallback)
    const effectiveIonoData = hasValidIonoData ? ionoData : null;
    const predictions = {};
    let currentBands;
    
    if (hybridResult) {
      // Use hybrid results for current bands
      currentBands = bands.map((band, idx) => {
        const hybridBand = hybridResult.bands?.[band];
        if (hybridBand) {
          return {
            band,
            freq: bandFreqs[idx],
            reliability: hybridBand.reliability,
            baseReliability: hybridBand.baseReliability,
            snr: calculateSNR(hybridBand.reliability),
            status: hybridBand.status,
            corrected: hybridBand.correctionApplied
          };
        }
        // Fallback for bands not in hybrid result
        const reliability = calculateEnhancedReliability(
          bandFreqs[idx], distance, midLat, midLon, currentHour, sfi, ssn, kIndex, de, dx, effectiveIonoData, currentHour
        );
        return {
          band,
          freq: bandFreqs[idx],
          reliability: Math.round(reliability),
          snr: calculateSNR(reliability),
          status: getStatus(reliability)
        };
      }).sort((a, b) => b.reliability - a.reliability);
      
      // Generate 24-hour predictions with correction ratios from hybrid data
      // This makes predictions more accurate by scaling them to match the hybrid model
      bands.forEach((band, idx) => {
        const freq = bandFreqs[idx];
        predictions[band] = [];
        
        // Calculate built-in reliability for current hour
        const builtInCurrentReliability = calculateEnhancedReliability(
          freq, distance, midLat, midLon, currentHour, sfi, ssn, kIndex, de, dx, effectiveIonoData, currentHour
        );
        
        // Get hybrid reliability for this band (the accurate one)
        const hybridBand = hybridResult.bands?.[band];
        const hybridReliability = hybridBand?.reliability || builtInCurrentReliability;
        
        // Calculate correction ratio (how much to scale predictions)
        // Avoid division by zero, and cap the ratio to prevent extreme corrections
        let correctionRatio = 1.0;
        if (builtInCurrentReliability > 5) {
          correctionRatio = hybridReliability / builtInCurrentReliability;
          // Cap correction ratio to reasonable bounds (0.2x to 3x)
          correctionRatio = Math.max(0.2, Math.min(3.0, correctionRatio));
        } else if (hybridReliability > 20) {
          // Built-in thinks band is closed but hybrid says it's open
          correctionRatio = 2.0;
        }
        
        for (let hour = 0; hour < 24; hour++) {
          const baseReliability = calculateEnhancedReliability(
            freq, distance, midLat, midLon, hour, sfi, ssn, kIndex, de, dx, effectiveIonoData, currentHour
          );
          // Apply correction ratio and clamp to valid range
          const correctedReliability = Math.min(99, Math.max(0, Math.round(baseReliability * correctionRatio)));
          predictions[band].push({
            hour,
            reliability: correctedReliability,
            snr: calculateSNR(correctedReliability)
          });
        }
      });
      
    } else {
      // Full fallback - use built-in calculations
      logDebug('[Propagation] Using FALLBACK mode (built-in calculations)');
      
      bands.forEach((band, idx) => {
        const freq = bandFreqs[idx];
        predictions[band] = [];
        for (let hour = 0; hour < 24; hour++) {
          const reliability = calculateEnhancedReliability(
            freq, distance, midLat, midLon, hour, sfi, ssn, kIndex, de, dx, effectiveIonoData, currentHour
          );
          predictions[band].push({
            hour,
            reliability: Math.round(reliability),
            snr: calculateSNR(reliability)
          });
        }
      });
      
      currentBands = bands.map((band, idx) => ({
        band,
        freq: bandFreqs[idx],
        reliability: predictions[band][currentHour].reliability,
        snr: predictions[band][currentHour].snr,
        status: getStatus(predictions[band][currentHour].reliability)
      })).sort((a, b) => b.reliability - a.reliability);
    }
    
    // Calculate MUF and LUF
    const currentMuf = hybridResult?.muf || calculateMUF(distance, midLat, midLon, currentHour, sfi, ssn, effectiveIonoData);
    const currentLuf = calculateLUF(distance, midLat, currentHour, sfi, kIndex);
    
    // Build ionospheric response
    let ionosphericResponse;
    if (hasValidIonoData) {
      ionosphericResponse = {
        foF2: ionoData.foF2?.toFixed(2),
        mufd: ionoData.mufd?.toFixed(1),
        hmF2: ionoData.hmF2?.toFixed(0),
        source: ionoData.nearestStation || ionoData.source,
        distance: ionoData.nearestDistance,
        method: ionoData.method,
        stationsUsed: ionoData.stationsUsed || 1
      };
    } else if (ionoData?.method === 'no-coverage') {
      ionosphericResponse = {
        source: 'No ionosonde coverage',
        reason: ionoData.reason,
        nearestStation: ionoData.nearestStation,
        nearestDistance: ionoData.nearestDistance,
        method: 'estimated'
      };
    } else {
      ionosphericResponse = { source: 'model', method: 'estimated' };
    }
    
    // Determine data source description
    let dataSource;
    if (hybridResult && hasValidIonoData) {
      dataSource = 'Hybrid: ITURHFProp (ITU-R P.533-14) + KC2G/GIRO ionosonde';
    } else if (hybridResult) {
      dataSource = 'ITURHFProp (ITU-R P.533-14)';
    } else if (hasValidIonoData) {
      dataSource = 'KC2G/GIRO Ionosonde Network';
    } else {
      dataSource = 'Estimated from solar indices';
    }
    
    res.json({
      model: hybridResult?.model || 'Built-in estimation',
      solarData: { sfi, ssn, kIndex },
      ionospheric: ionosphericResponse,
      muf: Math.round(currentMuf * 10) / 10,
      luf: Math.round(currentLuf * 10) / 10,
      distance: Math.round(distance),
      currentHour,
      currentBands,
      hourlyPredictions: predictions,
      hybrid: {
        enabled: useHybrid,
        iturhfpropAvailable: hybridResult !== null,
        ionosondeAvailable: hasValidIonoData,
        correctionFactor: hybridResult?.correction?.factor?.toFixed(2),
        confidence: hybridResult?.correction?.confidence
      },
      dataSource
    });
    
  } catch (error) {
    logErrorOnce('Propagation', error.message);
    res.status(500).json({ error: 'Failed to calculate propagation' });
  }
});

// Legacy endpoint removed - merged into /api/propagation above

// Calculate MUF using real ionosonde data or model
function calculateMUF(distance, midLat, midLon, hour, sfi, ssn, ionoData) {
  // If we have real MUF(3000) data, scale it for actual distance
  if (ionoData?.mufd) {
    // MUF scales with distance: MUF(d) ≈ MUF(3000) * sqrt(3000/d) for d < 3000km
    // For d > 3000km, MUF(d) ≈ MUF(3000) * (1 + 0.1 * log(d/3000))
    if (distance < 3000) {
      return ionoData.mufd * Math.sqrt(distance / 3000);
    } else {
      return ionoData.mufd * (1 + 0.15 * Math.log10(distance / 3000));
    }
  }
  
  // If we have foF2, calculate MUF using M(3000)F2 factor
  if (ionoData?.foF2) {
    const M = ionoData.md || 3.0; // M(3000)F2 factor, typically 2.5-3.5
    const muf3000 = ionoData.foF2 * M;
    
    // Scale for actual distance
    if (distance < 3000) {
      return muf3000 * Math.sqrt(distance / 3000);
    } else {
      return muf3000 * (1 + 0.15 * Math.log10(distance / 3000));
    }
  }
  
  // Fallback: Estimate foF2 from solar indices
  // foF2 ≈ 0.9 * sqrt(SSN + 15) * diurnal_factor
  const hourFactor = 1 + 0.4 * Math.cos((hour - 14) * Math.PI / 12); // Peak at 14:00 local
  const latFactor = 1 - Math.abs(midLat) / 150; // Higher latitudes = lower foF2
  const foF2_est = 0.9 * Math.sqrt(ssn + 15) * hourFactor * latFactor;
  
  // Standard M(3000)F2 factor
  const M = 3.0;
  const muf3000 = foF2_est * M;
  
  // Scale for distance
  if (distance < 3000) {
    return muf3000 * Math.sqrt(distance / 3000);
  } else {
    return muf3000 * (1 + 0.15 * Math.log10(distance / 3000));
  }
}

// Calculate LUF (Lowest Usable Frequency) based on D-layer absorption
function calculateLUF(distance, midLat, hour, sfi, kIndex) {
  // LUF increases with:
  // - Higher solar flux (more D-layer ionization)
  // - Daytime (D-layer forms during day)
  // - Shorter paths (higher elevation angles = more time in D-layer)
  // - Geomagnetic activity
  
  // Local solar time at midpoint (approximate)
  const localHour = hour; // Would need proper calculation with midLon
  
  // Day/night factor: D-layer absorption is much higher during daytime
  let dayFactor = 0.3; // Night
  if (localHour >= 6 && localHour <= 18) {
    // Daytime - peaks around noon
    dayFactor = 0.5 + 0.5 * Math.cos((localHour - 12) * Math.PI / 6);
  }
  
  // Solar flux factor: higher SFI = more absorption
  const sfiFactor = 1 + (sfi - 70) / 200;
  
  // Distance factor: shorter paths have higher LUF (higher angles)
  const distFactor = Math.max(0.5, 1 - distance / 10000);
  
  // Latitude factor: polar paths have more absorption
  const latFactor = 1 + Math.abs(midLat) / 90 * 0.5;
  
  // K-index: geomagnetic storms increase absorption
  const kFactor = 1 + kIndex * 0.1;
  
  // Base LUF is around 2 MHz for long night paths
  const baseLuf = 2.0;
  
  return baseLuf * dayFactor * sfiFactor * distFactor * latFactor * kFactor;
}

// Enhanced reliability calculation using real ionosonde data
function calculateEnhancedReliability(freq, distance, midLat, midLon, hour, sfi, ssn, kIndex, de, dx, ionoData, currentHour) {
  // Calculate MUF and LUF for this hour
  // For non-current hours, we need to estimate how foF2 changes
  let hourIonoData = ionoData;
  
  if (ionoData && hour !== currentHour) {
    // Estimate foF2 change based on diurnal variation
    // foF2 typically varies by factor of 2-3 between day and night
    const currentHourFactor = 1 + 0.4 * Math.cos((currentHour - 14) * Math.PI / 12);
    const targetHourFactor = 1 + 0.4 * Math.cos((hour - 14) * Math.PI / 12);
    const scaleFactor = targetHourFactor / currentHourFactor;
    
    hourIonoData = {
      ...ionoData,
      foF2: ionoData.foF2 * scaleFactor,
      mufd: ionoData.mufd ? ionoData.mufd * scaleFactor : null
    };
  }
  
  const muf = calculateMUF(distance, midLat, midLon, hour, sfi, ssn, hourIonoData);
  const luf = calculateLUF(distance, midLat, hour, sfi, kIndex);
  
  // Calculate reliability based on frequency position relative to MUF/LUF
  let reliability = 0;
  
  if (freq > muf * 1.1) {
    // Well above MUF - very poor
    reliability = Math.max(0, 30 - (freq - muf) * 5);
  } else if (freq > muf) {
    // Slightly above MUF - marginal (sometimes works due to scatter)
    reliability = 30 + (muf * 1.1 - freq) / (muf * 0.1) * 20;
  } else if (freq < luf * 0.8) {
    // Well below LUF - absorbed
    reliability = Math.max(0, 20 - (luf - freq) * 10);
  } else if (freq < luf) {
    // Near LUF - marginal
    reliability = 20 + (freq - luf * 0.8) / (luf * 0.2) * 30;
  } else {
    // In usable range - calculate optimum
    // Optimum Working Frequency (OWF) is typically 80-85% of MUF
    const owf = muf * 0.85;
    const range = muf - luf;
    
    if (range <= 0) {
      reliability = 30; // Very narrow window
    } else {
      // Higher reliability near OWF, tapering toward MUF and LUF
      const position = (freq - luf) / range; // 0 at LUF, 1 at MUF
      const optimalPosition = 0.75; // 75% up from LUF = OWF
      
      if (position < optimalPosition) {
        // Below OWF - reliability increases as we approach OWF
        reliability = 50 + (position / optimalPosition) * 45;
      } else {
        // Above OWF - reliability decreases as we approach MUF
        reliability = 95 - ((position - optimalPosition) / (1 - optimalPosition)) * 45;
      }
    }
  }
  
  // K-index degradation (geomagnetic storms)
  if (kIndex >= 7) reliability *= 0.1;
  else if (kIndex >= 6) reliability *= 0.2;
  else if (kIndex >= 5) reliability *= 0.4;
  else if (kIndex >= 4) reliability *= 0.6;
  else if (kIndex >= 3) reliability *= 0.8;
  
  // Very long paths (multiple hops) are harder
  const hops = Math.ceil(distance / 3500);
  if (hops > 1) {
    reliability *= Math.pow(0.92, hops - 1); // ~8% loss per additional hop
  }
  
  // Polar path penalty (auroral absorption)
  if (Math.abs(midLat) > 60) {
    reliability *= 0.7;
    if (kIndex >= 3) reliability *= 0.7; // Additional penalty during storms
  }
  
  // High bands need sufficient solar activity
  if (freq >= 21 && sfi < 100) reliability *= Math.sqrt(sfi / 100);
  if (freq >= 28 && sfi < 120) reliability *= Math.sqrt(sfi / 120);
  if (freq >= 50 && sfi < 150) reliability *= Math.pow(sfi / 150, 1.5);
  
  // Low bands work better at night
  const localHour = (hour + midLon / 15 + 24) % 24;
  const isNight = localHour < 6 || localHour > 18;
  if (freq <= 7 && isNight) reliability *= 1.1;
  if (freq <= 3.5 && !isNight) reliability *= 0.7;
  
  return Math.min(99, Math.max(0, reliability));
}

// Convert reliability to estimated SNR
function calculateSNR(reliability) {
  if (reliability >= 80) return '+20dB';
  if (reliability >= 60) return '+10dB';
  if (reliability >= 40) return '0dB';
  if (reliability >= 20) return '-10dB';
  return '-20dB';
}

// Get status label from reliability
function getStatus(reliability) {
  if (reliability >= 70) return 'EXCELLENT';
  if (reliability >= 50) return 'GOOD';
  if (reliability >= 30) return 'FAIR';
  if (reliability >= 15) return 'POOR';
  return 'CLOSED';
}

// QRZ Callsign lookup (requires API key)
app.get('/api/qrz/lookup/:callsign', async (req, res) => {
  const { callsign } = req.params;
  // Note: QRZ requires an API key - this is a placeholder
  res.json({ 
    message: 'QRZ lookup requires API key configuration',
    callsign: callsign.toUpperCase()
  });
});

// ============================================
// CONTEST CALENDAR API
// ============================================

app.get('/api/contests', async (req, res) => {
  // Try WA7BNM Contest Calendar RSS feed
  try {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 10000);
    
    const response = await fetch('https://www.contestcalendar.com/calendar.rss', {
      headers: { 
        'User-Agent': 'OpenHamClock/3.3',
        'Accept': 'application/rss+xml, application/xml, text/xml'
      },
      signal: controller.signal
    });
    clearTimeout(timeout);
    
    if (response.ok) {
      const text = await response.text();
      const contests = parseContestRSS(text);
      
      if (contests.length > 0) {
        logDebug('[Contests] WA7BNM RSS:', contests.length, 'contests');
        return res.json(contests);
      }
    }
  } catch (error) {
    if (error.name !== 'AbortError') {
      logErrorOnce('Contests RSS', error.message);
    }
  }

  // Fallback: Use calculated contests
  try {
    const contests = calculateUpcomingContests();
    logDebug('[Contests] Using calculated:', contests.length, 'contests');
    return res.json(contests);
  } catch (error) {
    logErrorOnce('Contests', error.message);
  }

  res.json([]);
});

// Parse WA7BNM RSS feed
function parseContestRSS(xml) {
  const contests = [];
  const now = new Date();
  const currentYear = now.getFullYear();
  
  // Simple regex-based XML parsing (no external dependencies)
  const itemRegex = /<item>([\s\S]*?)<\/item>/g;
  const titleRegex = /<title>([^<]+)<\/title>/;
  const linkRegex = /<link>([^<]+)<\/link>/;
  const descRegex = /<description>([^<]+)<\/description>/;
  
  let match;
  while ((match = itemRegex.exec(xml)) !== null) {
    const item = match[1];
    
    const titleMatch = item.match(titleRegex);
    const linkMatch = item.match(linkRegex);
    const descMatch = item.match(descRegex);
    
    if (titleMatch && descMatch) {
      const name = titleMatch[1].trim();
      const desc = descMatch[1].trim();
      const url = linkMatch ? linkMatch[1].trim() : null;
      
      // Parse description like "1300Z, Jan 31 to 1300Z, Feb 1" or "0000Z-2359Z, Jan 31"
      const parsed = parseContestDateTime(desc, currentYear);
      
      if (parsed) {
        const status = (now >= parsed.start && now <= parsed.end) ? 'active' : 'upcoming';
        
        // Try to detect mode from contest name
        let mode = 'Mixed';
        const nameLower = name.toLowerCase();
        if (nameLower.includes('cw') || nameLower.includes('morse')) mode = 'CW';
        else if (nameLower.includes('ssb') || nameLower.includes('phone') || nameLower.includes('sideband')) mode = 'SSB';
        else if (nameLower.includes('rtty')) mode = 'RTTY';
        else if (nameLower.includes('ft4') || nameLower.includes('ft8') || nameLower.includes('digi')) mode = 'Digital';
        else if (nameLower.includes('vhf') || nameLower.includes('uhf')) mode = 'VHF';
        
        contests.push({
          name,
          start: parsed.start.toISOString(),
          end: parsed.end.toISOString(),
          mode,
          status,
          url
        });
      }
    }
  }
  
  // Sort by start date, filter out past contests, and limit
  const currentAndFuture = contests.filter(c => new Date(c.end) >= now);
  currentAndFuture.sort((a, b) => new Date(a.start) - new Date(b.start));
  return currentAndFuture.slice(0, 20);
}

// Parse contest date/time strings
function parseContestDateTime(desc, year) {
  try {
    const months = { 'jan': 0, 'feb': 1, 'mar': 2, 'apr': 3, 'may': 4, 'jun': 5, 
                     'jul': 6, 'aug': 7, 'sep': 8, 'oct': 9, 'nov': 10, 'dec': 11 };
    
    // Pattern 1: "1300Z, Jan 31 to 1300Z, Feb 1"
    const rangeMatch = desc.match(/(\d{4})Z,\s*(\w+)\s+(\d+)\s+to\s+(\d{4})Z,\s*(\w+)\s+(\d+)/i);
    if (rangeMatch) {
      const [, startTime, startMon, startDay, endTime, endMon, endDay] = rangeMatch;
      const startMonth = months[startMon.toLowerCase()];
      const endMonth = months[endMon.toLowerCase()];
      
      let startYear = year;
      let endYear = year;
      // Handle year rollover
      if (startMonth > 10 && endMonth < 2) endYear = year + 1;
      
      const start = new Date(Date.UTC(startYear, startMonth, parseInt(startDay), 
        parseInt(startTime.substring(0, 2)), parseInt(startTime.substring(2, 4))));
      const end = new Date(Date.UTC(endYear, endMonth, parseInt(endDay),
        parseInt(endTime.substring(0, 2)), parseInt(endTime.substring(2, 4))));
      
      return { start, end };
    }
    
    // Pattern 2: "0000Z-2359Z, Jan 31" (same day)
    const sameDayMatch = desc.match(/(\d{4})Z-(\d{4})Z,\s*(\w+)\s+(\d+)/i);
    if (sameDayMatch) {
      const [, startTime, endTime, mon, day] = sameDayMatch;
      const month = months[mon.toLowerCase()];
      
      const start = new Date(Date.UTC(year, month, parseInt(day),
        parseInt(startTime.substring(0, 2)), parseInt(startTime.substring(2, 4))));
      const end = new Date(Date.UTC(year, month, parseInt(day),
        parseInt(endTime.substring(0, 2)), parseInt(endTime.substring(2, 4))));
      
      // Handle overnight contests (end time < start time means next day)
      if (end <= start) end.setUTCDate(end.getUTCDate() + 1);
      
      return { start, end };
    }
    
    // Pattern 3: "0000Z-0100Z, Feb 5 and 0200Z-0300Z, Feb 6" (multiple sessions - use first)
    const multiMatch = desc.match(/(\d{4})Z-(\d{4})Z,\s*(\w+)\s+(\d+)/i);
    if (multiMatch) {
      const [, startTime, endTime, mon, day] = multiMatch;
      const month = months[mon.toLowerCase()];
      
      const start = new Date(Date.UTC(year, month, parseInt(day),
        parseInt(startTime.substring(0, 2)), parseInt(startTime.substring(2, 4))));
      const end = new Date(Date.UTC(year, month, parseInt(day),
        parseInt(endTime.substring(0, 2)), parseInt(endTime.substring(2, 4))));
      
      if (end <= start) end.setUTCDate(end.getUTCDate() + 1);
      
      return { start, end };
    }
    
  } catch (e) {
    // Parse error, skip this contest
  }
  
  return null;
}

// Helper function to calculate upcoming contests
function calculateUpcomingContests() {
  const now = new Date();
  const contests = [];
  
  // Major contest definitions with typical schedules
  const majorContests = [
    { name: 'CQ WW DX CW', month: 10, weekend: -1, duration: 48, mode: 'CW' }, // Last full weekend Nov
    { name: 'CQ WW DX SSB', month: 9, weekend: -1, duration: 48, mode: 'SSB' }, // Last full weekend Oct
    { name: 'ARRL DX CW', month: 1, weekend: 3, duration: 48, mode: 'CW' }, // 3rd full weekend Feb
    { name: 'ARRL DX SSB', month: 2, weekend: 1, duration: 48, mode: 'SSB' }, // 1st full weekend Mar
    { name: 'CQ WPX SSB', month: 2, weekend: -1, duration: 48, mode: 'SSB' }, // Last full weekend Mar
    { name: 'CQ WPX CW', month: 4, weekend: -1, duration: 48, mode: 'CW' }, // Last full weekend May
    { name: 'IARU HF Championship', month: 6, weekend: 2, duration: 24, mode: 'Mixed' }, // 2nd full weekend Jul
    { name: 'ARRL Field Day', month: 5, weekend: 4, duration: 27, mode: 'Mixed' }, // 4th full weekend Jun
    { name: 'ARRL Sweepstakes CW', month: 10, weekend: 1, duration: 24, mode: 'CW' }, // 1st full weekend Nov
    { name: 'ARRL Sweepstakes SSB', month: 10, weekend: 3, duration: 24, mode: 'SSB' }, // 3rd full weekend Nov
    { name: 'ARRL 10m Contest', month: 11, weekend: 2, duration: 48, mode: 'Mixed' }, // 2nd full weekend Dec
    { name: 'ARRL RTTY Roundup', month: 0, weekend: 1, duration: 24, mode: 'RTTY' }, // 1st full weekend Jan
    { name: 'NA QSO Party CW', month: 0, weekend: 2, duration: 12, mode: 'CW' },
    { name: 'NA QSO Party SSB', month: 0, weekend: 3, duration: 12, mode: 'SSB' },
    { name: 'CQ 160m CW', month: 0, weekend: -1, duration: 42, mode: 'CW' }, // Last full weekend Jan
    { name: 'CQ 160m SSB', month: 1, weekend: -1, duration: 42, mode: 'SSB' }, // Last full weekend Feb
    { name: 'CQ WW RTTY', month: 8, weekend: -1, duration: 48, mode: 'RTTY' },
    { name: 'JIDX CW', month: 3, weekend: 2, duration: 48, mode: 'CW' },
    { name: 'JIDX SSB', month: 10, weekend: 2, duration: 48, mode: 'SSB' },
    { name: 'ARRL VHF Contest', month: 0, weekend: 3, duration: 33, mode: 'Mixed' }, // 3rd weekend Jan
    { name: 'ARRL June VHF', month: 5, weekend: 2, duration: 33, mode: 'Mixed' }, // 2nd weekend Jun
    { name: 'ARRL Sept VHF', month: 8, weekend: 2, duration: 33, mode: 'Mixed' }, // 2nd weekend Sep
    { name: 'Winter Field Day', month: 0, weekend: -1, duration: 24, mode: 'Mixed' }, // Last weekend Jan
    { name: 'CQWW WPX RTTY', month: 1, weekend: 2, duration: 48, mode: 'RTTY' }, // 2nd weekend Feb
    { name: 'Stew Perry Topband', month: 11, weekend: 4, duration: 14, mode: 'CW' }, // 4th weekend Dec
    { name: 'RAC Canada Day', month: 6, weekend: 1, duration: 24, mode: 'Mixed' }, // 1st weekend Jul
    { name: 'RAC Winter Contest', month: 11, weekend: -1, duration: 24, mode: 'Mixed' }, // Last weekend Dec
    { name: 'NAQP RTTY', month: 1, weekend: 4, duration: 12, mode: 'RTTY' }, // 4th weekend Feb
    { name: 'NAQP RTTY', month: 6, weekend: 3, duration: 12, mode: 'RTTY' }, // 3rd weekend Jul
  ];

  // Weekly mini-contests (CWT, SST, etc.) - dayOfWeek: 0=Sun, 1=Mon, ... 6=Sat
  const weeklyContests = [
    { name: 'CWT 1300z', dayOfWeek: 3, hour: 13, duration: 1, mode: 'CW' }, // Wednesday
    { name: 'CWT 1900z', dayOfWeek: 3, hour: 19, duration: 1, mode: 'CW' }, // Wednesday
    { name: 'CWT 0300z', dayOfWeek: 4, hour: 3, duration: 1, mode: 'CW' }, // Thursday
    { name: 'CWT 0700z', dayOfWeek: 4, hour: 7, duration: 1, mode: 'CW' }, // Thursday
    { name: 'NCCC Sprint', dayOfWeek: 5, hour: 3, minute: 30, duration: 0.5, mode: 'CW' }, // Friday
    { name: 'K1USN SST', dayOfWeek: 0, hour: 0, duration: 1, mode: 'CW' }, // Sunday 0000z (Sat evening US)
    { name: 'K1USN SST', dayOfWeek: 1, hour: 20, duration: 1, mode: 'CW' }, // Monday 2000z
    { name: 'ICWC MST', dayOfWeek: 1, hour: 13, duration: 1, mode: 'CW' }, // Monday 1300z
    { name: 'ICWC MST', dayOfWeek: 1, hour: 19, duration: 1, mode: 'CW' }, // Monday 1900z
    { name: 'ICWC MST', dayOfWeek: 2, hour: 3, duration: 1, mode: 'CW' }, // Tuesday 0300z
    { name: 'SKCC Sprint', dayOfWeek: 3, hour: 0, duration: 2, mode: 'CW' }, // Wednesday 0000z
    { name: 'QRP Fox Hunt', dayOfWeek: 3, hour: 2, duration: 1.5, mode: 'CW' }, // Wednesday 0200z
    { name: 'RTTY Weekday Sprint', dayOfWeek: 2, hour: 23, duration: 1, mode: 'RTTY' }, // Tuesday 2300z
  ];

  // Calculate next occurrences of weekly contests
  weeklyContests.forEach(contest => {
    const next = new Date(now);
    const currentDay = now.getUTCDay();
    let daysUntil = contest.dayOfWeek - currentDay;
    if (daysUntil < 0) daysUntil += 7;
    if (daysUntil === 0) {
      // Check if it's today but already passed
      const todayStart = new Date(now);
      todayStart.setUTCHours(contest.hour, contest.minute || 0, 0, 0);
      if (now > todayStart) daysUntil = 7;
    }
    
    next.setUTCDate(now.getUTCDate() + daysUntil);
    next.setUTCHours(contest.hour, contest.minute || 0, 0, 0);
    
    const endTime = new Date(next.getTime() + contest.duration * 3600000);
    
    contests.push({
      name: contest.name,
      start: next.toISOString(),
      end: endTime.toISOString(),
      mode: contest.mode,
      status: (now >= next && now <= endTime) ? 'active' : 'upcoming'
    });
  });

  // Calculate next occurrences of major contests
  const year = now.getFullYear();
  majorContests.forEach(contest => {
    for (let y = year; y <= year + 1; y++) {
      let startDate;
      
      if (contest.weekend === -1) {
        // Last weekend of month
        startDate = getLastWeekendOfMonth(y, contest.month);
      } else {
        // Nth weekend of month
        startDate = getNthWeekendOfMonth(y, contest.month, contest.weekend);
      }
      
      // Most contests start at 00:00 UTC Saturday
      startDate.setUTCHours(0, 0, 0, 0);
      const endDate = new Date(startDate.getTime() + contest.duration * 3600000);
      
      if (endDate > now) {
        const status = (now >= startDate && now <= endDate) ? 'active' : 'upcoming';
        contests.push({
          name: contest.name,
          start: startDate.toISOString(),
          end: endDate.toISOString(),
          mode: contest.mode,
          status: status
        });
        break; // Only add next occurrence
      }
    }
  });

  // Sort by start date
  contests.sort((a, b) => new Date(a.start) - new Date(b.start));
  
  return contests.slice(0, 15);
}

function getNthWeekendOfMonth(year, month, n) {
  const date = new Date(Date.UTC(year, month, 1, 0, 0, 0));
  let weekendCount = 0;
  
  while (date.getUTCMonth() === month) {
    if (date.getUTCDay() === 6) { // Saturday
      weekendCount++;
      if (weekendCount === n) return new Date(date);
    }
    date.setUTCDate(date.getUTCDate() + 1);
  }
  
  return date;
}

function getLastWeekendOfMonth(year, month) {
  // Start from last day of month and work backwards
  const date = new Date(Date.UTC(year, month + 1, 0)); // Last day of month
  
  while (date.getUTCDay() !== 6) { // Find last Saturday
    date.setUTCDate(date.getUTCDate() - 1);
  }
  
  return date;
}

// ============================================
// HEALTH CHECK
// ============================================

app.get('/api/health', (req, res) => {
  rolloverVisitorStats();
  const avg = visitorStats.history.length > 0
    ? Math.round(visitorStats.history.reduce((sum, d) => sum + d.uniqueVisitors, 0) / visitorStats.history.length)
    : visitorStats.uniqueIPs.size;
  res.json({
    status: 'ok',
    version: APP_VERSION,
    uptime: process.uptime(),
    timestamp: new Date().toISOString(),
    visitors: {
      today: {
        date: visitorStats.today,
        uniqueVisitors: visitorStats.uniqueIPs.size,
        totalRequests: visitorStats.totalRequests
      },
      allTime: {
        since: visitorStats.serverStarted,
        uniqueVisitors: visitorStats.allTimeVisitors,
        totalRequests: visitorStats.allTimeRequests
      },
      dailyAverage: avg,
      history: visitorStats.history
    }
  });
});

// ============================================
// CONFIGURATION ENDPOINT
// ============================================

// Serve station configuration to frontend
// This allows the frontend to get config from .env/config.json without exposing secrets
app.get('/api/config', (req, res) => {
  // Don't expose API keys/passwords - only public config
  res.json({
    version: APP_VERSION,
    
    // Station info (from .env or config.json)
    callsign: CONFIG.callsign,
    locator: CONFIG.gridSquare,
    latitude: CONFIG.latitude,
    longitude: CONFIG.longitude,
    
    // Display preferences
    units: CONFIG.units,
    timeFormat: CONFIG.timeFormat,
    theme: CONFIG.theme,
    layout: CONFIG.layout,
    
    // DX target
    dxLatitude: CONFIG.dxLatitude,
    dxLongitude: CONFIG.dxLongitude,
    
    // Feature toggles
    showSatellites: CONFIG.showSatellites,
    showPota: CONFIG.showPota,
    showDxPaths: CONFIG.showDxPaths,
    showContests: CONFIG.showContests,
    showDXpeditions: CONFIG.showDXpeditions,
    
    // DX Cluster settings
    spotRetentionMinutes: CONFIG.spotRetentionMinutes,
    dxClusterSource: CONFIG.dxClusterSource,
    
    // Whether config is incomplete (show setup wizard)
    configIncomplete: CONFIG.callsign === 'N0CALL' || !CONFIG.gridSquare,
    
    // Server timezone (from TZ env var or system)
    timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || '',
    
    // Feature availability
    features: {
      spaceWeather: true,
      pota: true,
      sota: true,
      dxCluster: true,
      satellites: true,
      contests: true,
      dxpeditions: true,
      wsjtxRelay: !!WSJTX_RELAY_KEY,
    },
    
    // Refresh intervals (ms)
    refreshIntervals: {
      spaceWeather: 300000,
      pota: 60000,
      sota: 60000,
      dxCluster: 30000
    }
  });
});

// ============================================
// WSJT-X UDP LISTENER
// ============================================
// Receives decoded messages from WSJT-X, JTDX, etc.
// Configure WSJT-X: Settings > Reporting > UDP Server > address/port
// Protocol: QDataStream binary format per NetworkMessage.hpp

const WSJTX_UDP_PORT = parseInt(process.env.WSJTX_UDP_PORT || '2237');
const WSJTX_ENABLED = process.env.WSJTX_ENABLED !== 'false'; // enabled by default
const WSJTX_RELAY_KEY = process.env.WSJTX_RELAY_KEY || ''; // auth key for remote relay agent
const WSJTX_MAX_DECODES = 200; // max decodes to keep in memory
const WSJTX_MAX_AGE = 30 * 60 * 1000; // 30 minutes

// WSJT-X protocol magic number
const WSJTX_MAGIC = 0xADBCCBDA;

// Message types
const WSJTX_MSG = {
  HEARTBEAT: 0,
  STATUS: 1,
  DECODE: 2,
  CLEAR: 3,
  REPLY: 4,
  QSO_LOGGED: 5,
  CLOSE: 6,
  REPLAY: 7,
  HALT_TX: 8,
  FREE_TEXT: 9,
  WSPR_DECODE: 10,
  LOCATION: 11,
  LOGGED_ADIF: 12,
  HIGHLIGHT_CALLSIGN: 13,
  SWITCH_CONFIG: 14,
  CONFIGURE: 15,
};

// In-memory store (for local UDP — no session)
const wsjtxState = {
  clients: {},    // clientId -> { status, lastSeen }
  decodes: [],    // decoded messages (ring buffer)
  qsos: [],       // logged QSOs
  wspr: [],       // WSPR decodes
  relay: null,    // not used for local UDP
};

// Per-session relay storage — each browser gets its own isolated data
const wsjtxRelaySessions = {};  // sessionId -> { clients, decodes, qsos, wspr, relay, lastAccess }
const WSJTX_SESSION_MAX_AGE = 60 * 60 * 1000; // 1 hour inactive expiry
const WSJTX_MAX_SESSIONS = 50; // prevent memory abuse

function getRelaySession(sessionId) {
  if (!sessionId) return null;
  if (!wsjtxRelaySessions[sessionId]) {
    // Check session limit
    if (Object.keys(wsjtxRelaySessions).length >= WSJTX_MAX_SESSIONS) {
      // Evict oldest session
      let oldestId = null, oldestTime = Infinity;
      for (const [id, s] of Object.entries(wsjtxRelaySessions)) {
        if (s.lastAccess < oldestTime) { oldestTime = s.lastAccess; oldestId = id; }
      }
      if (oldestId) delete wsjtxRelaySessions[oldestId];
    }
    wsjtxRelaySessions[sessionId] = {
      clients: {}, decodes: [], qsos: [], wspr: [],
      relay: null, lastAccess: Date.now()
    };
  }
  wsjtxRelaySessions[sessionId].lastAccess = Date.now();
  return wsjtxRelaySessions[sessionId];
}

// Cleanup expired sessions every 5 minutes
setInterval(() => {
  const now = Date.now();
  for (const [id, session] of Object.entries(wsjtxRelaySessions)) {
    if (now - session.lastAccess > WSJTX_SESSION_MAX_AGE) {
      delete wsjtxRelaySessions[id];
    }
  }
}, 5 * 60 * 1000);

/**
 * QDataStream binary reader for WSJT-X protocol
 * Reads big-endian Qt-serialized data types
 */
class WSJTXReader {
  constructor(buffer) {
    this.buf = buffer;
    this.offset = 0;
  }
  
  remaining() { return this.buf.length - this.offset; }
  
  readUInt8() {
    if (this.remaining() < 1) return null;
    const v = this.buf.readUInt8(this.offset);
    this.offset += 1;
    return v;
  }
  
  readInt32() {
    if (this.remaining() < 4) return null;
    const v = this.buf.readInt32BE(this.offset);
    this.offset += 4;
    return v;
  }
  
  readUInt32() {
    if (this.remaining() < 4) return null;
    const v = this.buf.readUInt32BE(this.offset);
    this.offset += 4;
    return v;
  }
  
  readUInt64() {
    if (this.remaining() < 8) return null;
    // JavaScript can't do 64-bit ints natively, use BigInt or approximate
    const high = this.buf.readUInt32BE(this.offset);
    const low = this.buf.readUInt32BE(this.offset + 4);
    this.offset += 8;
    return high * 0x100000000 + low;
  }
  
  readBool() {
    const v = this.readUInt8();
    return v === null ? null : v !== 0;
  }
  
  readDouble() {
    if (this.remaining() < 8) return null;
    const v = this.buf.readDoubleBE(this.offset);
    this.offset += 8;
    return v;
  }
  
  // Qt utf8 string: uint32 length + bytes (0xFFFFFFFF = null)
  readUtf8() {
    const len = this.readUInt32();
    if (len === null || len === 0xFFFFFFFF) return null;
    if (len === 0) return '';
    if (this.remaining() < len) return null;
    const str = this.buf.toString('utf8', this.offset, this.offset + len);
    this.offset += len;
    return str;
  }
  
  // QTime: uint32 milliseconds since midnight
  readQTime() {
    const ms = this.readUInt32();
    if (ms === null) return null;
    const h = Math.floor(ms / 3600000);
    const m = Math.floor((ms % 3600000) / 60000);
    const s = Math.floor((ms % 60000) / 1000);
    return { ms, hours: h, minutes: m, seconds: s, 
             formatted: `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}` };
  }
  
  // QDateTime: QDate (int64 julian day) + QTime (uint32 ms) + timespec
  readQDateTime() {
    const julianDay = this.readUInt64();
    const time = this.readQTime();
    const timeSpec = this.readUInt8();
    if (timeSpec === 2) this.readInt32(); // UTC offset
    return { julianDay, time, timeSpec };
  }
}

/**
 * Parse a WSJT-X UDP datagram
 */
function parseWSJTXMessage(buffer) {
  const reader = new WSJTXReader(buffer);
  
  // Header
  const magic = reader.readUInt32();
  if (magic !== WSJTX_MAGIC) return null;
  
  const schema = reader.readUInt32();
  const type = reader.readUInt32();
  const id = reader.readUtf8();
  
  if (type === null || id === null) return null;
  
  const msg = { type, id, schema, timestamp: Date.now() };
  
  try {
    switch (type) {
      case WSJTX_MSG.HEARTBEAT: {
        msg.maxSchema = reader.readUInt32();
        msg.version = reader.readUtf8();
        msg.revision = reader.readUtf8();
        break;
      }
      
      case WSJTX_MSG.STATUS: {
        msg.dialFrequency = reader.readUInt64();
        msg.mode = reader.readUtf8();
        msg.dxCall = reader.readUtf8();
        msg.report = reader.readUtf8();
        msg.txMode = reader.readUtf8();
        msg.txEnabled = reader.readBool();
        msg.transmitting = reader.readBool();
        msg.decoding = reader.readBool();
        msg.rxDF = reader.readUInt32();
        msg.txDF = reader.readUInt32();
        msg.deCall = reader.readUtf8();
        msg.deGrid = reader.readUtf8();
        msg.dxGrid = reader.readUtf8();
        msg.txWatchdog = reader.readBool();
        msg.subMode = reader.readUtf8();
        msg.fastMode = reader.readBool();
        msg.specialOp = reader.readUInt8();
        msg.freqTolerance = reader.readUInt32();
        msg.trPeriod = reader.readUInt32();
        msg.configName = reader.readUtf8();
        msg.txMessage = reader.readUtf8();
        break;
      }
      
      case WSJTX_MSG.DECODE: {
        msg.isNew = reader.readBool();
        msg.time = reader.readQTime();
        msg.snr = reader.readInt32();
        msg.deltaTime = reader.readDouble();
        msg.deltaFreq = reader.readUInt32();
        msg.mode = reader.readUtf8();
        msg.message = reader.readUtf8();
        msg.lowConfidence = reader.readBool();
        msg.offAir = reader.readBool();
        break;
      }
      
      case WSJTX_MSG.CLEAR: {
        msg.window = reader.readUInt8();
        break;
      }
      
      case WSJTX_MSG.QSO_LOGGED: {
        msg.dateTimeOff = reader.readQDateTime();
        msg.dxCall = reader.readUtf8();
        msg.dxGrid = reader.readUtf8();
        msg.txFrequency = reader.readUInt64();
        msg.mode = reader.readUtf8();
        msg.reportSent = reader.readUtf8();
        msg.reportRecv = reader.readUtf8();
        msg.txPower = reader.readUtf8();
        msg.comments = reader.readUtf8();
        msg.name = reader.readUtf8();
        msg.dateTimeOn = reader.readQDateTime();
        msg.operatorCall = reader.readUtf8();
        msg.myCall = reader.readUtf8();
        msg.myGrid = reader.readUtf8();
        msg.exchangeSent = reader.readUtf8();
        msg.exchangeRecv = reader.readUtf8();
        msg.adifPropMode = reader.readUtf8();
        break;
      }
      
      case WSJTX_MSG.WSPR_DECODE: {
        msg.isNew = reader.readBool();
        msg.time = reader.readQTime();
        msg.snr = reader.readInt32();
        msg.deltaTime = reader.readDouble();
        msg.frequency = reader.readUInt64();
        msg.drift = reader.readInt32();
        msg.callsign = reader.readUtf8();
        msg.grid = reader.readUtf8();
        msg.power = reader.readInt32();
        msg.offAir = reader.readBool();
        break;
      }
      
      case WSJTX_MSG.LOGGED_ADIF: {
        msg.adif = reader.readUtf8();
        break;
      }
      
      case WSJTX_MSG.CLOSE:
        break;
        
      default:
        // Unknown message type - ignore per protocol spec
        return null;
    }
  } catch (e) {
    // Malformed packet - ignore
    return null;
  }
  
  return msg;
}

/**
 * Parse decoded message text to extract callsigns and grid
 * FT8/FT4 messages follow a standard format
 */
function parseDecodeMessage(text) {
  if (!text) return {};
  const result = {};
  
  // CQ message: "CQ DX K1ABC FN42" or "CQ K1ABC FN42"
  const cqMatch = text.match(/^CQ\s+(?:(\S+)\s+)?([A-Z0-9/]+)\s+([A-Z]{2}\d{2}[a-z]{0,2})?/i);
  if (cqMatch) {
    result.type = 'CQ';
    result.modifier = cqMatch[1] && !cqMatch[1].match(/^[A-Z0-9/]{3,}$/) ? cqMatch[1] : null;
    result.caller = cqMatch[2] || cqMatch[1];
    result.grid = cqMatch[3] || null;
    return result;
  }
  
  // Standard QSO exchange: "K1ABC W2DEF +05" or "K1ABC W2DEF R-12" or "K1ABC W2DEF RR73"
  const qsoMatch = text.match(/^([A-Z0-9/]+)\s+([A-Z0-9/]+)\s+(.*)/i);
  if (qsoMatch) {
    result.type = 'QSO';
    result.dxCall = qsoMatch[1];
    result.deCall = qsoMatch[2];
    result.exchange = qsoMatch[3].trim();
    // Check for grid in exchange
    const gridMatch = result.exchange.match(/^([A-Z]{2}\d{2}[a-z]{0,2})$/i);
    if (gridMatch) result.grid = gridMatch[1];
    return result;
  }
  
  return result;
}

/**
 * Convert frequency in Hz to band name
 */
function freqToBand(freqHz) {
  const mhz = freqHz / 1000000;
  if (mhz >= 1.8 && mhz < 2.0) return '160m';
  if (mhz >= 3.5 && mhz < 4.0) return '80m';
  if (mhz >= 5.3 && mhz < 5.4) return '60m';
  if (mhz >= 7.0 && mhz < 7.3) return '40m';
  if (mhz >= 10.1 && mhz < 10.15) return '30m';
  if (mhz >= 14.0 && mhz < 14.35) return '20m';
  if (mhz >= 18.068 && mhz < 18.168) return '17m';
  if (mhz >= 21.0 && mhz < 21.45) return '15m';
  if (mhz >= 24.89 && mhz < 24.99) return '12m';
  if (mhz >= 28.0 && mhz < 29.7) return '10m';
  if (mhz >= 50.0 && mhz < 54.0) return '6m';
  if (mhz >= 144.0 && mhz < 148.0) return '2m';
  if (mhz >= 420.0 && mhz < 450.0) return '70cm';
  return `${mhz.toFixed(3)} MHz`;
}

/**
 * Handle incoming WSJT-X messages
 * @param {Object} msg - parsed WSJT-X message
 * @param {Object} state - state object to update (wsjtxState for local, session for relay)
 */
function handleWSJTXMessage(msg, state) {
  if (!msg) return;
  if (!state) state = wsjtxState;
  
  switch (msg.type) {
    case WSJTX_MSG.HEARTBEAT: {
      state.clients[msg.id] = {
        ...(state.clients[msg.id] || {}),
        version: msg.version,
        lastSeen: msg.timestamp
      };
      break;
    }
    
    case WSJTX_MSG.STATUS: {
      state.clients[msg.id] = {
        ...(state.clients[msg.id] || {}),
        lastSeen: msg.timestamp,
        dialFrequency: msg.dialFrequency,
        mode: msg.mode,
        dxCall: msg.dxCall,
        deCall: msg.deCall,
        deGrid: msg.deGrid,
        txEnabled: msg.txEnabled,
        transmitting: msg.transmitting,
        decoding: msg.decoding,
        subMode: msg.subMode,
        band: msg.dialFrequency ? freqToBand(msg.dialFrequency) : null,
        configName: msg.configName,
        txMessage: msg.txMessage,
      };
      break;
    }
    
    case WSJTX_MSG.DECODE: {
      const clientStatus = state.clients[msg.id] || {};
      const parsed = parseDecodeMessage(msg.message);
      
      const decode = {
        id: `${msg.id}-${msg.timestamp}-${msg.deltaFreq}`,
        clientId: msg.id,
        isNew: msg.isNew,
        time: msg.time?.formatted || '',
        timeMs: msg.time?.ms || 0,
        snr: msg.snr,
        dt: msg.deltaTime ? msg.deltaTime.toFixed(1) : '0.0',
        freq: msg.deltaFreq,
        mode: msg.mode || clientStatus.mode || '',
        message: msg.message,
        lowConfidence: msg.lowConfidence,
        offAir: msg.offAir,
        dialFrequency: clientStatus.dialFrequency || 0,
        band: clientStatus.band || '',
        ...parsed,
        timestamp: msg.timestamp,
      };
      
      // Resolve grid to lat/lon for map plotting
      if (parsed.grid) {
        const coords = gridToLatLon(parsed.grid);
        if (coords) {
          decode.lat = coords.latitude;
          decode.lon = coords.longitude;
        }
      }
      
      // Only keep new decodes (not replays)
      if (msg.isNew) {
        state.decodes.push(decode);
        
        // Trim old decodes
        const cutoff = Date.now() - WSJTX_MAX_AGE;
        while (state.decodes.length > WSJTX_MAX_DECODES || 
               (state.decodes.length > 0 && state.decodes[0].timestamp < cutoff)) {
          state.decodes.shift();
        }
      }
      break;
    }
    
    case WSJTX_MSG.CLEAR: {
      // WSJT-X cleared its band activity - optionally clear our decodes for this client
      state.decodes = state.decodes.filter(d => d.clientId !== msg.id);
      break;
    }
    
    case WSJTX_MSG.QSO_LOGGED: {
      const clientStatus = state.clients[msg.id] || {};
      const qso = {
        clientId: msg.id,
        dxCall: msg.dxCall,
        dxGrid: msg.dxGrid,
        frequency: msg.txFrequency,
        band: msg.txFrequency ? freqToBand(msg.txFrequency) : '',
        mode: msg.mode,
        reportSent: msg.reportSent,
        reportRecv: msg.reportRecv,
        myCall: msg.myCall || clientStatus.deCall,
        myGrid: msg.myGrid || clientStatus.deGrid,
        timestamp: msg.timestamp,
      };
      // Resolve grid to lat/lon
      if (msg.dxGrid) {
        const coords = gridToLatLon(msg.dxGrid);
        if (coords) { qso.lat = coords.latitude; qso.lon = coords.longitude; }
      }
      state.qsos.push(qso);
      // Keep last 50 QSOs
      if (state.qsos.length > 50) state.qsos.shift();
      break;
    }
    
    case WSJTX_MSG.WSPR_DECODE: {
      const wsprDecode = {
        clientId: msg.id,
        isNew: msg.isNew,
        time: msg.time?.formatted || '',
        snr: msg.snr,
        dt: msg.deltaTime ? msg.deltaTime.toFixed(1) : '0.0',
        frequency: msg.frequency,
        drift: msg.drift,
        callsign: msg.callsign,
        grid: msg.grid,
        power: msg.power,
        timestamp: msg.timestamp,
      };
      if (msg.isNew) {
        state.wspr.push(wsprDecode);
        if (state.wspr.length > 100) state.wspr.shift();
      }
      break;
    }
    
    case WSJTX_MSG.CLOSE: {
      delete state.clients[msg.id];
      break;
    }
  }
}

// Start UDP listener
let wsjtxSocket = null;
if (WSJTX_ENABLED) {
  try {
    wsjtxSocket = dgram.createSocket('udp4');
    
    wsjtxSocket.on('message', (buf, rinfo) => {
      const msg = parseWSJTXMessage(buf);
      if (msg) handleWSJTXMessage(msg);
    });
    
    wsjtxSocket.on('error', (err) => {
      logErrorOnce('WSJT-X UDP', err.message);
    });
    
    wsjtxSocket.on('listening', () => {
      const addr = wsjtxSocket.address();
      console.log(`[WSJT-X] UDP listener on ${addr.address}:${addr.port}`);
    });
    
    wsjtxSocket.bind(WSJTX_UDP_PORT, '0.0.0.0');
  } catch (e) {
    console.error(`[WSJT-X] Failed to start UDP listener: ${e.message}`);
  }
}

// API endpoint: get WSJT-X data
app.get('/api/wsjtx', (req, res) => {
  const sessionId = req.query.session || '';
  
  // Use session-specific state for relay mode, or global state for local UDP
  const state = (sessionId && WSJTX_RELAY_KEY) ? (wsjtxRelaySessions[sessionId] || { clients: {}, decodes: [], qsos: [], wspr: [], relay: null }) : wsjtxState;
  
  const clients = {};
  for (const [id, client] of Object.entries(state.clients)) {
    // Only include clients seen in last 5 minutes
    if (Date.now() - client.lastSeen < 5 * 60 * 1000) {
      clients[id] = client;
    }
  }
  
  // Relay is "connected" if this session's relay was seen in last 60 seconds
  const relayConnected = state.relay && (Date.now() - state.relay.lastSeen < 60000);
  
  res.json({
    enabled: WSJTX_ENABLED,
    port: WSJTX_UDP_PORT,
    relayEnabled: !!WSJTX_RELAY_KEY,
    relayConnected: !!relayConnected,
    clients,
    decodes: state.decodes.slice(-100), // last 100
    qsos: state.qsos.slice(-20), // last 20
    wspr: state.wspr.slice(-50), // last 50
    stats: {
      totalDecodes: state.decodes.length,
      totalQsos: state.qsos.length,
      totalWspr: state.wspr.length,
      activeClients: Object.keys(clients).length,
    }
  });
});

// API endpoint: get just decodes (lightweight polling)
app.get('/api/wsjtx/decodes', (req, res) => {
  const sessionId = req.query.session || '';
  const state = (sessionId && WSJTX_RELAY_KEY) ? (wsjtxRelaySessions[sessionId] || { decodes: [] }) : wsjtxState;
  
  const since = parseInt(req.query.since) || 0;
  const decodes = since 
    ? state.decodes.filter(d => d.timestamp > since)
    : state.decodes.slice(-100);
  
  res.json({ decodes, timestamp: Date.now() });
});

// API endpoint: relay — receive messages from remote relay agent
// The relay agent runs on the same machine as WSJT-X and forwards
// parsed messages over HTTPS for cloud-hosted instances.
app.post('/api/wsjtx/relay', (req, res) => {
  // Auth check
  if (!WSJTX_RELAY_KEY) {
    return res.status(503).json({ error: 'Relay not configured — set WSJTX_RELAY_KEY in .env' });
  }
  
  const authHeader = req.headers.authorization || '';
  const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
  if (token !== WSJTX_RELAY_KEY) {
    return res.status(401).json({ error: 'Invalid relay key' });
  }
  
  // Session ID is required for relay — isolates data per browser
  const sessionId = req.body.session || req.headers['x-relay-session'] || '';
  if (!sessionId) {
    return res.status(400).json({ error: 'Session ID required' });
  }
  
  const session = getRelaySession(sessionId);
  
  // Relay heartbeat — just registers the relay as alive for this session
  if (req.body && req.body.relay === true) {
    session.relay = {
      lastSeen: Date.now(),
      version: req.body.version || '1.0.0',
      port: req.body.port || 2237,
    };
    return res.json({ ok: true, timestamp: Date.now() });
  }
  
  // Regular message batch
  const { messages } = req.body || {};
  if (!Array.isArray(messages) || messages.length === 0) {
    return res.status(400).json({ error: 'No messages provided' });
  }
  
  // Update relay last seen on every batch too
  session.relay = { ...(session.relay || {}), lastSeen: Date.now() };
  
  // Rate limit: max 100 messages per request
  const batch = messages.slice(0, 100);
  let processed = 0;
  
  for (const msg of batch) {
    if (msg && typeof msg.type === 'number' && msg.id) {
      // Ensure timestamp is reasonable (within last 5 minutes or use server time)
      if (!msg.timestamp || Math.abs(Date.now() - msg.timestamp) > 5 * 60 * 1000) {
        msg.timestamp = Date.now();
      }
      handleWSJTXMessage(msg, session);
      processed++;
    }
  }
  
  res.json({ ok: true, processed, timestamp: Date.now() });
});

// API endpoint: serve raw relay.js (used by Windows .bat launcher)
app.get('/api/wsjtx/relay/agent.js', (req, res) => {
  const relayJsPath = path.join(__dirname, 'wsjtx-relay', 'relay.js');
  try {
    const content = fs.readFileSync(relayJsPath, 'utf8');
    res.setHeader('Content-Type', 'application/javascript');
    res.send(content);
  } catch (e) {
    res.status(500).json({ error: 'relay.js not found on server' });
  }
});

// API endpoint: download pre-configured relay agent script
// Embeds relay.js + server URL + relay key into a one-file launcher
app.get('/api/wsjtx/relay/download/:platform', (req, res) => {
  if (!WSJTX_RELAY_KEY) {
    return res.status(503).json({ error: 'Relay not configured — set WSJTX_RELAY_KEY in .env' });
  }
  
  const platform = req.params.platform; // 'linux', 'mac', or 'windows'
  const relayJsPath = path.join(__dirname, 'wsjtx-relay', 'relay.js');
  
  let relayJs;
  try {
    relayJs = fs.readFileSync(relayJsPath, 'utf8');
  } catch (e) {
    return res.status(500).json({ error: 'relay.js not found on server' });
  }
  
  // Detect server URL from request
  const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
  const host = req.headers['x-forwarded-host'] || req.headers.host;
  const serverURL = proto + '://' + host;
  
  // Session ID from query param — ties this relay to the downloading browser
  const sessionId = req.query.session || '';
  if (!sessionId) {
    return res.status(400).json({ error: 'Session ID required — download from the OpenHamClock dashboard' });
  }
  
  if (platform === 'linux' || platform === 'mac') {
    // Build bash script with relay.js embedded as heredoc
    const lines = [
      '#!/bin/bash',
      '# OpenHamClock WSJT-X Relay — Auto-configured',
      '# Generated by ' + serverURL,
      '#',
      '# Usage:  bash ' + (platform === 'mac' ? 'start-relay.command' : 'start-relay.sh'),
      '# Stop:   Ctrl+C',
      '# Requires: Node.js 14+ (https://nodejs.org)',
      '#',
      '# In WSJT-X: Settings > Reporting > UDP Server',
      '#   Address: 127.0.0.1   Port: 2237',
      '',
      'set -e',
      '',
      '# Check for Node.js',
      'if ! command -v node &> /dev/null; then',
      '    echo ""',
      '    echo "Node.js is not installed."',
      '    echo "Install from https://nodejs.org (LTS recommended)"',
      '    echo ""',
      '    echo "Quick install:"',
      '    echo "  Ubuntu/Debian: sudo apt install nodejs"',
      '    echo "  Mac (Homebrew): brew install node"',
      '    echo "  Fedora: sudo dnf install nodejs"',
      '    echo ""',
      '    exit 1',
      'fi',
      '',
      '# Write relay agent to temp file',
      'RELAY_FILE=$(mktemp /tmp/ohc-relay-XXXXXX.js)',
      'trap "rm -f $RELAY_FILE" EXIT',
      '',
      "cat > \"$RELAY_FILE\" << 'OPENHAMCLOCK_RELAY_EOF'",
      relayJs,
      'OPENHAMCLOCK_RELAY_EOF',
      '',
      '# Run relay',
      'exec node "$RELAY_FILE" \\',
      '  --url "' + serverURL + '" \\',
      '  --key "' + WSJTX_RELAY_KEY + '" \\',
      '  --session "' + sessionId + '"',
    ];
    
    const script = lines.join('\n') + '\n';
    const filename = platform === 'mac' ? 'start-relay.command' : 'start-relay.sh';
    res.setHeader('Content-Type', 'application/x-sh');
    res.setHeader('Content-Disposition', 'attachment; filename="' + filename + '"');
    return res.send(script);
    
  } else if (platform === 'windows') {
    // .bat that auto-downloads portable Node.js if needed, then runs relay
    // No install, no admin, no PowerShell execution policy issues
    const NODE_VERSION = 'v22.13.1'; // LTS
    const NODE_ZIP = 'node-' + NODE_VERSION + '-win-x64.zip';
    const NODE_DIR = 'node-' + NODE_VERSION + '-win-x64';
    const NODE_URL = 'https://nodejs.org/dist/' + NODE_VERSION + '/' + NODE_ZIP;
    
    const batLines = [
      '@echo off',
      'setlocal',
      'title OpenHamClock WSJT-X Relay',
      'echo.',
      'echo  =========================================',
      'echo   OpenHamClock WSJT-X Relay Agent v1.0',
      'echo  =========================================',
      'echo.',
      '',
      ':: Check for Node.js (system-installed or portable)',
      'set "NODE_EXE=node"',
      'set "PORTABLE_DIR=%TEMP%\\ohc-node"',
      '',
      'where node >nul 2>nul',
      'if not errorlevel 1 (',
      '    for /f "tokens=*" %%i in (\'node -v\') do echo   Found Node.js %%i',
      '    goto :have_node',
      ')',
      '',
      ':: Check for previously downloaded portable Node.js',
      'if exist "%PORTABLE_DIR%\\' + NODE_DIR + '\\node.exe" (',
      '    set "NODE_EXE=%PORTABLE_DIR%\\' + NODE_DIR + '\\node.exe"',
      '    echo   Found portable Node.js',
      '    goto :have_node',
      ')',
      '',
      ':: Download portable Node.js',
      'echo   Node.js not found. Downloading portable version...',
      'echo   (This is a one-time ~30MB download^)',
      'echo.',
      '',
      'if not exist "%PORTABLE_DIR%" mkdir "%PORTABLE_DIR%"',
      '',
      'powershell -Command "try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri \'' + NODE_URL + '\' -OutFile \'%PORTABLE_DIR%\\' + NODE_ZIP + '\' } catch { Write-Host $_.Exception.Message; exit 1 }"',
      'if errorlevel 1 (',
      '    echo.',
      '    echo   Failed to download Node.js!',
      '    echo   Check your internet connection and try again.',
      '    echo.',
      '    pause',
      '    exit /b 1',
      ')',
      '',
      'echo   Extracting...',
      'powershell -Command "Expand-Archive -Path \'%PORTABLE_DIR%\\' + NODE_ZIP + '\' -DestinationPath \'%PORTABLE_DIR%\' -Force"',
      'if errorlevel 1 (',
      '    echo   Failed to extract Node.js!',
      '    echo.',
      '    pause',
      '    exit /b 1',
      ')',
      '',
      'del "%PORTABLE_DIR%\\' + NODE_ZIP + '" >nul 2>nul',
      'set "NODE_EXE=%PORTABLE_DIR%\\' + NODE_DIR + '\\node.exe"',
      'echo   Portable Node.js ready.',
      'echo.',
      '',
      ':have_node',
      'echo   Server: ' + serverURL,
      'echo.',
      '',
      ':: Download relay agent',
      'echo   Downloading relay agent...',
      'powershell -Command "try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri \'' + serverURL + '/api/wsjtx/relay/agent.js\' -OutFile \'%TEMP%\\ohc-relay.js\' } catch { Write-Host $_.Exception.Message; exit 1 }"',
      'if errorlevel 1 (',
      '    echo   Failed to download relay agent!',
      '    echo   Check your internet connection and try again.',
      '    echo.',
      '    pause',
      '    exit /b 1',
      ')',
      '',
      'echo   Relay agent ready.',
      'echo.',
      'echo   In WSJT-X: Settings ^> Reporting ^> UDP Server',
      'echo     Address: 127.0.0.1   Port: 2237',
      'echo.',
      'echo   Press Ctrl+C to stop',
      'echo.',
      '',
      ':: Run relay',
      '%NODE_EXE% "%TEMP%\\ohc-relay.js" --url "' + serverURL + '" --key "' + WSJTX_RELAY_KEY + '" --session "' + sessionId + '"',
      '',
      'echo.',
      'echo   Relay stopped.',
      'del "%TEMP%\\ohc-relay.js" >nul 2>nul',
      'echo.',
      'pause',
    ];
    
    const script = batLines.join('\r\n') + '\r\n';
    res.setHeader('Content-Type', 'application/x-msdos-program');
    res.setHeader('Content-Disposition', 'attachment; filename="start-relay.bat"');
    return res.send(script);
    
  } else {
    return res.status(400).json({ error: 'Invalid platform. Use: linux, mac, or windows' });
  }
});

// ============================================
// CATCH-ALL FOR SPA
// ============================================

app.get('*', (req, res) => {
  // Try dist first (built React app), fallback to public (monolithic)
  const distIndex = path.join(__dirname, 'dist', 'index.html');
  const publicIndex = path.join(__dirname, 'public', 'index.html');
  
  const indexPath = fs.existsSync(distIndex) ? distIndex : publicIndex;
  res.sendFile(indexPath);
});

// ============================================
// START SERVER
// ============================================

app.listen(PORT, '0.0.0.0', () => {
  console.log('');
  console.log('╔═══════════════════════════════════════════════════════╗');
  console.log('║                                                       ║');
  console.log('║   ██████╗ ██████╗ ███████╗███╗   ██╗                  ║');
  console.log('║  ██╔═══██╗██╔══██╗██╔════╝████╗  ██║                  ║');
  console.log('║  ██║   ██║██████╔╝█████╗  ██╔██╗ ██║                  ║');
  console.log('║  ██║   ██║██╔═══╝ ██╔══╝  ██║╚██╗██║                  ║');
  console.log('║  ╚██████╔╝██║     ███████╗██║ ╚████║                  ║');
  console.log('║   ╚═════╝ ╚═╝     ╚══════╝╚═╝  ╚═══╝                  ║');
  console.log('║                                                       ║');
  console.log('║  ██╗  ██╗ █████╗ ███╗   ███╗ ██████╗██╗      ██╗  ██╗ ║');
  console.log('║  ██║  ██║██╔══██╗████╗ ████║██╔════╝██║      ██║ ██╔╝ ║');
  console.log('║  ███████║███████║██╔████╔██║██║     ██║      █████╔╝  ║');
  console.log('║  ██╔══██║██╔══██║██║╚██╔╝██║██║     ██║      ██╔═██╗  ║');
  console.log('║  ██║  ██║██║  ██║██║ ╚═╝ ██║╚██████╗███████╗██║  ██╗ ║');
  console.log('║  ╚═╝  ╚═╝╚═╝  ╚═╝╚═╝     ╚═╝ ╚═════╝╚══════╝╚═╝  ╚═╝ ║');
  console.log('║                                                       ║');
  console.log('╚═══════════════════════════════════════════════════════╝');
  console.log('');
  const displayHost = HOST === '0.0.0.0' ? 'localhost' : HOST;
  console.log(`  🌐 OpenHamClock v${APP_VERSION}`);
  console.log(`  🌐 Server running at http://${displayHost}:${PORT}`);
  if (HOST === '0.0.0.0') {
    console.log(`  🔗 Network access: http://<your-ip>:${PORT}`);
  }
  console.log('  📡 API proxy enabled for NOAA, POTA, SOTA, DX Cluster');
  console.log(`  📋 Log level: ${LOG_LEVEL} (set LOG_LEVEL=debug for verbose)`);
  if (WSJTX_ENABLED) {
    console.log(`  🔊 WSJT-X UDP listener on port ${WSJTX_UDP_PORT}`);
  }
  if (WSJTX_RELAY_KEY) {
    console.log(`  🔁 WSJT-X relay endpoint enabled (POST /api/wsjtx/relay)`);
  }
  console.log('  🖥️  Open your browser to start using OpenHamClock');
  console.log('');
  if (CONFIG.callsign !== 'N0CALL') {
    console.log(`  📻 Station: ${CONFIG.callsign} @ ${CONFIG.gridSquare}`);
  } else {
    console.log('  ⚠️  Configure your station in .env file');
  }
  console.log('');
  console.log('  In memory of Elwood Downey, WB0OEW');
  console.log('  73 de OpenHamClock contributors');
  console.log('');
});

// Graceful shutdown
process.on('SIGTERM', () => {
  console.log('SIGTERM received, shutting down gracefully');
  process.exit(0);
});

process.on('SIGINT', () => {
  console.log('\nShutting down...');
  process.exit(0);
});
