#!/usr/bin/env python3
"""
Sparrow DroneID — Main Entry Point

FAA Remote ID drone detection via Wi-Fi NAN/Beacon frame capture.
Serves a browser-based UI and REST API.
"""
import argparse
import json
import logging
import os
import signal
import ssl
import sys
import threading
from datetime import datetime, timedelta, timezone
from pathlib import Path
from time import sleep

log = logging.getLogger(__name__)

# Add project root to path so relative imports work from any CWD
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from backend.database import get_db
from backend.droneid_engine import DroneIDEngine, CaptureManager, check_prerequisites
from backend.gps_engine import GPSEngine
from backend.alert_engine import AlertEngine
from backend.cot_engine import CotEngine
from backend.elasticsearch_engine import ElasticsearchEngine, _parse_iso_utc
from backend.cert_manager import CertManager
from backend.wifi_ssid_scanner import WifiSsidScanner
from backend.supervisor import Supervisor
from backend.api_handler import (
    MultithreadHTTPServer, RequestHandler, set_engines,
)

__version__ = '1.0.0'


class SparrowDroneID:
    """Main application class managing lifecycle of all components."""

    def __init__(self, port: int = 8097, html_dir: str = 'frontend',
                 data_dir: str = None, interface: str = None):
        self.port = port
        self.html_dir = html_dir
        self.data_dir = data_dir or 'data'
        self.db_path = os.path.join(self.data_dir, 'sparrow_droneid.db')
        self.auto_interface = interface  # Auto-start monitoring on this interface

        self._shutdown_event = threading.Event()
        self._httpd = None
        self._maintenance_thread = None
        self._maintenance_last_tick: float = 0.0   # heartbeat for health endpoint

        # Engine references
        self.db = None
        self.gps_engine = None
        self.droneid_engine = None
        self.alert_engine = None
        self.cot_engine = None
        self.es_engine = None
        self.cert_manager = None
        self.wifi_ssid_scanner = None
        self.supervisor: Supervisor = None  # type: ignore[assignment]

    def _check_prerequisites(self):
        """Check root, tcpdump, iw. Exit on failure."""
        errors = check_prerequisites()
        if errors:
            for err in errors:
                print(f"ERROR: {err}")
            sys.exit(1)

    def _ensure_data_dir(self):
        """Create data directory if needed."""
        try:
            os.makedirs(self.data_dir, exist_ok=True)
        except OSError as e:
            print(f"ERROR: Unable to create data directory '{self.data_dir}': {e.strerror}")
            sys.exit(1)

    def _init_database(self):
        """Initialize database and read saved port if applicable."""
        self.db = get_db(self.db_path)

        # Use saved port if default was passed
        if self.port == 8097:
            saved_port = self.db.get_setting('port', '8097')
            try:
                self.port = int(saved_port)
            except (ValueError, TypeError):
                pass

        # Honour DB log_level setting (overrides env var after DB is open).
        # Only adjusts the numeric level on the already-configured root
        # logger — basicConfig is NOT called again.
        db_level_name = self.db.get_setting('log_level', '').strip().upper()
        if db_level_name:
            db_level = getattr(logging, db_level_name, None)
            if isinstance(db_level, int):
                logging.getLogger().setLevel(db_level)
                log.debug("Log level set to %s from database setting", db_level_name)

        self._seed_vendor_codes()
        self._seed_ssid_patterns()

        return self.db

    def _seed_vendor_codes(self):
        """Seed vendor codes from the bundled JSON file if not already in DB.

        Only writes to DB when the settings keys are absent or empty — existing
        user-customised data is never overwritten.
        """
        existing_serial = self.db.get_setting('vendor_serial_prefixes', '')
        existing_oui    = self.db.get_setting('vendor_mac_oui', '')

        if existing_serial and existing_oui:
            return  # Already seeded — nothing to do.

        seed_path = Path(__file__).parent.parent / 'data' / 'vendor_codes.json'
        if not seed_path.is_file():
            log.warning("app: vendor_codes.json not found at %s — skipping seed", seed_path)
            return

        try:
            with open(seed_path, 'r', encoding='utf-8') as fh:
                data = json.load(fh)
        except Exception as e:
            log.warning("app: failed to read vendor_codes.json: %s", e)
            return

        if not existing_serial:
            serial_prefixes = data.get('serial_prefixes', {})
            self.db.set_setting('vendor_serial_prefixes', json.dumps(serial_prefixes))
            log.info("app: seeded %d vendor serial prefixes from vendor_codes.json",
                     len(serial_prefixes))

        if not existing_oui:
            mac_oui = data.get('mac_oui', {})
            self.db.set_setting('vendor_mac_oui', json.dumps(mac_oui))
            log.info("app: seeded %d vendor MAC OUIs from vendor_codes.json", len(mac_oui))

    def _seed_ssid_patterns(self):
        """Seed SSID drone patterns from the bundled JSON file if not already in DB.

        Only writes to DB when the setting key is absent or empty — existing
        user-customised data is never overwritten.
        """
        existing = self.db.get_setting('wifi_ssid_patterns', '')
        if existing:
            return  # Already seeded

        seed_path = Path(__file__).parent.parent / 'data' / 'drone_ssid_patterns.json'
        if not seed_path.is_file():
            log.warning("app: drone_ssid_patterns.json not found at %s — skipping seed", seed_path)
            return

        try:
            with open(seed_path, 'r', encoding='utf-8') as fh:
                data = json.load(fh)
        except Exception as e:
            log.warning("app: failed to read drone_ssid_patterns.json: %s", e)
            return

        patterns = data.get('patterns', [])
        self.db.set_setting('wifi_ssid_patterns', json.dumps(patterns))
        log.info("app: seeded %d SSID drone patterns from drone_ssid_patterns.json",
                 len(patterns))

    def _init_engines(self):
        """Create and configure all engines."""
        db = self.db

        # Certificate manager
        self.cert_manager = CertManager(os.path.join(self.data_dir, 'certs'))

        # GPS engine
        self.gps_engine = GPSEngine()
        gps_mode = db.get_setting('gps_mode', 'none')
        self.gps_engine.configure(
            mode=gps_mode,
            static_lat=float(db.get_setting('gps_static_lat', '0.0')),
            static_lon=float(db.get_setting('gps_static_lon', '0.0')),
            static_alt=float(db.get_setting('gps_static_alt', '0.0')),
        )

        # DroneID engine
        self.droneid_engine = DroneIDEngine(db, self.gps_engine)

        # Alert engine
        self.alert_engine = AlertEngine(db, gps_engine=self.gps_engine)

        # CoT engine
        self.cot_engine = CotEngine()
        cot_enabled = db.get_setting('cot_enabled', 'false').lower() == 'true'
        if cot_enabled:
            self.cot_engine.configure(
                enabled=True,
                address=db.get_setting('cot_address', '239.2.3.1'),
                port=int(db.get_setting('cot_port', '6969')),
            )

        # Elasticsearch engine
        self.es_engine = ElasticsearchEngine()
        es_enabled = db.get_setting('es_enabled', 'false').lower() == 'true'
        if es_enabled:
            self.es_engine.configure(
                enabled=True,
                backend_type=db.get_setting('es_backend_type', 'elasticsearch'),
                url=db.get_setting('es_url', ''),
                auth_method=db.get_setting('es_auth_method', 'none'),
                username=db.get_setting('es_username', ''),
                password=db.get_setting('es_password', ''),
                api_key=db.get_setting('es_api_key', ''),
                verify_tls=db.get_setting('es_verify_tls', 'true').lower() == 'true',
                agent_name=db.get_setting('es_agent_name', ''),
                index_prefix=db.get_setting('es_index_prefix', 'sparrow-droneid'),
                shards=int(db.get_setting('es_shards', '2')),
                replicas=int(db.get_setting('es_replicas', '0')),
                ilm_policy=db.get_setting('es_ilm_policy', ''),
                bulk_size=int(db.get_setting('es_bulk_size', '100')),
                flush_interval=int(db.get_setting('es_flush_interval', '5')),
            )

        # Wire detection callback: alert engine + CoT engine + Elasticsearch engine
        def on_detection(device):
            self.alert_engine.evaluate(device)
            if self.cot_engine.enabled:
                self.cot_engine.send_event(device)
            if self.es_engine and self.es_engine._enabled:
                rx_lat, rx_lon, rx_alt = self.gps_engine.get_receiver_position()
                key = device.get_key()
                rssi_hist = self.droneid_engine._rssi_history.get(key, [])
                from backend.models import rssi_trend as calc_rssi_trend
                trend = calc_rssi_trend(rssi_hist)
                vendor = self.alert_engine.resolve_vendor(
                    serial=device.serial_number,
                    mac=device.mac_address,
                    protocol=device.protocol,
                )
                first = _parse_iso_utc(device.first_seen)
                last = _parse_iso_utc(device.last_seen)
                time_in_area_s = max(0, int((last - first).total_seconds()))
                self.es_engine.add_detection(
                    device, rx_lat, rx_lon, rx_alt,
                    rssi_trend_value=trend,
                    vendor=vendor,
                    time_in_area_s=time_in_area_s,
                )

        self.droneid_engine.on_detection = on_detection

        # Wire alert callback for Elasticsearch alert indexing
        def on_alert(alert_event, device):
            if self.es_engine and self.es_engine._enabled:
                rx_lat, rx_lon, rx_alt = self.gps_engine.get_receiver_position()
                self.es_engine.add_alert(
                    alert_event, device, rx_lat, rx_lon, rx_alt)

        self.alert_engine.on_alert = on_alert

        # Heartbeat callback for ES engine
        def get_heartbeat_data():
            rx_lat, rx_lon, rx_alt = self.gps_engine.get_receiver_position()
            status = self.droneid_engine.get_status()
            active = self.droneid_engine.get_active_drones()
            return {
                "receiver_lat": rx_lat,
                "receiver_lon": rx_lon,
                "receiver_alt": rx_alt,
                "active_drones": len(active),
                "monitoring": status.get("monitoring", False),
                "interface": status.get("interface", ""),
                "uptime_s": status.get("duration_seconds", 0),
                "frame_count": status.get("frame_count", 0),
                "gps_fix": self.gps_engine.fix,
            }

        self.es_engine.get_heartbeat_data = get_heartbeat_data

        # WiFi SSID scanner — needs to go through _track_device so the
        # drone appears in the active drones dict, DB, and API responses.
        def on_ssid_detection(device):
            self.droneid_engine._track_device(device)

        self.wifi_ssid_scanner = WifiSsidScanner(
            db=db,
            gps_engine=self.gps_engine,
            on_detection=on_ssid_detection,
        )
        wifi_enabled = db.get_setting('wifi_ssid_enabled', 'false').lower() == 'true'
        if wifi_enabled:
            self.wifi_ssid_scanner.configure(
                enabled=True,
                agent_url=db.get_setting('wifi_ssid_agent_url', 'http://127.0.0.1:8020'),
                poll_interval=int(db.get_setting('wifi_ssid_poll_interval', '20')),
                agent_interface=db.get_setting('wifi_ssid_agent_interface', '') or '',
            )

    def _setup_signal_handlers(self):
        """Graceful shutdown on Ctrl+C / SIGTERM."""
        def signal_handler(signum, frame):
            print("\nShutting down...")
            self._shutdown_event.set()
            if self._httpd:
                threading.Thread(target=self._httpd.shutdown, daemon=True).start()

        signal.signal(signal.SIGINT, signal_handler)
        signal.signal(signal.SIGTERM, signal_handler)

    def _start_maintenance_thread(self):
        """Periodic maintenance: retention purge, stale cleanup, signal-lost checks."""
        def maintenance_loop():
            while not self._shutdown_event.is_set():
                self._shutdown_event.wait(60)
                if self._shutdown_event.is_set():
                    break
                import time as _time
                self._maintenance_last_tick = _time.monotonic()
                try:
                    # Retention purge
                    retention_days = int(self.db.get_setting('retention_days', '90'))
                    if retention_days > 0:
                        self.db.run_retention_purge(retention_days)

                    # Emit any deferred new_drone alerts whose merge window has
                    # elapsed. Done on the timer (not only on frame arrival) so a
                    # single-packet detection still announces before it goes
                    # stale — otherwise only its signal_lost would ever fire.
                    self.alert_engine.flush_pending_new()

                    # Clean stale drones from memory, then forget their alert
                    # state so a genuine reappearance re-arms the new_drone alert.
                    evicted = self.droneid_engine.cleanup_stale(300)
                    self.alert_engine.forget_drones(evicted)

                    # Check for signal-lost alerts
                    with self.droneid_engine._lock:
                        snapshot = dict(self.droneid_engine._active_drones)
                    self.alert_engine.check_signal_lost(snapshot)
                except Exception as e:
                    log.warning("Maintenance error: %s", e)

        self._maintenance_thread = threading.Thread(
            target=maintenance_loop, daemon=True, name='maintenance'
        )
        self._maintenance_thread.start()

    def _auto_start_monitoring(self):
        """Auto-start monitoring if interface is configured.

        Resolution order:
        1. --interface CLI flag
        2. monitor_interface from DB settings
        3. Auto-detect: single monitor-capable interface on the system

        Retries up to 30 seconds if the interface isn't ready yet (common
        at boot when iwlwifi firmware is still loading).
        """
        interface = self.auto_interface
        if not interface:
            interface = self.db.get_setting('monitor_interface', '')
        if not interface:
            # Auto-detect: if exactly one monitor-capable interface, use it
            try:
                capable = [i for i in CaptureManager.get_interfaces()
                           if i.get('monitor_capable') and i.get('mode') != 'monitor']
                if len(capable) == 1:
                    interface = capable[0].get('base_name') or capable[0]['name']
            except Exception:
                pass
        if not interface:
            return

        # Wait for the interface to appear (iwlwifi firmware can take 60s+)
        sysfs_path = f"/sys/class/net/{interface}"
        wait_max = 90
        waited = 0
        if not os.path.exists(sysfs_path):
            print(f"  Monitor:  Waiting for {interface} to appear...")
            while waited < wait_max:
                sleep(5)
                waited += 5
                if os.path.exists(sysfs_path):
                    print(f"  Monitor:  {interface} appeared after {waited}s")
                    break
            else:
                print(f"  Monitor:  {interface} not found after {wait_max}s — skipping auto-start")
                return

        try:
            self.droneid_engine.start(interface)
            self.db.set_setting('monitor_interface', interface)
            print(f"  Monitor:  {interface} on channel 6")
        except Exception as e:
            print(f"  Monitor:  Failed to start on {interface}: {e}")

    def run(self):
        """Run the application."""
        # Prerequisites
        self._check_prerequisites()
        self._ensure_data_dir()

        # Database
        db = self._init_database()

        # Engines
        self._init_engines()

        # Signal handlers
        self._setup_signal_handlers()

        # Resolve html_dir
        if not os.path.isabs(self.html_dir):
            self.html_dir = os.path.join(
                os.path.dirname(os.path.abspath(__file__)), self.html_dir
            )

        # Build the Supervisor — knows how to get maintenance/flush ticks
        def _get_maintenance_tick() -> float:
            return self._maintenance_last_tick

        def _get_flush_tick() -> float:
            return getattr(self.es_engine, '_flush_last_tick', 0.0)

        self.supervisor = Supervisor(
            engine=self.droneid_engine,
            get_maintenance_tick=_get_maintenance_tick,
            get_flush_tick=_get_flush_tick,
        )

        # Wire engines into API handler (supervisor included so health endpoint can reach it)
        set_engines(
            droneid=self.droneid_engine,
            gps=self.gps_engine,
            alert=self.alert_engine,
            cot=self.cot_engine,
            db=db,
            data_dir=self.data_dir,
            html_dir=self.html_dir,
            cert_manager=self.cert_manager,
            wifi_ssid_scanner=self.wifi_ssid_scanner,
            es_engine=self.es_engine,
            supervisor=self.supervisor,
        )

        # Bind address
        bind_addr = db.get_setting('bind_address', '127.0.0.1')
        server_address = (bind_addr, self.port)

        try:
            self._httpd = MultithreadHTTPServer(server_address, RequestHandler)
        except OSError as e:
            print(f"ERROR: Unable to bind to {bind_addr}:{self.port}: {e.strerror}")
            sys.exit(1)

        # HTTPS
        https_enabled = db.get_setting('https_enabled', 'false').lower() == 'true'
        scheme = 'http'
        if https_enabled:
            cert_name = db.get_setting('https_cert_name', '')
            if cert_name:
                try:
                    cert_path, key_path = self.cert_manager.get_cert_path(cert_name)
                    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
                    ctx.load_cert_chain(cert_path, key_path)
                    self._httpd.socket = ctx.wrap_socket(self._httpd.socket, server_side=True)
                    scheme = 'https'
                except Exception as e:
                    print(f"WARN: HTTPS setup failed: {e}. Falling back to HTTP.")
            else:
                print("WARN: HTTPS enabled but https_cert_name is not set. Falling back to HTTP.")

        # Start maintenance thread
        self._start_maintenance_thread()

        # Auto-start monitoring
        self._auto_start_monitoring()

        # Start the Supervisor after monitoring is (potentially) running so its
        # first tick sees accurate engine state rather than a cold zero baseline.
        self.supervisor.start()

        # Banner
        ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        display_host = 'localhost' if bind_addr == '127.0.0.1' else bind_addr
        print(f"\n[{ts}] Sparrow DroneID v{__version__}")
        print(f"  URL:      {scheme}://{display_host}:{self.port}")
        print(f"  Database: {db.db_path}")
        gps_mode = db.get_setting('gps_mode', 'none')
        print(f"  GPS:      {gps_mode}")
        status = "Monitoring" if self.droneid_engine.monitoring else "Ready (not monitoring)"
        print(f"  Status:   {status}")
        print("\nPress Ctrl+C to stop\n")

        # Serve
        try:
            self._httpd.serve_forever()
        except Exception as e:
            if not self._shutdown_event.is_set():
                print(f"Server error: {e}")

        # Cleanup — stop supervisor before the engine so it doesn't try to
        # restart a capture that is intentionally being torn down.
        if self.supervisor:
            self.supervisor.stop()

        if self.droneid_engine.monitoring:
            print("Stopping monitor capture...")
            self.droneid_engine.stop()

        self.gps_engine.stop()
        self.cot_engine.stop()
        if self.es_engine:
            self.es_engine.stop()
        if self.wifi_ssid_scanner:
            self.wifi_ssid_scanner.stop()
        self._httpd.server_close()

        ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        print(f"[{ts}] Sparrow DroneID stopped.")


