"""
Submission ingestion helpers for GigLez.

Turns a variety of popular Sub-GHz capture formats into the platform's common
capture record shape:

  - Flipper Zero  .sub            (handled in main_simple via SubFileParser)
  - rtl_433       .json / .ndjson (decoded observations, one JSON object per line)
  - tabular       .csv            (Wigle-style pre-decoded rows)
  - batch         .zip            (any mix of the above + a manifest)

rtl_433 and CSV rows arrive already decoded, so no signature matching is needed:
the reported model IS the device. This module maps those into capture records,
assigns a device category, applies optional GPS privacy rounding, and computes a
stable dedup key so the same observation is never stored twice.
"""

import csv
import hashlib
import io
import json
import zipfile
from pathlib import Path
from typing import Iterable, Optional

# ---------------------------------------------------------------------------
# Device-category mapping for decoded (rtl_433 / CSV) submissions
# ---------------------------------------------------------------------------

# rtl_433 catalog category -> GigLez display category
_RTL433_CAT = {
    "weather": "Weather Sensor",
    "tpms": "TPMS",
    "automotive": "Vehicle",
    "security": "Security",
    "home_automation": "Home Automation",
    "energy": "Energy Monitor",
    "lighting": "Lighting",
    "sensors": "Sensor",
    "other": "Other",
}

# Keyword heuristics applied to a decoded model string (checked in order).
_KEYWORD_CATEGORY = [
    (("tpms", "schrader", "toyota", "ford", "citroen", "renault", "hyundai",
      "jansite", "abarth", "pmv", "truck-tpms"), "TPMS"),
    (("acurite", "oregon", "lacrosse", "nexus", "ambient", "fineoffset",
      "fine offset", "ecowitt", "bresser", "tfa", "auriol", "prologue",
      "rubicson", "inkbird", "thermopro", "wt450", "ws2032", "rain", "temperature",
      "humidity", "weather"), "Weather Sensor"),
    # Garage/gate and doorbell come before Security so "doorbell" isn't swallowed
    # by the Security rule's "door" keyword (contact sensors).
    (("garage", "gate", "chamberlain", "liftmaster", "genie", "linear"),
     "Garage/Gate"),
    (("doorbell", "chime", "bell"), "Doorbell"),
    (("honeywell", "dsc", "visonic", "interlogix", "secplus", "contact",
      "door", "pir", "motion", "smoke", "alarm", "security"), "Security"),
    (("efergy", "owl", "power", "energy", "watt"), "Energy Monitor"),
    (("led", "light", "lamp"), "Lighting"),
    (("switch", "socket", "plug", "blind", "shade", "relay", "remote"),
     "Home Automation"),
]


def map_model_to_category(model: str, explicit: Optional[str] = None) -> str:
    """Best-effort device category for a decoded model name."""
    if explicit:
        # normalise a raw rtl_433 category if that's what was passed
        return _RTL433_CAT.get(explicit.lower(), explicit)
    m = (model or "").lower()
    for keywords, category in _KEYWORD_CATEGORY:
        if any(k in m for k in keywords):
            return category
    return "Sensor"


# ---------------------------------------------------------------------------
# Format detection
# ---------------------------------------------------------------------------

def detect_format(filename: str, content: bytes) -> str:
    """Return one of: sub, rtl433_json, csv, zip, unknown."""
    name = (filename or "").lower()
    if name.endswith(".zip") or content[:2] == b"PK":
        return "zip"
    if name.endswith((".ndjson", ".jsonl")):
        return "rtl433_json"
    if name.endswith(".csv"):
        return "csv"
    if name.endswith(".sub"):
        return "sub"
    if name.endswith(".json"):
        return "rtl433_json"

    # Fall back to sniffing the first non-empty line.
    head = content[:4096].lstrip()
    if head.startswith(b"Filetype:"):
        return "sub"
    if head[:1] in (b"{", b"["):
        return "rtl433_json"
    if b"," in head.split(b"\n", 1)[0]:
        return "csv"
    return "unknown"


# ---------------------------------------------------------------------------
# GPS helpers
# ---------------------------------------------------------------------------

def round_gps(lat, lon, decimals: Optional[int]):
    """Round coordinates for privacy. `decimals=None` leaves them untouched."""
    if lat is None or lon is None or decimals is None:
        return lat, lon
    try:
        return round(float(lat), decimals), round(float(lon), decimals)
    except (TypeError, ValueError):
        return lat, lon


def _record_gps(record: dict, fallback_entry: dict, session_gps: dict):
    """Resolve GPS for a decoded record: record fields > manifest entry > session."""
    for lat_k, lon_k in (("latitude", "longitude"), ("lat", "lon"), ("lat", "lng")):
        if record.get(lat_k) is not None and record.get(lon_k) is not None:
            return record[lat_k], record[lon_k], "record"
    if fallback_entry.get("latitude") is not None:
        return fallback_entry.get("latitude"), fallback_entry.get("longitude"), "manifest"
    if session_gps.get("latitude") is not None:
        return session_gps.get("latitude"), session_gps.get("longitude"), "session"
    return None, None, "none"


