#!/usr/bin/env python3
import json
import os
import shutil
import signal
import socket
import subprocess
import sys
import threading
import time
import webbrowser
from datetime import datetime
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from urllib.parse import urlparse
from urllib.request import Request, urlopen

BASE_DIR = Path(__file__).resolve().parent
CONFIG_PATH = BASE_DIR / 'platform_config.json'
TENANTS_DIR = BASE_DIR / 'tenants'
LOCATION_PROFILE_PATH = BASE_DIR / 'location_profile.json'
TENANT_OVERRIDE = (os.environ.get('NWALS_TENANT') or '').strip()
INSTANCE_LOCKED = bool(TENANT_OVERRIDE)
INSTANCE_ID = TENANT_OVERRIDE or 'legacy'
RUNTIME_DIR = BASE_DIR / 'runtime' / 'instances' / INSTANCE_ID if INSTANCE_LOCKED else BASE_DIR / 'runtime'
LOG_DIR = RUNTIME_DIR / 'logs'
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
LOG_DIR.mkdir(parents=True, exist_ok=True)
PID_FILE = RUNTIME_DIR / 'platform_launcher.pid'
STOP_EVENT = threading.Event()


def now_text():
    return datetime.now().strftime('%Y-%m-%d %H:%M:%S')


def load_json(path):
    with open(path, 'r', encoding='utf-8-sig') as f:
        return json.load(f)


def save_json(path, data):
    tmp = path.with_suffix(path.suffix + '.tmp')
    with open(tmp, 'w', encoding='utf-8') as f:
        json.dump(data, f, indent=2)
    tmp.replace(path)


def port_open(port, host='127.0.0.1', timeout=0.4):
    try:
        with socket.create_connection((host, int(port)), timeout=timeout):
            return True
    except OSError:
        return False


def http_ok(url, timeout=2):
    try:
        with urlopen(url, timeout=timeout) as r:
            return 200 <= r.status < 500
    except Exception:
        return False


def http_json(url, timeout=2):
    try:
        req = Request(url, headers={'Cache-Control': 'no-cache', 'User-Agent': 'NWALPlatformBuildCheck/1.0'})
        with urlopen(req, timeout=timeout) as r:
            return json.loads(r.read().decode('utf-8'))
    except Exception:
        return None