def _configure_logging():
    """Configure the root logger once, before any module logs anything.

    Level resolution: env SPARROW_DRONEID_LOG_LEVEL → default INFO.
    The DB log_level setting is applied after DB init via a root-logger
    level override in _init_database (if set).

    logging.basicConfig is idempotent — subsequent calls are no-ops if
    the root logger already has handlers (e.g. when running under a test
    harness that configures logging first).
    """
    level_name = os.environ.get('SPARROW_DRONEID_LOG_LEVEL', 'INFO').upper()
    level = getattr(logging, level_name, logging.INFO)
    logging.basicConfig(
        level=level,
        format='%(asctime)s %(levelname)s %(name)s: %(message)s',
        stream=sys.stdout,
    )


def main():
    # Configure logging before ANYTHING else so that all subsequent
    # logging.* calls in every module are captured from process start.
    _configure_logging()

    parser = argparse.ArgumentParser(
        description='Sparrow DroneID — FAA Remote ID drone detection',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  sudo %(prog)s                              # Start with defaults
  sudo %(prog)s --port 8080                  # Custom port
  sudo %(prog)s --interface wlan1            # Auto-start monitoring
  sudo %(prog)s --data /var/lib/droneid      # Custom data directory
        """
    )

    parser.add_argument(
        '--port', '-p', type=int, default=8097,
        help='HTTP server port (default: 8097 or from settings)',
    )
    parser.add_argument(
        '--html-dir', default='frontend',
        help='Directory for static frontend files (default: frontend)',
    )
    parser.add_argument(
        '--data', default=None,
        help='Data directory for database and tile cache (default: ./data)',
    )
    parser.add_argument(
        '--interface', '-i', default=None,
        help='WiFi interface to auto-start monitoring on (e.g., wlan0)',
    )

    args = parser.parse_args()

    app = SparrowDroneID(
        port=args.port,
        html_dir=args.html_dir,
        data_dir=args.data,
        interface=args.interface,
    )
    app.run()


if __name__ == '__main__':
    main()