# ---------------------------------------------------------------------------
# Parsers
# ---------------------------------------------------------------------------

def parse_rtl433(content: bytes) -> list:
    """Parse rtl_433 output: newline-delimited JSON objects OR a JSON array."""
    text = content.decode("utf-8", errors="replace").strip()
    if not text:
        return []
    # JSON array form
    if text[0] == "[":
        try:
            data = json.loads(text)
            return [r for r in data if isinstance(r, dict)]
        except json.JSONDecodeError:
            return []
    # NDJSON form (one object per line)
    records = []
    for line in text.splitlines():
        line = line.strip()
        if not line:
            continue
        try:
            obj = json.loads(line)
            if isinstance(obj, dict):
                records.append(obj)
        except json.JSONDecodeError:
            continue
    return records


def parse_csv(content: bytes) -> list:
    """Parse a Wigle-style CSV into a list of row dicts."""
    text = content.decode("utf-8", errors="replace")
    reader = csv.DictReader(io.StringIO(text))
    return [dict(row) for row in reader]


# ---------------------------------------------------------------------------
# Decoded record -> capture record
# ---------------------------------------------------------------------------

def _to_float(v):
    try:
        return float(v)
    except (TypeError, ValueError):
        return None


def build_decoded_capture(record: dict, *, source_format: str, filename: str,
                          manifest_entry: dict, session_gps: dict,
                          data_source: str, session_id: str,
                          gps_decimals: Optional[int]) -> dict:
    """Map one decoded rtl_433/CSV record into a GigLez capture record."""
    model = (record.get("model") or record.get("device_name")
             or record.get("protocol") or "Unknown")
    manufacturer = record.get("manufacturer") or record.get("brand") or ""

    # frequency: rtl_433 emits MHz in `freq`/`freq1` only with -M level; CSV may
    # provide Hz or MHz. Normalise to Hz.
    freq_hz = 0
    for key in ("frequency", "freq", "freq1"):
        val = _to_float(record.get(key))
        if val:
            freq_hz = int(val if val > 1e6 else val * 1e6)
            break

    lat, lon, gps_source = _record_gps(record, manifest_entry, session_gps)
    lat, lon = round_gps(lat, lon, gps_decimals)

    method = "rtl_433_decoded" if source_format == "rtl433_json" else "csv_import"
    conf = _to_float(record.get("confidence"))
    if conf is None:
        conf = 1.0 if source_format == "rtl433_json" else 0.9

    return {
        "filename": filename,
        "frequency": freq_hz,
        "protocol": model,
        "preset": record.get("mod") or record.get("modulation") or source_format,
        "latitude": lat,
        "longitude": lon,
        "gps_source": gps_source,
        "timestamp": record.get("time") or record.get("timestamp")
                     or manifest_entry.get("timestamp", ""),
        "data_source": data_source,
        "session_id": session_id,
        "device_name": model,
        "device_category": map_model_to_category(model, record.get("category")),
        "match_confidence": conf,
        "match_method": method,
        "device_description": (f"{manufacturer} {model}".strip()),
        "matched_devices": [],
        # keep a couple of useful decoded fields for training/inspection
        "decoded": {k: record.get(k) for k in ("id", "channel", "battery_ok",
                    "temperature_C", "humidity", "pressure_kPa") if k in record},
    }


# ---------------------------------------------------------------------------
# Deduplication
# ---------------------------------------------------------------------------

def dedup_key_for_file(content: bytes) -> str:
    """SHA256 of raw file bytes (for whole-file formats like .sub)."""
    return "sha256:" + hashlib.sha256(content).hexdigest()


def dedup_key_for_capture(cap: dict) -> str:
    """Stable identity for a decoded observation (model + id + place + time)."""
    ident = "|".join(str(cap.get(k, "")) for k in (
        "protocol", "device_name", "frequency"))
    decoded = cap.get("decoded") or {}
    ident += "|" + str(decoded.get("id", "")) + "|" + str(decoded.get("channel", ""))
    lat, lon = cap.get("latitude"), cap.get("longitude")
    if lat is not None and lon is not None:
        ident += f"|{round(float(lat), 4)}|{round(float(lon), 4)}"
    ident += "|" + str(cap.get("timestamp", ""))[:19]  # to the second
    return "rec:" + hashlib.sha256(ident.encode()).hexdigest()


# ---------------------------------------------------------------------------
# ZIP batch
# ---------------------------------------------------------------------------

def iter_zip_members(content: bytes) -> Iterable:
    """Yield (member_filename, member_bytes) for non-manifest files in a zip."""
    with zipfile.ZipFile(io.BytesIO(content)) as zf:
        for info in zf.infolist():
            if info.is_dir():
                continue
            name = Path(info.filename).name
            if not name or name.startswith(".") or name.lower() == "manifest.json":
                continue
            yield name, zf.read(info)