def terminate_windows_port_owner(port):
    """Terminate the listener only after it has identified as a stale NWAL renderer."""
    if os.name != 'nt':
        return False
    script = (
        f"$pids=Get-NetTCPConnection -LocalPort {int(port)} -State Listen -ErrorAction SilentlyContinue "
        "| Select-Object -ExpandProperty OwningProcess -Unique; "
        "if(-not $pids){exit 1}; "
        "foreach($ownerPid in $pids){taskkill /PID $ownerPid /T /F | Out-Null}; exit 0"
    )
    try:
        return subprocess.run(['powershell', '-NoProfile', '-Command', script],
                              stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
    except Exception:
        return False


def remote_http_ok(url, timeout=1.5):
    if not url:
        return False
    try:
        req = Request(url, headers={'User-Agent': 'LocalOperationsPlatform/0.0.3'})
        with urlopen(req, timeout=timeout) as r:
            return 200 <= r.status < 500
    except Exception:
        return False


def find_python():
    return sys.executable


def find_node():
    return shutil.which('node.exe') or shutil.which('node')


def process_exists_with_text(text, kind='python'):
    if os.name != 'nt':
        return False
    escaped = text.replace("'", "''")
    name_pattern = r'^node\.exe$' if kind == 'node' else r'^py(?:thon(?:w)?(?:3(?:\.\d+)?)?)?\.exe$'
    cmd = [
        'powershell', '-NoProfile', '-Command',
        # Only a real Python process running this script counts. PowerShell,
        # the launcher, log viewers, and stale command text cannot match.
        "$p=Get-CimInstance Win32_Process | Where-Object { "
        "$_.Name -match '" + name_pattern + "' -and "
        "$_.CommandLine -like '*" + escaped + "*' "
        "}; if($p){exit 0}else{exit 1}"
    ]
    try:
        return subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
    except Exception:
        return False


class ManagedService:
    def __init__(self, spec):
        self.spec = spec
        self.key = spec['key']
        self.name = spec['name']
        self.process = None
        self.mode = 'stopped'  # managed, external, stopped, failed, disabled
        self.status = 'Stopped'
        self.last_error = ''
        self.started_at = None
        self.restart_count = 0
        self.unhealthy_since = None
        self.log_path = LOG_DIR / f'{self.key}.log'
        self.log_handle = None

    def detect_external(self):
        detect = self.spec.get('detect_text')
        port = self.spec.get('port')
        if detect and not process_exists_with_text(detect, self.spec.get('kind', 'python')):
            return False
        # Parser services must prove that their live output is fresh. A stale
        # JSON file or a command-line false match is never "Running externally".
        if not self.output_fresh():
            return False
        if port and not port_open(port):
            return False
        return bool(detect or port)

    def output_fresh(self):
        health_url = self.spec.get('health_url')
        if health_url:
            epoch_field = self.spec.get('health_json_epoch_field')
            if epoch_field:
                payload = http_json(health_url)
                try:
                    value = float(payload.get(epoch_field) or 0)
                except Exception:
                    return False
                if time.time() - value >= self.spec.get('health_max_age', 90):
                    return False
            elif not http_ok(health_url):
                return False
        watch_file = self.spec.get('watch_file')
        if watch_file:
            p = Path(watch_file)
            if not p.exists() or time.time() - p.stat().st_mtime >= self.spec.get('watch_max_age', 180):
                return False
            epoch_field = self.spec.get('watch_json_epoch_field')
            if epoch_field:
                try:
                    value = float(load_json(p).get(epoch_field) or 0)
                except Exception:
                    return False
                if time.time() - value >= self.spec.get('watch_max_age', 180):
                    return False
        return True

    def ensure_dependencies(self):
        if self.spec.get('kind') != 'node':
            return True
        cwd = Path(self.spec['cwd'])
        if (cwd / 'node_modules').exists():
            return True
        npm = shutil.which('npm.cmd') or shutil.which('npm')
        if not npm:
            self.last_error = 'Node.js/npm not found'
            return False
        self.status = 'Installing Node dependencies'
        result = subprocess.run([npm, 'install'], cwd=str(cwd), capture_output=True, text=True)
        with open(self.log_path, 'a', encoding='utf-8', errors='replace') as f:
            f.write(result.stdout or '')
            f.write(result.stderr or '')
        if result.returncode != 0:
            self.last_error = 'npm install failed; see service log'
            return False
        return True

    def start(self):
        if not self.spec.get('enabled', True):
            self.mode = 'disabled'
            self.status = 'Disabled for this profile'
            return
        expected_version = self.spec.get('expected_incident_motion_version')
        port = self.spec.get('port')
        health_url = self.spec.get('health_url')
        if expected_version and port and port_open(port):
            identity = http_json(health_url) if health_url else None
            actual_version = identity.get('incident_motion_version') if isinstance(identity, dict) else None
            if actual_version == expected_version:
                self.mode = 'external'
                self.status = f'Running verified {expected_version} externally'
                return
            if actual_version:
                self.status = f'Stopping stale renderer {actual_version}'
                if not terminate_windows_port_owner(port):
                    self.mode = 'failed'
                    self.status = 'Stale renderer could not be stopped'
                    self.last_error = f'Port {port} reports {actual_version}; {expected_version} is required. Run STOP_PLATFORM.bat, then start again.'
                    return
                for _ in range(30):
                    if not port_open(port, timeout=0.1):
                        break
                    time.sleep(0.1)
                if port_open(port):
                    self.mode = 'failed'
                    self.status = 'Port still occupied by stale renderer'
                    self.last_error = f'Port {port} must be free before {expected_version} can start.'
                    return
            else:
                self.mode = 'failed'
                self.status = 'Unknown service occupies renderer port'
                self.last_error = f'Port {port} is in use but did not identify as an NWAL renderer. It was left untouched.'
                return
        if self.detect_external():
            self.mode = 'external'
            self.status = 'Running externally (live output verified)'
            return
        if port and port_open(port):
            self.mode = 'failed'
            self.status = 'Port occupied; live output is stale'
            self.last_error = f'Port {port} is open, but this service did not pass its live-data check.'
            return
        if not self.ensure_dependencies():
            self.mode = 'failed'
            self.status = 'Dependency setup failed'
            return
        cmd = self.spec['command']
        cwd = self.spec['cwd']
        self.log_handle = open(self.log_path, 'a', encoding='utf-8', errors='replace', buffering=1)
        self.log_handle.write(f'\n[{now_text()}] START {self.name}: {cmd}\n')
        creationflags = 0
        if os.name == 'nt':
            creationflags = subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP
        try:
            self.process = subprocess.Popen(
                cmd,
                cwd=str(cwd),
                stdout=self.log_handle,
                stderr=subprocess.STDOUT,
                stdin=subprocess.DEVNULL,
                creationflags=creationflags,
                env={**os.environ, **{str(k): str(v) for k, v in (self.spec.get('env') or {}).items()}},
            )
            self.mode = 'managed'
            self.status = 'Starting'
            self.started_at = time.time()
            self.unhealthy_since = None
        except Exception as exc:
            self.mode = 'failed'
            self.status = 'Failed to start'
            self.last_error = str(exc)

    def healthy(self):
        if self.mode == 'disabled':
            return True
        if self.mode == 'external':
            return self.detect_external()
        if self.mode != 'managed' or self.process is None or self.process.poll() is not None:
            return False
        return self.output_fresh()

    def check(self):
        if self.mode == 'disabled':
            return
        if self.mode == 'external':
            if self.detect_external():
                self.status = 'Running externally (live output verified)'
            else:
                self.status = 'External output stopped or stale; taking over'
                self.mode = 'stopped'
                self.restart_count += 1
                self.start()
            return
        if self.mode == 'managed':
            if self.process is None or self.process.poll() is not None:
                self.status = 'Exited; restarting'
                self.restart_count += 1
                time.sleep(1)
                self.start()
                return
            if self.healthy():
                self.status = 'Running'
                self.unhealthy_since = None
            else:
                # Give startup a generous grace period before declaring trouble.
                if self.started_at and time.time() - self.started_at < 30:
                    self.status = 'Starting'
                else:
                    if self.unhealthy_since is None:
                        self.unhealthy_since = time.time()
                    stale_for = time.time() - self.unhealthy_since
                    restart_after = float(self.spec.get('restart_unhealthy_after', 60))
                    if stale_for >= restart_after:
                        self.status = 'Live output stale; restarting'
                        self.restart_count += 1
                        self.stop()
                        time.sleep(1)
                        self.start()
                    else:
                        self.status = f'Process running; live output stale ({int(stale_for)}s)'

    def stop(self):
        if self.mode != 'managed' or not self.process:
            return
        try:
            if os.name == 'nt':
                subprocess.run(['taskkill', '/PID', str(self.process.pid), '/T', '/F'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            else:
                self.process.terminate()
        except Exception:
            pass
        self.process = None
        self.mode = 'stopped'
        self.status = 'Stopped'
        if self.log_handle:
            try:
                self.log_handle.close()
            except Exception:
                pass
            self.log_handle = None

    def as_dict(self):
        return {
            'key': self.key,
            'name': self.name,
            'mode': self.mode,
            'status': self.status,
            'healthy': self.healthy(),
            'restart_count': self.restart_count,
            'last_error': self.last_error,
            'log': str(self.log_path.relative_to(BASE_DIR)),
        }


class Platform:
    def __init__(self):
        self.config = load_json(CONFIG_PATH)
        self.tenant_id = TENANT_OVERRIDE or self.config.get('active_tenant', 'nw-al-scanner')
        self.tenant = self.load_tenant(self.tenant_id)
        self.apply_tenant_location_profile()
        self.services = {}
        self.lock = threading.RLock()
        self.started_at = time.time()

    def tenant_ids(self):
        return sorted(p.parent.name for p in TENANTS_DIR.glob('*/station.json'))

    def load_tenant(self, tenant_id):
        path = TENANTS_DIR / tenant_id / 'station.json'
        if not path.exists():
            raise FileNotFoundError(f'Tenant profile not found: {tenant_id}')
        return load_json(path)

    def apply_tenant_location_profile(self):
        """Materialize the selected tenant's location profile at the legacy root path.

        Broadcast V3 and Incident Command intentionally continue reading
        /location_profile.json so production HTML does not need a redesign.
        Tenant selection now controls which profile is copied there.
        """
        rel = self.tenant.get('location_profile')
        if not rel:
            return
        if INSTANCE_LOCKED:
            return
        src = (BASE_DIR / rel).resolve()
        try:
            src.relative_to(BASE_DIR.resolve())
        except ValueError:
            raise ValueError('Tenant location_profile must stay inside the platform folder')
        if not src.exists():
            raise FileNotFoundError(f'Tenant location profile not found: {src}')
        profile = load_json(src)
        tmp = LOCATION_PROFILE_PATH.with_suffix('.json.tmp')
        tmp.write_text(json.dumps(profile, indent=2) + '\n', encoding='utf-8')
        tmp.replace(LOCATION_PROFILE_PATH)

    def build_specs(self):
        service_cfg = self.tenant.get('services', {})
        ports = self.tenant.get('ports') or {}
        core_port = int(ports.get('core', 9000))
        peet_port = int(ports.get('peet', 5000))
        lightning_port = int(ports.get('lightning', 4177))
        radio_port = int(ports.get('radio_intelligence', 9021))
        py = find_python()
        node = find_node()
        provider = service_cfg.get('lightning_provider', 'spark').lower()
        profile_rel = self.tenant.get('location_profile') or 'location_profile.json'
        profile_path = (BASE_DIR / profile_rel).resolve()
        lightning_dir = RUNTIME_DIR / 'lightning'
        lightning_dir.mkdir(parents=True, exist_ok=True)
        spark_data = lightning_dir / 'spark_lightning.json'
        spark_status = lightning_dir / 'spark_lightning_status.json'
        calls_file = RUNTIME_DIR / 'calls.json'
        if not calls_file.exists():
            calls_file.write_text('[]\n', encoding='utf-8')
        common_env = {
            'NWALS_TENANT': self.tenant_id,
            'NWALS_RUNTIME_DIR': str(RUNTIME_DIR),
            'NWALS_LOCATION_PROFILE': str(profile_path),
        }
        return [
            {
                'key': 'core', 'name': 'Core API + Incident Renderer',
                'enabled': bool(service_cfg.get('core', True)),
                'kind': 'python', 'cwd': BASE_DIR,
                'command': [py, '-X', 'utf8', 'serve_v097_incident_video.py'],
                'env': {**common_env,
                        'NWALS_PORT': core_port,
                        'NWALS_CALLS_FILE': str(calls_file),
                        'NWALS_PEET_URL': f'http://127.0.0.1:{peet_port}/api/data/currentdata',
                        'NWALS_SPARK_DATA_FILE': str(spark_data),
                        'NWALS_SPARK_STATUS_FILE': str(spark_status)},
                'detect_text': 'serve_v097_incident_video.py', 'port': core_port,
                'health_url': f'http://127.0.0.1:{core_port}/api/render/status',
                'expected_incident_motion_version': 'v030'
            },
            {
                'key': 'peet', 'name': 'PEET / HazCam Weather Parser',
                'enabled': bool(service_cfg.get('peet', False)),
                'kind': 'python', 'cwd': BASE_DIR,
                'command': [py, '-X', 'utf8', 'peet_server_hazcams_wu.py'],
                'env': {**common_env, 'NWALS_PEET_PORT': peet_port},
                'detect_text': 'peet_server_hazcams_wu.py', 'port': peet_port,
                'health_url': f'http://127.0.0.1:{peet_port}/api/data/currentdata',
                'health_json_epoch_field': 'LastDataReadEpoch', 'health_max_age': 30,
                'restart_unhealthy_after': 120
            },
            {
                'key': 'spark', 'name': 'Spark Lightning Bridge',
                'enabled': provider in {'spark', 'hazcams'},
                'kind': 'python', 'cwd': BASE_DIR,
                'command': [py, '-X', 'utf8', 'spark_lightning_bridge_v003.py', '--output-dir', str(lightning_dir)],
                'env': common_env,
                'detect_text': 'spark_lightning_bridge_v003.py',
                'watch_file': spark_status, 'watch_max_age': 90,
                'watch_json_epoch_field': 'last_successful_write_epoch',
                'restart_unhealthy_after': 90
            },
            {
                'key': 'lightning_dashboard', 'name': 'Lightning / Radar Dashboard',
                'enabled': bool(service_cfg.get('lightning_dashboard', True)),
                'kind': 'node', 'cwd': BASE_DIR / 'modules' / 'lightning-radar-dashboard',
                'command': [node or 'node', 'server.js'],
                'env': {**common_env,
                        'PORT': lightning_port,
                        'SPARK_API_URL': f'http://127.0.0.1:{core_port}/api/lightning',
                        'SPARK_STATUS_URL': f'http://127.0.0.1:{core_port}/api/lightning/status',
                        'HISTORY_FILE': str(RUNTIME_DIR / 'lightning' / 'strike-history.json')},
                'detect_text': 'lightning-radar-dashboard', 'port': lightning_port,
                'health_url': f'http://127.0.0.1:{lightning_port}/health'
            },
            {
                'key': 'radio_intelligence', 'name': 'Radio Intelligence · Phase 3',
                'enabled': bool(service_cfg.get('radio_intelligence', False)),
                'kind': 'python', 'cwd': BASE_DIR,
                'command': [py, '-X', 'utf8', 'radio_intelligence_server.py'],
                'env': {**common_env,
                        'NWALS_RADIO_PORT': radio_port,
                        'NWALS_RADIO_CONFIG': str(BASE_DIR / 'tenants' / self.tenant_id / 'radio_intelligence.json')},
                'detect_text': 'radio_intelligence_server.py', 'port': radio_port,
                'health_url': f'http://127.0.0.1:{radio_port}/health'
            },
        ]

    def start_services(self):
        with self.lock:
            self.services = {}
            for spec in self.build_specs():
                svc = ManagedService(spec)
                self.services[svc.key] = svc
                svc.start()

    def stop_managed(self):
        with self.lock:
            for svc in self.services.values():
                svc.stop()

    def switch_tenant(self, tenant_id):
        if INSTANCE_LOCKED:
            raise ValueError('This launcher is locked to one tenant. Start the other tenant with its own START_*.bat file.')
        with self.lock:
            if tenant_id not in self.tenant_ids():
                raise ValueError('Unknown tenant')
            self.stop_managed()
            self.tenant_id = tenant_id
            self.tenant = self.load_tenant(tenant_id)
            self.apply_tenant_location_profile()
            self.config['active_tenant'] = tenant_id
            save_json(CONFIG_PATH, self.config)
            self.start_services()

    def monitor(self):
        while not STOP_EVENT.wait(float(self.config.get('service_check_seconds', 5))):
            with self.lock:
                for svc in self.services.values():
                    svc.check()

    def lightning_status(self):
        cfg = self.tenant.get('lightning') or {}
        query = str(cfg.get('query') or '')
        lightning_port = int((self.tenant.get('ports') or {}).get('lightning', 4177))
        local_base = (f'http://localhost:{lightning_port}' if INSTANCE_LOCKED else str(cfg.get('local_base_url') or f'http://localhost:{lightning_port}')).rstrip('/')
        remote_base = str(cfg.get('remote_base_url') or '').rstrip('/')
        local_health_url = local_base + '/health'
        remote_health_url = (remote_base + '/health') if remote_base else ''
        local_healthy = http_ok(local_health_url, timeout=1.2)
        remote_healthy = remote_http_ok(remote_health_url, timeout=1.5) if cfg.get('fallback_enabled', True) else False
        preferred = str(cfg.get('preferred') or 'local').lower()
        if preferred == 'remote' and remote_healthy:
            active_source, active_base = 'remote', remote_base
        elif local_healthy:
            active_source, active_base = 'local', local_base
        elif remote_healthy:
            active_source, active_base = 'remote-fallback', remote_base
        else:
            active_source, active_base = 'offline', local_base
        return {
            'preferred': preferred,
            'local_healthy': local_healthy,
            'remote_healthy': remote_healthy,
            'active_source': active_source,
            'active_url': active_base + query,
            'local_url': local_base + query,
            'remote_url': (remote_base + query) if remote_base else '',
            'local_base_url': local_base,
            'remote_base_url': remote_base,
        }

    def status(self):
        with self.lock:
            lightning = self.lightning_status()
            tenant_payload = dict(self.tenant)
            tenant_payload['resolved_links'] = dict(self.tenant.get('links') or {})
            ports = self.tenant.get('ports') or {}
            core_port = int(ports.get('core', 9000))
            base = f'http://localhost:{core_port}'
            peet_port = int(ports.get('peet', 5000))
            tenant_payload['resolved_links'].update({
                'broadcast': base + '/broadcast-v3.html',
                'studio': base + '/broadcast_studio.html',
                'program_output': base + '/program_output.html',
                'incident_command': base + '/incident_command.html',
                'broadcast_automation': base + '/broadcast_automation.html',
                'calls_admin': base + '/calls_admin.html',
                'lightning': lightning['active_url'],
                'wxpanel': f'http://localhost:{peet_port}/',
                'history_entry': f'http://localhost:{peet_port}/wxpanel_history_manager.html',
                'climate_entry': f'http://localhost:{peet_port}/wxpanel_climate_manager.html',
                'weather_source': f'http://localhost:{peet_port}/weather_source_manager.html',
            })
            return {
                'ok': True,
                'platform_name': self.config.get('platform_name', 'Local Operations Platform'),
                'tenant_id': self.tenant_id,
                'tenant': tenant_payload,
                'lightning': lightning,
                'tenants': ([self.tenant] if INSTANCE_LOCKED else [self.load_tenant(tid) for tid in self.tenant_ids()]),
                'instance_locked': INSTANCE_LOCKED,
                'runtime_dir': str(RUNTIME_DIR.relative_to(BASE_DIR)),
                'services': [svc.as_dict() for svc in self.services.values()],
                'uptime_seconds': int(time.time() - self.started_at),
                'timestamp': now_text(),
            }


PLATFORM = Platform()


DASHBOARD_HTML = r'''<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Local Operations Platform v033</title>
<style>
:root{--bg:#06101a;--panel:#0d1b29;--panel2:#112337;--line:#20384c;--text:#edf6ff;--muted:#8da7bb;--accent:#1da1f2;--good:#39d98a;--warn:#ffbd4a;--bad:#ff5d6c}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 80% 0,#12324a 0,transparent 32%),linear-gradient(145deg,#050b12,#081521 55%,#04090e);color:var(--text);font-family:Inter,Segoe UI,Arial,sans-serif;min-height:100vh}.shell{max-width:1500px;margin:auto;padding:28px}.top{display:flex;gap:18px;align-items:center;justify-content:space-between;margin-bottom:26px}.brand h1{margin:0;font-size:30px}.brand p{margin:6px 0 0;color:var(--muted)}select,button{background:#102438;border:1px solid var(--line);color:var(--text);padding:11px 14px;border-radius:10px;font-weight:700}.section-title{font-size:13px;letter-spacing:.18em;color:#74c8fa;text-transform:uppercase;margin:25px 0 12px}.apps{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.card{background:linear-gradient(160deg,rgba(17,35,55,.96),rgba(8,21,33,.96));border:1px solid var(--line);border-radius:16px;padding:18px;min-height:164px;box-shadow:0 18px 45px rgba(0,0,0,.24)}.card h3{font-size:20px;margin:9px 0}.card p{color:var(--muted);line-height:1.45;margin:0 0 18px}.launch{display:inline-block;text-decoration:none;color:white;background:var(--accent);font-weight:800;padding:10px 14px;border-radius:9px}.services{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.svc{background:rgba(13,27,41,.93);border:1px solid var(--line);border-radius:14px;padding:15px}.svchead{display:flex;gap:9px;align-items:center}.dot{width:10px;height:10px;border-radius:50%;background:var(--bad);box-shadow:0 0 12px currentColor}.dot.good{background:var(--good)}.dot.warn{background:var(--warn)}.status{font-weight:800;margin-top:11px}.mode{color:var(--muted);font-size:13px;margin-top:5px}.notice{margin-top:20px;border:1px solid rgba(255,189,74,.35);background:rgba(255,189,74,.08);color:#ffd990;padding:14px;border-radius:12px;display:none}.footer{color:var(--muted);font-size:12px;margin-top:24px}@media(max-width:1000px){.apps{grid-template-columns:repeat(2,1fr)}.services{grid-template-columns:repeat(2,1fr)}}@media(max-width:650px){.shell{padding:16px}.top{align-items:flex-start;flex-direction:column}.apps,.services{grid-template-columns:1fr}}
</style></head><body><div class="shell"><div class="top"><div class="brand"><h1 id="platformName">Local Operations Platform</h1><p id="stationName">Loading station profile...</p></div><div><select id="tenantSelect"></select></div></div><div class="section-title">Applications</div><div class="apps" id="apps"></div><div class="section-title">Services</div><div class="services" id="services"></div><div class="notice" id="notice"></div><div class="footer" id="footer"></div></div>
<script>
const appDefs=[['broadcast_automation','Broadcast Automation','Arm/manual-control SweepWX weather takeover and tune lightning/warning triggers.'],['radio_intelligence','Radio Intelligence','Monitor local SDRTrunk and VOX call recordings before transcription/automation is enabled.'],['broadcast','Automated Channel','Continuous WeatherScan-style broadcast output.'],['studio','Broadcast Studio','Manual live production and fullscreen graphics.'],['program_output','Program Output','Standalone 16:9 program window for OBS capture.'],['incident_command','Incident Command','Create calls, update TV, copy graphics, and render MP4 clips.'],['lightning','Lightning / Radar','Open the integrated lightning monitoring and radar workstation.'],['wxpanel','WxPanel','Open the full-screen local weather observation panel.'],['weather_source','Weather Source Manager','Choose PEET/Davis sources and manage temperature/humidity calibration.'],['history_entry','WxPanel History Setup','Enter prior monthly rainfall totals for station onboarding.'],['climate_entry','Climate Normals Setup','Enter NOAA daily/monthly normals for WxPanel departures and climate reporting.'],['calls_admin','Calls Admin','Stable call-entry and incident management interface.']];
function browserSafeUrl(url,key){if(!url)return url;try{const u=new URL(url,window.location.href);if((u.hostname==='localhost'||u.hostname==='127.0.0.1')&&window.location.hostname)u.hostname=window.location.hostname;return u.toString()}catch(e){return url}}
async function api(path,opts){const r=await fetch(path,opts);if(!r.ok)throw new Error(await r.text());return r.json()}
function esc(s){return String(s??'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}
async function refresh(){const d=await api('/api/status');document.documentElement.style.setProperty('--accent',d.tenant.branding?.accent||'#1da1f2');platformName.textContent=d.platform_name;stationName.textContent=d.tenant.display_name+' · '+d.tenant.description;tenantSelect.innerHTML=d.tenants.map(t=>`<option value="${esc(t.id)}" ${t.id===d.tenant_id?'selected':''}>${esc(t.display_name)}</option>`).join('');apps.innerHTML=appDefs.map(([key,title,desc])=>{const links=d.tenant.resolved_links||d.tenant.links||{};const url=browserSafeUrl(links[key],key);const lightningNote=key==='lightning'?`<div class="mode" style="margin:0 0 12px">Source: ${esc((d.lightning?.active_source||'offline').replace('-', ' ').toUpperCase())}${d.lightning?.local_healthy?' · Local ready':''}${d.lightning?.remote_healthy?' · Remote ready':''}</div>`:'';return `<article class="card"><div style="font-size:26px">${({broadcast:'📺',studio:'🎬',program_output:'🖥️',incident_command:'🚨',broadcast_automation:'🌩️',lightning:'⚡',wxpanel:'🌡️',weather_source:'⚙️',history_entry:'📚',climate_entry:'🌤️',radio_intelligence:'📻',calls_admin:'📟'})[key]}</div><h3>${title}</h3><p>${desc}</p>${lightningNote}${url?`<a class="launch" href="${esc(url)}" target="_blank">Launch</a>`:'<span class="mode">Not configured</span>'}</article>`}).join('');const physical=d.services.map(s=>`<div class="svc"><div class="svchead"><span class="dot ${s.healthy?'good':s.mode==='disabled'?'warn':''}"></span><b>${esc(s.name)}</b></div><div class="status">${esc(s.status)}</div><div class="mode">${esc(s.mode.toUpperCase())}${s.restart_count?' · '+s.restart_count+' restarts':''}</div></div>`).join('');const lightningHealth=`<div class="svc"><div class="svchead"><span class="dot ${d.lightning?.local_healthy?'good':'warn'}"></span><b>Local Lightning Endpoint</b></div><div class="status">${d.lightning?.local_healthy?'Available':'Starting / Offline'}</div><div class="mode">${esc(d.lightning?.local_base_url||'')}</div></div><div class="svc"><div class="svchead"><span class="dot ${d.lightning?.remote_healthy?'good':'warn'}"></span><b>Remote Lightning Fallback</b></div><div class="status">${d.lightning?.remote_healthy?'Available':'Unavailable / Not configured'}</div><div class="mode">${esc(d.lightning?.remote_base_url||'None')}</div></div>`;services.innerHTML=physical+lightningHealth;if(d.tenant.limitations){notice.style.display='block';notice.textContent=d.tenant.limitations}else notice.style.display='none';footer.textContent=`Updated ${d.timestamp} · Platform uptime ${Math.floor(d.uptime_seconds/60)} min · PEET and Spark are verified from live output timestamps; stale files never count as running.`}
tenantSelect.addEventListener('change',async()=>{tenantSelect.disabled=true;try{await api('/api/tenant',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({tenant_id:tenantSelect.value})});setTimeout(refresh,1000)}catch(e){alert(e.message)}finally{tenantSelect.disabled=false}});refresh();setInterval(refresh,3000);
</script></body></html>'''


class DashboardHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        path = urlparse(self.path).path
        if path in {'', '/'}:
            body = DASHBOARD_HTML.encode('utf-8')
            self.send_response(200)
            self.send_header('Content-Type', 'text/html; charset=utf-8')
            self.send_header('Content-Length', str(len(body)))
            self.end_headers()
            try:
                self.wfile.write(body)
            except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError):
                pass
            return
        if path == '/api/status':
            self.json_response(200, PLATFORM.status())
            return
        self.json_response(404, {'error': 'not found'})

    def do_POST(self):
        path = urlparse(self.path).path
        length = int(self.headers.get('Content-Length', '0'))
        try:
            data = json.loads(self.rfile.read(length) or b'{}')
        except Exception:
            self.json_response(400, {'error': 'invalid json'})
            return
        if path == '/api/tenant':
            try:
                PLATFORM.switch_tenant(str(data.get('tenant_id', '')))
                self.json_response(200, {'ok': True, 'tenant_id': PLATFORM.tenant_id})
            except Exception as exc:
                self.json_response(400, {'error': str(exc)})
            return
        self.json_response(404, {'error': 'not found'})

    def json_response(self, code, obj):
        body = json.dumps(obj).encode('utf-8')
        self.send_response(code)
        self.send_header('Content-Type', 'application/json')
        self.send_header('Cache-Control', 'no-store')
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        try:
            self.wfile.write(body)
        except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError):
            # Browsers may cancel the dashboard's polling request during refresh,
            # navigation, sleep, or source switching. This is harmless and should
            # not print a traceback or affect the platform.
            pass

    def log_message(self, *_args):
        return


def cleanup(*_args):
    if STOP_EVENT.is_set():
        return
    STOP_EVENT.set()
    PLATFORM.stop_managed()
    try:
        PID_FILE.unlink(missing_ok=True)
    except Exception:
        pass


def main():
    if PID_FILE.exists():
        try:
            old_pid = int(PID_FILE.read_text().strip())
            if os.name == 'nt':
                check = subprocess.run(['tasklist', '/FI', f'PID eq {old_pid}'], capture_output=True, text=True)
                if str(old_pid) in check.stdout:
                    print(f'Platform launcher is already running as PID {old_pid}.')
                    print(f'Open http://localhost:{((PLATFORM.tenant.get('ports') or {}).get('operations', PLATFORM.config.get('dashboard_port',9011)))}/')
                    return 1
        except Exception:
            pass
    PID_FILE.write_text(str(os.getpid()), encoding='utf-8')
    signal.signal(signal.SIGINT, cleanup)
    signal.signal(signal.SIGTERM, cleanup)
    PLATFORM.start_services()
    threading.Thread(target=PLATFORM.monitor, daemon=True).start()
    port = int((PLATFORM.tenant.get('ports') or {}).get('operations', PLATFORM.config.get('dashboard_port', 9011)) if INSTANCE_LOCKED else PLATFORM.config.get('dashboard_port', 9011))
    server = ThreadingHTTPServer(('0.0.0.0', port), DashboardHandler)
    print('=' * 68)
    print('LOCAL OPERATIONS PLATFORM v029')
    print('=' * 68)
    print(f'Profile   : {PLATFORM.tenant.get("display_name")}')
    print(f'Dashboard : http://localhost:{port}/')
    print('PEET and Spark are started here unless a verified live copy already exists.')
    print('Use the matching STOP_*.bat file to stop this instance and all services it launched.')
    print('=' * 68)
    if PLATFORM.config.get('open_dashboard_on_start', True):
        threading.Timer(1.5, lambda: webbrowser.open(f'http://localhost:{port}/')).start()
    try:
        server.serve_forever()
    finally:
        server.server_close()
        cleanup()
    return 0


if __name__ == '__main__':
    raise SystemExit(main())
