"""
SQLite Database Access Layer for Sparrow DroneID.

Thread-safe singleton with connection pooling.
Tables: detections, alerts, settings.
"""
import sqlite3
import threading
import os
from datetime import datetime, timedelta, timezone


def _utcnow_iso_z() -> str:
    return datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')
from typing import Optional, List, Dict, Tuple
from contextlib import contextmanager

from .models import DroneIDDevice, AlertEvent, AlertRule, DEFAULT_ALERT_RULES
import json


class Database:
    """Thread-safe SQLite database with WAL mode for concurrent read/write."""

    def __init__(self, db_path: str = "data/sparrow_droneid.db"):
        self.db_path = db_path
        self._local = threading.local()
        self._lock = threading.Lock()

        # Ensure data directory exists
        db_dir = os.path.dirname(db_path)
        if db_dir:
            os.makedirs(db_dir, exist_ok=True)

        self._init_schema()

    def _get_connection(self) -> sqlite3.Connection:
        """Get thread-local connection."""
        if not hasattr(self._local, 'connection') or self._local.connection is None:
            self._local.connection = sqlite3.connect(self.db_path, check_same_thread=False)
            self._local.connection.row_factory = sqlite3.Row
            self._local.connection.execute("PRAGMA journal_mode=WAL")
            self._local.connection.execute("PRAGMA foreign_keys = ON")
        return self._local.connection

    @contextmanager
    def get_cursor(self):
        """Context manager for database cursor with auto-commit."""
        conn = self._get_connection()
        cursor = conn.cursor()
        try:
            yield cursor
            conn.commit()
        except Exception:
            conn.rollback()
            raise

    def _init_schema(self):
        """Initialize database schema and default data."""
        with self.get_cursor() as cursor:
            # Detections table — one row per received ODID frame
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS detections (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    serial_number TEXT NOT NULL,
                    registration_id TEXT DEFAULT '',
                    id_type INTEGER DEFAULT 0,
                    ua_type INTEGER DEFAULT 0,
                    drone_lat REAL DEFAULT 0.0,
                    drone_lon REAL DEFAULT 0.0,
                    drone_alt_geo REAL DEFAULT 0.0,
                    drone_alt_baro REAL DEFAULT 0.0,
                    drone_height_agl REAL DEFAULT 0.0,
                    speed REAL DEFAULT 0.0,
                    direction REAL DEFAULT 0.0,
                    vertical_speed REAL DEFAULT 0.0,
                    operator_lat REAL DEFAULT 0.0,
                    operator_lon REAL DEFAULT 0.0,
                    operator_alt REAL DEFAULT 0.0,
                    operator_id TEXT DEFAULT '',
                    self_id_text TEXT DEFAULT '',
                    takeoff_lat REAL DEFAULT 0.0,
                    takeoff_lon REAL DEFAULT 0.0,
                    mac_address TEXT DEFAULT '',
                    rssi INTEGER DEFAULT 0,
                    protocol TEXT DEFAULT 'astm_nan',
                    receiver_lat REAL DEFAULT 0.0,
                    receiver_lon REAL DEFAULT 0.0,
                    receiver_alt REAL DEFAULT 0.0,
                    timestamp TEXT NOT NULL
                )
            """)

            # Alert log table
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS alerts (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    timestamp TEXT NOT NULL,
                    alert_type TEXT NOT NULL,
                    serial_number TEXT DEFAULT '',
                    detail TEXT DEFAULT '',
                    drone_lat REAL DEFAULT 0.0,
                    drone_lon REAL DEFAULT 0.0,
                    drone_height_agl REAL DEFAULT 0.0,
                    state TEXT NOT NULL DEFAULT 'ACTIVE',
                    acknowledged_by TEXT DEFAULT '',
                    acknowledged_at TEXT DEFAULT '',
                    resolved_at TEXT DEFAULT ''
                )
            """)

            # Settings table — key-value pairs
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS settings (
                    key TEXT PRIMARY KEY,
                    value TEXT
                )
            """)

            # Migrate existing DBs: add new alert columns if they don't exist yet.
            # This must run BEFORE the idx_alerts_state index creation below.
            # SQLite does not support ALTER TABLE ADD COLUMN IF NOT EXISTS, so use try/except.
            for col_def in (
                "ADD COLUMN state TEXT NOT NULL DEFAULT 'ACTIVE'",
                "ADD COLUMN acknowledged_by TEXT DEFAULT ''",
                "ADD COLUMN acknowledged_at TEXT DEFAULT ''",
                "ADD COLUMN resolved_at TEXT DEFAULT ''",
            ):
                try:
                    cursor.execute(f"ALTER TABLE alerts {col_def}")
                except Exception:
                    pass  # Column already exists

            # Migrate existing DBs: add takeoff columns (French RemoteID).
            for col_def in (
                "ADD COLUMN takeoff_lat REAL DEFAULT 0.0",
                "ADD COLUMN takeoff_lon REAL DEFAULT 0.0",
            ):
                try:
                    cursor.execute(f"ALTER TABLE detections {col_def}")
                except Exception:
                    pass  # Column already exists

            # Iteration 2: add geo columns to alerts (DEFAULT NULL so absent ≠ 0).
            for col_def in (
                "ADD COLUMN range_m REAL",
                "ADD COLUMN bearing_deg REAL",
                "ADD COLUMN operator_lat REAL",
                "ADD COLUMN operator_lon REAL",
                "ADD COLUMN operator_range_m REAL",
                "ADD COLUMN operator_bearing_deg REAL",
                "ADD COLUMN receiver_lat REAL",
                "ADD COLUMN receiver_lon REAL",
            ):
                try:
                    cursor.execute(f"ALTER TABLE alerts {col_def}")
                except Exception:
                    pass  # Column already exists

            # Disposition audit log — append-only; current value = latest row per key
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS disposition_events (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    drone_key TEXT NOT NULL,
                    disposition TEXT NOT NULL,
                    changed_at TEXT NOT NULL,
                    changed_by TEXT DEFAULT '',
                    notes TEXT DEFAULT ''
                )
            """)

            # Flags audit log — append-only; current value per (drone_key, flag_name)
            # = latest row; flag_name in {'military','law_enforcement'}; value 0/1.
            cursor.execute("""
                CREATE TABLE IF NOT EXISTS drone_flags (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    drone_key TEXT NOT NULL,
                    flag_name TEXT NOT NULL,
                    value INTEGER NOT NULL,
                    changed_at TEXT NOT NULL,
                    changed_by TEXT DEFAULT '',
                    notes TEXT DEFAULT ''
                )
            """)

            # Indexes for performance
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_detections_serial ON detections(serial_number)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_detections_timestamp ON detections(timestamp)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_detections_serial_ts ON detections(serial_number, timestamp)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_alerts_timestamp ON alerts(timestamp)")
            cursor.execute("CREATE INDEX IF NOT EXISTS idx_alerts_state ON alerts(state)")
            cursor.execute("""
                CREATE INDEX IF NOT EXISTS idx_disposition_events_key_time
                    ON disposition_events(drone_key, changed_at DESC)
            """)
            cursor.execute("""
                CREATE INDEX IF NOT EXISTS idx_drone_flags_key_name_time
                    ON drone_flags(drone_key, flag_name, changed_at DESC)
            """)

            self._init_defaults(cursor)

        # Backfill geo for historic alerts that predate iteration 2.
        # Runs after the with-cursor block commits so the backfill sees the
        # new columns.  Guarded internally so re-runs are no-ops.
        self._backfill_alert_geo()

    def _init_defaults(self, cursor):
        """Populate default settings."""
        defaults = {
            'port': '8097',
            'bind_address': '0.0.0.0',
            'https_enabled': 'false',
            'https_cert_name': '',
            'auth_token': '',
            'allowed_ips': '',
            'gps_mode': 'gpsd',
            'gps_static_lat': '0.0',
            'gps_static_lon': '0.0',
            'gps_static_alt': '0.0',
            'retention_days': '90',
            'cot_enabled': 'false',
            'cot_address': '239.2.3.1',
            'cot_port': '6969',
            'alert_audio_enabled': 'true',
            'alert_visual_enabled': 'true',
            'alert_script_enabled': 'false',
            'alert_script_path': '',
            'alert_rules': json.dumps([r.to_dict() for r in DEFAULT_ALERT_RULES]),
            'alert_slack_enabled': 'false',
            'alert_slack_webhook_url': '',
            'alert_slack_display_name': 'Sparrow DroneID',
            # API-based alert delivery (generic ECS-shaped POST to an
            # external alert ingest API).  Default off; existing installs
            # inherit these via INSERT OR IGNORE on next startup, leaving
            # the channel disabled until the operator configures it.
            'alert_api_enabled': 'false',
            'alert_api_base_url': '',
            'alert_api_domain': '',
            'alert_api_token': '',
            'alert_api_verify_tls': 'true',
            'tile_cache_enabled': 'true',
            'monitor_interface': '',
            'operator_name': '',
            'airport_geozone_radius_mi': '2.0',
            'vendor_serial_prefixes': '',
            'vendor_mac_oui': '',
            'vendor_codes_url': '',
            'wifi_ssid_enabled': 'false',
            'wifi_ssid_agent_url': 'http://127.0.0.1:8020',
            'wifi_ssid_agent_interface': '',  # empty = auto-discover first interface
            'wifi_ssid_poll_interval': '20',
            'wifi_ssid_patterns': '',   # seeded from drone_ssid_patterns.json on first startup
        }
        for key, value in defaults.items():
            cursor.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", (key, value))

    # ==================== Detection Operations ====================

    def insert_detection(self, device: DroneIDDevice,
                         receiver_lat: float = 0.0, receiver_lon: float = 0.0,
                         receiver_alt: float = 0.0) -> int:
        """Insert a detection record. Returns the row id."""
        with self.get_cursor() as cursor:
            cursor.execute("""
                INSERT INTO detections (
                    serial_number, registration_id, id_type, ua_type,
                    drone_lat, drone_lon, drone_alt_geo, drone_alt_baro, drone_height_agl,
                    speed, direction, vertical_speed,
                    operator_lat, operator_lon, operator_alt,
                    operator_id, self_id_text,
                    takeoff_lat, takeoff_lon,
                    mac_address, rssi, protocol,
                    receiver_lat, receiver_lon, receiver_alt,
                    timestamp
                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """, (
                device.serial_number, device.registration_id, device.id_type, device.ua_type,
                device.drone_lat, device.drone_lon, device.drone_alt_geo,
                device.drone_alt_baro, device.drone_height_agl,
                device.speed, device.direction, device.vertical_speed,
                device.operator_lat, device.operator_lon, device.operator_alt,
                device.operator_id, device.self_id_text,
                device.takeoff_lat, device.takeoff_lon,
                device.mac_address, device.rssi, device.protocol,
                receiver_lat, receiver_lon, receiver_alt,
                device.last_seen or _utcnow_iso_z(),
            ))
            return cursor.lastrowid or 0

    def get_active_drones(self, max_age_seconds: int = 180) -> List[Dict]:
        """Get the latest detection for each drone seen within max_age_seconds.

        Returns one row per unique serial_number with the most recent data.
        """
        cutoff = (datetime.now(timezone.utc) - timedelta(seconds=max_age_seconds)).isoformat().replace('+00:00', 'Z')
        with self.get_cursor() as cursor:
            cursor.execute("""
                SELECT d.*, fs.first_seen FROM detections d
                INNER JOIN (
                    SELECT serial_number, MAX(timestamp) as max_ts
                    FROM detections
                    WHERE timestamp > ?
                    GROUP BY serial_number
                ) latest ON d.serial_number = latest.serial_number AND d.timestamp = latest.max_ts
                INNER JOIN (
                    SELECT serial_number, MIN(timestamp) as first_seen
                    FROM detections
                    GROUP BY serial_number
                ) fs ON d.serial_number = fs.serial_number
                ORDER BY d.timestamp DESC
            """, (cutoff,))
            rows = [dict(row) for row in cursor.fetchall()]
            for row in rows:
                row['last_seen'] = row['timestamp']
            return rows

    def get_drone_by_serial(self, serial: str) -> Optional[Dict]:
        """Get the latest detection for a specific drone."""
        with self.get_cursor() as cursor:
            cursor.execute("""
                SELECT * FROM detections
                WHERE serial_number = ?
                ORDER BY timestamp DESC LIMIT 1
            """, (serial,))
            row = cursor.fetchone()
            if not row:
                return None
            result = dict(row)
            cursor.execute(
                "SELECT MIN(timestamp) as first_seen FROM detections WHERE serial_number = ?",
                (serial,)
            )
            fs = cursor.fetchone()
            result['first_seen'] = fs['first_seen'] if fs else result['timestamp']
            result['last_seen'] = result['timestamp']
            return result

    def get_drone_track(self, serial: str, minutes: int = 5) -> List[Dict]:
        """Get track points for a drone over the last N minutes."""
        cutoff = (datetime.now(timezone.utc) - timedelta(minutes=minutes)).isoformat().replace('+00:00', 'Z')
        with self.get_cursor() as cursor:
            cursor.execute("""
                SELECT drone_lat, drone_lon, drone_alt_geo, drone_height_agl,
                       speed, direction, rssi, timestamp
                FROM detections
                WHERE serial_number = ? AND timestamp > ?
                ORDER BY timestamp DESC
            """, (serial, cutoff))
            return [dict(row) for row in cursor.fetchall()]

    # ==================== History / Replay Operations ====================

    def get_history(self, from_ts: str, to_ts: str, serial: Optional[str] = None,
                    limit: int = 10000, offset: int = 0) -> Tuple[List[Dict], int]:
        """Get detection records for a time range. Returns (records, total_count)."""
        with self.get_cursor() as cursor:
            params = [from_ts, to_ts]
            where = "WHERE timestamp >= ? AND timestamp <= ?"
            if serial:
                where += " AND serial_number = ?"
                params.append(serial)

            # Total count
            cursor.execute(f"SELECT COUNT(*) as cnt FROM detections {where}", params)
            total = cursor.fetchone()['cnt']

            # Paginated results
            cursor.execute(f"""
                SELECT serial_number, ua_type, drone_lat, drone_lon, drone_height_agl,
                       speed, direction, operator_lat, operator_lon,
                       takeoff_lat, takeoff_lon,
                       rssi, protocol, receiver_lat, receiver_lon, timestamp
                FROM detections {where}
                ORDER BY timestamp ASC
                LIMIT ? OFFSET ?
            """, params + [limit, offset])
            records = [dict(row) for row in cursor.fetchall()]
            return records, total

    def get_history_serials(self, from_ts: str, to_ts: str) -> List[Dict]:
        """Get summary per drone serial for a time range."""
        with self.get_cursor() as cursor:
            cursor.execute("""
                SELECT serial_number, ua_type, protocol,
                       MIN(timestamp) as first_seen,
                       MAX(timestamp) as last_seen,
                       COUNT(*) as detection_count,
                       MAX(rssi) as max_rssi,
                       MAX(self_id_text) as self_id_text
                FROM detections
                WHERE timestamp >= ? AND timestamp <= ?
                GROUP BY serial_number
                ORDER BY last_seen DESC
            """, (from_ts, to_ts))
            rows = [dict(row) for row in cursor.fetchall()]
            from .models import UAType
            for row in rows:
                ua = row.get('ua_type', 0)
                row['ua_type_name'] = UAType(ua).display_name if 0 <= ua <= 15 else "Unknown"
            return rows

    def get_history_timeline(self, from_ts: str, to_ts: str,
                             bucket_seconds: int = 10) -> List[Dict]:
        """Get time-bucketed summary for replay timeline."""
        with self.get_cursor() as cursor:
            cursor.execute("""
                SELECT serial_number, timestamp
                FROM detections
                WHERE timestamp >= ? AND timestamp <= ?
                ORDER BY timestamp ASC
            """, (from_ts, to_ts))
            rows = cursor.fetchall()

        # Bucket in Python (SQLite datetime bucketing is awkward)
        if not rows:
            return []

        buckets = {}
        for row in rows:
            try:
                ts = datetime.fromisoformat(row['timestamp'].replace('Z', ''))
                # Round down to bucket boundary
                epoch = ts.timestamp()
                bucket_epoch = int(epoch // bucket_seconds) * bucket_seconds
                bucket_key = datetime.utcfromtimestamp(bucket_epoch).isoformat() + 'Z'
            except (ValueError, AttributeError):
                continue

            if bucket_key not in buckets:
                buckets[bucket_key] = set()
            buckets[bucket_key].add(row['serial_number'])

        return [
            {
                'timestamp': ts,
                'active_drones': len(serials),
                'serials': sorted(serials),
            }
            for ts, serials in sorted(buckets.items())
        ]

    # ==================== Alert Operations ====================

    def insert_alert(self, event: AlertEvent) -> int:
        """Insert an alert event. Returns the row id."""
        with self.get_cursor() as cursor:
            cursor.execute("""
                INSERT INTO alerts (timestamp, alert_type, serial_number, detail,
                                   drone_lat, drone_lon, drone_height_agl,
                                   range_m, bearing_deg,
                                   operator_lat, operator_lon,
                                   operator_range_m, operator_bearing_deg,
                                   receiver_lat, receiver_lon)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """, (
                event.timestamp or _utcnow_iso_z(),
                event.alert_type, event.serial_number, event.detail,
                event.drone_lat, event.drone_lon, event.drone_height_agl,
                event.range_m, event.bearing_deg,
                event.operator_lat, event.operator_lon,
                event.operator_range_m, event.operator_bearing_deg,
                event.receiver_lat, event.receiver_lon,
            ))
            return cursor.lastrowid or 0

    def _backfill_alert_geo(self) -> None:
        """One-time backfill of geo columns for alerts that predate iteration 2.

        Selects alerts where range_m IS NULL and serial_number is non-empty,
        finds the nearest detection, and writes receiver/drone/operator geo.
        Re-runs are no-ops (updated rows will no longer match IS NULL).
        Wrapped in try/except so any hiccup never blocks startup.
        """
        from .models import haversine, bearing  # avoid circular import at module level
        try:
            with self.get_cursor() as cursor:
                cursor.execute("""
                    SELECT id, serial_number, timestamp
                    FROM alerts
                    WHERE range_m IS NULL AND serial_number != ''
                """)
                rows = cursor.fetchall()

            for row in rows:
                try:
                    alert_id = row['id']
                    serial = row['serial_number']
                    ts = row['timestamp']
                    det = self.get_detection_nearest(serial, ts)
                    if not det:
                        continue
                    rx_lat = det.get('receiver_lat') or 0.0
                    rx_lon = det.get('receiver_lon') or 0.0
                    has_rx = rx_lat != 0.0 or rx_lon != 0.0
                    d_lat = det.get('drone_lat') or 0.0
                    d_lon = det.get('drone_lon') or 0.0
                    has_drone = d_lat != 0.0 or d_lon != 0.0
                    op_lat = det.get('operator_lat') or 0.0
                    op_lon = det.get('operator_lon') or 0.0
                    has_op = op_lat != 0.0 or op_lon != 0.0

                    range_m = bearing_deg = None
                    op_range = op_bearing = None
                    recv_lat = recv_lon = None

                    if has_rx:
                        recv_lat = rx_lat
                        recv_lon = rx_lon
                        if has_drone:
                            range_m = round(haversine(rx_lat, rx_lon, d_lat, d_lon), 1)
                            bearing_deg = round(bearing(rx_lat, rx_lon, d_lat, d_lon), 1)
                        if has_op:
                            op_range = round(haversine(rx_lat, rx_lon, op_lat, op_lon), 1)
                            op_bearing = round(bearing(rx_lat, rx_lon, op_lat, op_lon), 1)

                    with self.get_cursor() as ucursor:
                        ucursor.execute("""
                            UPDATE alerts SET
                                range_m = ?, bearing_deg = ?,
                                operator_lat = ?, operator_lon = ?,
                                operator_range_m = ?, operator_bearing_deg = ?,
                                receiver_lat = ?, receiver_lon = ?
                            WHERE id = ?
                        """, (
                            range_m, bearing_deg,
                            op_lat if has_op else None,
                            op_lon if has_op else None,
                            op_range, op_bearing,
                            recv_lat, recv_lon,
                            alert_id,
                        ))
                except Exception:
                    pass  # Individual alert backfill failure is non-fatal

        except Exception:
            pass  # Any startup-path hiccup must not block the service

    def get_alerts(self, from_ts: Optional[str] = None, to_ts: Optional[str] = None,
                   limit: int = 100, offset: int = 0,
                   state: Optional[str] = None) -> Tuple[List[Dict], int]:
        """Get alert log entries. Returns (alerts, total_count).

        Args:
            state: Optional filter — 'ACTIVE', 'ACKNOWLEDGED', or 'RESOLVED'.
                   None returns all states.
        """
        with self.get_cursor() as cursor:
            where_parts = []
            params = []
            if from_ts:
                where_parts.append("timestamp >= ?")
                params.append(from_ts)
            if to_ts:
                where_parts.append("timestamp <= ?")
                params.append(to_ts)
            if state:
                where_parts.append("state = ?")
                params.append(state)

            where = "WHERE " + " AND ".join(where_parts) if where_parts else ""

            cursor.execute(f"SELECT COUNT(*) as cnt FROM alerts {where}", params)
            total = cursor.fetchone()['cnt']

            cursor.execute(f"""
                SELECT * FROM alerts {where}
                ORDER BY timestamp DESC
                LIMIT ? OFFSET ?
            """, params + [limit, offset])
            alerts = [dict(row) for row in cursor.fetchall()]
            return alerts, total

    def acknowledge_alert(self, alert_id: int, operator: str = '') -> bool:
        """Set a single alert to ACKNOWLEDGED state. Returns True if a row was updated."""
        now = _utcnow_iso_z()
        with self.get_cursor() as cursor:
            cursor.execute("""
                UPDATE alerts
                SET state = 'ACKNOWLEDGED', acknowledged_by = ?, acknowledged_at = ?
                WHERE id = ? AND state = 'ACTIVE'
            """, (operator, now, alert_id))
            return cursor.rowcount > 0

    def acknowledge_all_active(self, operator: str = '') -> int:
        """Acknowledge all ACTIVE alerts. Returns the count of rows updated."""
        now = _utcnow_iso_z()
        with self.get_cursor() as cursor:
            cursor.execute("""
                UPDATE alerts
                SET state = 'ACKNOWLEDGED', acknowledged_by = ?, acknowledged_at = ?
                WHERE state = 'ACTIVE'
            """, (operator, now))
            return cursor.rowcount

    def resolve_alert(self, alert_id: int) -> bool:
        """Set an alert to RESOLVED state. Returns True if a row was updated."""
        now = _utcnow_iso_z()
        with self.get_cursor() as cursor:
            cursor.execute("""
                UPDATE alerts
                SET state = 'RESOLVED', resolved_at = ?
                WHERE id = ? AND state != 'RESOLVED'
            """, (now, alert_id))
            return cursor.rowcount > 0

    def delete_alert(self, alert_id: int) -> bool:
        """Permanently delete an alert by ID. Returns True if a row was deleted."""
        with self.get_cursor() as cursor:
            cursor.execute("DELETE FROM alerts WHERE id = ?", (alert_id,))
            return cursor.rowcount > 0

    def get_alert(self, alert_id: int) -> Optional[Dict]:
        """Fetch a single alert row by ID. Returns dict or None if not found."""
        with self.get_cursor() as cursor:
            cursor.execute("SELECT * FROM alerts WHERE id = ?", (alert_id,))
            row = cursor.fetchone()
            return dict(row) if row else None

    def get_detection_nearest(self, serial: str, ts: str) -> Optional[Dict]:
        """Find the detection for *serial* closest in time to *ts*.

        Uses SQLite's julianday() for sub-second resolution.  Returns the full
        row as a dict, or None if no detection exists for that serial.
        """
        with self.get_cursor() as cursor:
            cursor.execute("""
                SELECT * FROM detections
                WHERE serial_number = ?
                ORDER BY ABS(julianday(timestamp) - julianday(?))
                LIMIT 1
            """, (serial, ts))
            row = cursor.fetchone()
            return dict(row) if row else None

    # ==================== Settings Operations ====================

    def get_setting(self, key: str, default: Optional[str] = None) -> Optional[str]:
        """Get a setting value."""
        with self.get_cursor() as cursor:
            cursor.execute("SELECT value FROM settings WHERE key = ?", (key,))
            row = cursor.fetchone()
            return row['value'] if row else default

    def get_setting_str(self, key: str, default: str) -> str:
        """Get a setting value, guaranteed to return str (uses default if not found)."""
        value = self.get_setting(key, default)
        return value if value is not None else default

    def set_setting(self, key: str, value: str) -> bool:
        """Set a setting value."""
        with self.get_cursor() as cursor:
            cursor.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (key, value))
        return True

    def get_all_settings(self) -> Dict[str, str]:
        """Get all settings as a dict."""
        with self.get_cursor() as cursor:
            cursor.execute("SELECT key, value FROM settings")
            return {row['key']: row['value'] for row in cursor.fetchall()}

    # ==================== Data Maintenance ====================

    def purge_detections(self, before_ts: str) -> int:
        """Delete detection records older than the given timestamp."""
        with self.get_cursor() as cursor:
            cursor.execute("DELETE FROM detections WHERE timestamp < ?", (before_ts,))
            return cursor.rowcount

    def purge_alerts(self, before_ts: str) -> int:
        """Delete alert records older than the given timestamp."""
        with self.get_cursor() as cursor:
            cursor.execute("DELETE FROM alerts WHERE timestamp < ?", (before_ts,))
            return cursor.rowcount

    def run_retention_purge(self, retention_days: int) -> Tuple[int, int]:
        """Purge data older than retention_days. Returns (detections_deleted, alerts_deleted)."""
        if retention_days <= 0:
            return 0, 0
        cutoff = (datetime.now(timezone.utc) - timedelta(days=retention_days)).isoformat().replace('+00:00', 'Z')
        d = self.purge_detections(cutoff)
        a = self.purge_alerts(cutoff)
        return d, a

    def get_stats(self) -> Dict:
        """Get database statistics."""
        with self.get_cursor() as cursor:
            cursor.execute("SELECT COUNT(*) as cnt FROM detections")
            detection_count = cursor.fetchone()['cnt']

            cursor.execute("SELECT COUNT(*) as cnt FROM alerts")
            alert_count = cursor.fetchone()['cnt']

            cursor.execute("SELECT COUNT(DISTINCT serial_number) as cnt FROM detections")
            unique_serials = cursor.fetchone()['cnt']

            cursor.execute("SELECT MIN(timestamp) as oldest FROM detections")
            row = cursor.fetchone()
            oldest = row['oldest'] if row else None

            cursor.execute("SELECT MAX(timestamp) as newest FROM detections")
            row = cursor.fetchone()
            newest = row['newest'] if row else None

        # DB file size
        try:
            db_size = os.path.getsize(self.db_path)
        except OSError:
            db_size = 0

        # Tile cache size
        tile_dir = os.path.join(os.path.dirname(self.db_path), 'tiles')
        tile_size = 0
        if os.path.isdir(tile_dir):
            for dirpath, _dirnames, filenames in os.walk(tile_dir):
                for f in filenames:
                    try:
                        tile_size += os.path.getsize(os.path.join(dirpath, f))
                    except OSError:
                        pass

        return {
            'db_size_bytes': db_size,
            'detection_count': detection_count,
            'alert_count': alert_count,
            'unique_serials': unique_serials,
            'oldest_record': oldest,
            'newest_record': newest,
            'tile_cache_size_bytes': tile_size,
        }

    def get_unique_serial_count(self) -> int:
        """Get count of unique drone serial numbers."""
        with self.get_cursor() as cursor:
            cursor.execute("SELECT COUNT(DISTINCT serial_number) as cnt FROM detections")
            return cursor.fetchone()['cnt']

    def get_drone_database(self) -> List[Dict]:
        """Return one dict per unique serial across the entire detections table
        (90-day retention window), newest-first by last_seen.

        Each row includes:
          - All columns from the latest detection frame for that serial.
          - first_seen / last_seen / detection_count from the aggregation sub-query.
          - controller position (operator_lat/lon/alt) from the most-recent frame
            that had a non-zero operator coordinate, or None when none exists.
          - ua_type_name  — human-readable UA type string.
          - time_in_area_seconds — int(last_seen - first_seen), or None on parse failure.
        """
        with self.get_cursor() as cursor:
            cursor.execute("""
                SELECT
                    d.*,
                    agg.first_seen,
                    agg.last_seen,
                    agg.detection_count,
                    op.operator_lat  AS ctrl_lat,
                    op.operator_lon  AS ctrl_lon,
                    op.operator_alt  AS ctrl_alt,
                    op.timestamp     AS ctrl_last_seen
                FROM detections d
                INNER JOIN (
                    SELECT serial_number,
                           MIN(timestamp) AS first_seen,
                           MAX(timestamp) AS last_seen,
                           COUNT(*)       AS detection_count
                    FROM detections
                    GROUP BY serial_number
                ) agg
                  ON d.serial_number = agg.serial_number
                 AND d.timestamp     = agg.last_seen
                LEFT JOIN (
                    SELECT od.serial_number,
                           od.operator_lat, od.operator_lon, od.operator_alt,
                           od.timestamp
                    FROM detections od
                    INNER JOIN (
                        SELECT serial_number, MAX(timestamp) AS op_ts
                        FROM detections
                        WHERE operator_lat <> 0 OR operator_lon <> 0
                        GROUP BY serial_number
                    ) opm
                      ON od.serial_number = opm.serial_number
                     AND od.timestamp     = opm.op_ts
                ) op
                  ON op.serial_number = d.serial_number
                ORDER BY agg.last_seen DESC
            """)
            raw = [dict(row) for row in cursor.fetchall()]

        # Dedup by serial_number, first-wins (rows already last_seen DESC).
        # Guards identical-timestamp ties that can produce duplicate latest rows.
        seen: set = set()
        rows: List[Dict] = []
        for row in raw:
            sn = row['serial_number']
            if sn in seen:
                continue
            seen.add(sn)

            # Remap controller columns to standard field names so the frontend
            # detail builder works unchanged.
            ctrl_lat = row.pop('ctrl_lat', None)
            ctrl_lon = row.pop('ctrl_lon', None)
            ctrl_alt = row.pop('ctrl_alt', None)
            ctrl_ts  = row.pop('ctrl_last_seen', None)
            # Only set operator fields when the LEFT JOIN found a non-zero row.
            row['operator_lat']  = ctrl_lat
            row['operator_lon']  = ctrl_lon
            row['operator_alt']  = ctrl_alt
            row['controller_last_seen'] = ctrl_ts

            # ua_type_name
            from .models import UAType
            ua = row.get('ua_type', 0) or 0
            row['ua_type_name'] = UAType(ua).display_name if 0 <= ua <= 15 else "Unknown"

            # time_in_area_seconds
            try:
                fs = datetime.fromisoformat(row['first_seen'].replace('Z', '+00:00'))
                ls = datetime.fromisoformat(row['last_seen'].replace('Z', '+00:00'))
                row['time_in_area_seconds'] = int((ls - fs).total_seconds())
            except (ValueError, AttributeError, TypeError):
                row['time_in_area_seconds'] = None

            rows.append(row)

        return rows

    # ==================== Disposition Operations ====================

    _VALID_DISPOSITIONS = frozenset({'friendly', 'threat', 'unknown'})

    def add_disposition_event(self, drone_key: str, disposition: str,
                              changed_by: str = '', notes: str = '') -> int:
        if disposition not in self._VALID_DISPOSITIONS:
            raise ValueError(f"Invalid disposition {disposition!r}; must be one of {sorted(self._VALID_DISPOSITIONS)}")
        now = _utcnow_iso_z()
        with self.get_cursor() as cursor:
            cursor.execute("""
                INSERT INTO disposition_events (drone_key, disposition, changed_at, changed_by, notes)
                VALUES (?, ?, ?, ?, ?)
            """, (drone_key, disposition, now, changed_by, notes))
            return cursor.lastrowid or 0

    def get_current_dispositions(self) -> Dict[str, str]:
        """Return {drone_key: disposition} for all keys whose latest disposition != 'unknown'."""
        with self.get_cursor() as cursor:
            cursor.execute("""
                SELECT drone_key, disposition
                FROM disposition_events
                WHERE id IN (
                    SELECT MAX(id) FROM disposition_events GROUP BY drone_key
                )
                AND disposition != 'unknown'
            """)
            return {row['drone_key']: row['disposition'] for row in cursor.fetchall()}

    def get_disposition_history(self, drone_key: Optional[str] = None, limit: int = 500) -> List[Dict]:
        """Return disposition event rows, newest first, optionally filtered by drone_key."""
        with self.get_cursor() as cursor:
            if drone_key:
                cursor.execute("""
                    SELECT id, drone_key, disposition, changed_at, changed_by, notes
                    FROM disposition_events
                    WHERE drone_key = ?
                    ORDER BY changed_at DESC
                    LIMIT ?
                """, (drone_key, limit))
            else:
                cursor.execute("""
                    SELECT id, drone_key, disposition, changed_at, changed_by, notes
                    FROM disposition_events
                    ORDER BY changed_at DESC
                    LIMIT ?
                """, (limit,))
            return [dict(row) for row in cursor.fetchall()]

    def migrate_disposition_key(self, old_key: str, new_key: str,
                                changed_by: str = 'system:key-migration') -> bool:
        """If old_key has a non-unknown current disposition, append a new event under new_key.

        Append-only — old rows are never deleted.
        Returns True if a migration event was written.
        """
        current = self.get_current_dispositions()
        disp = current.get(old_key)
        if not disp:
            return False
        self.add_disposition_event(new_key, disp, changed_by=changed_by,
                                   notes=f'migrated from {old_key}')
        return True

    # ==================== Flag Operations ====================

    _VALID_FLAGS = frozenset({'military', 'law_enforcement'})

    def add_flag_event(self, drone_key: str, flag_name: str,
                       value: bool, changed_by: str = '', notes: str = '') -> int:
        """Append a flag event row.  Returns the new row id."""
        if flag_name not in self._VALID_FLAGS:
            raise ValueError(
                f"Invalid flag_name {flag_name!r}; must be one of {sorted(self._VALID_FLAGS)}")
        int_value = int(bool(value))
        now = _utcnow_iso_z()
        with self.get_cursor() as cursor:
            cursor.execute("""
                INSERT INTO drone_flags (drone_key, flag_name, value, changed_at, changed_by, notes)
                VALUES (?, ?, ?, ?, ?, ?)
            """, (drone_key, flag_name, int_value, now, changed_by, notes))
            return cursor.lastrowid or 0

    def get_current_flags(self) -> Dict[str, Dict[str, bool]]:
        """Return {drone_key: {flag_name: True}} for all flags whose latest value = 1.

        Absent key or absent flag_name means False (sparse representation).
        """
        with self.get_cursor() as cursor:
            cursor.execute("""
                SELECT drone_key, flag_name, value
                FROM drone_flags
                WHERE id IN (
                    SELECT MAX(id) FROM drone_flags GROUP BY drone_key, flag_name
                )
                AND value = 1
            """)
            result: Dict[str, Dict[str, bool]] = {}
            for row in cursor.fetchall():
                result.setdefault(row['drone_key'], {})[row['flag_name']] = True
            return result

    def get_flag_history(self, drone_key: Optional[str] = None,
                         limit: int = 500) -> List[Dict]:
        """Return flag event rows, newest first, optionally filtered by drone_key."""
        with self.get_cursor() as cursor:
            if drone_key:
                cursor.execute("""
                    SELECT id, drone_key, flag_name, value, changed_at, changed_by, notes
                    FROM drone_flags
                    WHERE drone_key = ?
                    ORDER BY changed_at DESC
                    LIMIT ?
                """, (drone_key, limit))
            else:
                cursor.execute("""
                    SELECT id, drone_key, flag_name, value, changed_at, changed_by, notes
                    FROM drone_flags
                    ORDER BY changed_at DESC
                    LIMIT ?
                """, (limit,))
            return [dict(row) for row in cursor.fetchall()]

    def migrate_flags_key(self, old_key: str, new_key: str,
                          changed_by: str = 'system:key-migration') -> bool:
        """For each flag set True under old_key, append a new event under new_key.

        Append-only — old rows are never deleted.
        Returns True if any migration events were written.
        """
        current = self.get_current_flags()
        flags = current.get(old_key)
        if not flags:
            return False
        for flag_name in flags:
            self.add_flag_event(new_key, flag_name, True, changed_by=changed_by,
                                notes=f'migrated from {old_key}')
        return True


# --------------- Global Singleton -----------------------------------------

_db: Optional[Database] = None


def get_db(db_path: Optional[str] = None) -> Database:
    """Get or create the global database instance."""
    global _db
    if _db is None:
        _db = Database(db_path or "data/sparrow_droneid.db")
    return _db
