from datetime import datetime, timezone
import logging
import os
import threading
import time
from typing import Optional

import httpx
from sqlalchemy.orm import Session

from backend.database import get_db_session
from backend.models import Emitter, RosterUnit, IgnoredUnit
from backend.services.unit_location import bulk_active_locations

log = logging.getLogger(__name__)

SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")

# Tiny module-level cache: /api/status-snapshot is polled every 10s by the
# dashboard, and we don't want to hammer SFM with one /db/units roundtrip per
# call.  15s TTL keeps the cache mostly hot, with occasional refreshes.
_SFM_CACHE_TTL_SECONDS = 15.0
_sfm_cache_lock = threading.Lock()
_sfm_cache: dict = {"fetched_at": 0.0, "data": None, "reachable": False}


def _parse_sfm_timestamp(ts_str: Optional[str]) -> Optional[datetime]:
    """SFM /db/units returns naive ISO timestamps (no tz suffix).  Treat them
    as UTC, mirroring how the watcher heartbeat stores Emitter.last_seen."""
    if not ts_str:
        return None
    try:
        ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
    except ValueError:
        return None
    if ts.tzinfo is None:
        ts = ts.replace(tzinfo=timezone.utc)
    return ts


def fetch_sfm_unit_last_seen() -> tuple[dict[str, datetime], bool]:
    """Return ({serial: last_seen_utc}, sfm_reachable).

    Cached for _SFM_CACHE_TTL_SECONDS.  On any HTTP error returns ({}, False)
    so callers transparently fall back to the watcher-heartbeat path.
    """
    now = time.monotonic()
    with _sfm_cache_lock:
        if _sfm_cache["data"] is not None and (now - _sfm_cache["fetched_at"]) < _SFM_CACHE_TTL_SECONDS:
            return _sfm_cache["data"], _sfm_cache["reachable"]

    data: dict[str, datetime] = {}
    reachable = False
    try:
        with httpx.Client(timeout=4.0) as client:
            resp = client.get(f"{SFM_BASE_URL}/db/units")
            resp.raise_for_status()
            payload = resp.json() or []
            for row in payload:
                serial = row.get("serial")
                ts = _parse_sfm_timestamp(row.get("last_seen"))
                if serial and ts is not None:
                    data[serial] = ts
            reachable = True
    except httpx.HTTPError as e:
        log.warning("SFM /db/units unreachable for status snapshot: %s", e)
    except Exception as e:  # noqa: BLE001 — defensive against malformed payload
        log.warning("SFM /db/units parse error: %s", e)

    with _sfm_cache_lock:
        _sfm_cache["fetched_at"] = now
        _sfm_cache["data"] = data
        _sfm_cache["reachable"] = reachable
    return data, reachable


def ensure_utc(dt):
    if dt is None:
        return None
    if dt.tzinfo is None:
        return dt.replace(tzinfo=timezone.utc)
    return dt.astimezone(timezone.utc)


def format_age(last_seen):
    if not last_seen:
        return "N/A"
    last_seen = ensure_utc(last_seen)
    now = datetime.now(timezone.utc)
    diff = now - last_seen
    hours = diff.total_seconds() // 3600
    mins = (diff.total_seconds() % 3600) // 60
    return f"{int(hours)}h {int(mins)}m"


def calculate_status(last_seen, status_ok_threshold=12, status_pending_threshold=24):
    """
    Calculate status based on how long ago the unit was last seen.

    Args:
        last_seen: datetime of last seen (UTC)
        status_ok_threshold: hours before status becomes Pending (default 12)
        status_pending_threshold: hours before status becomes Missing (default 24)

    Returns:
        "OK", "Pending", or "Missing"
    """
    if not last_seen:
        return "Missing"

    last_seen = ensure_utc(last_seen)
    now = datetime.now(timezone.utc)
    hours_ago = (now - last_seen).total_seconds() / 3600

    if hours_ago > status_pending_threshold:
        return "Missing"
    elif hours_ago > status_ok_threshold:
        return "Pending"
    else:
        return "OK"


def emit_status_snapshot():
    """
    Merge roster (what we *intend*) with emitter data (what is *actually happening*).
    Status is recalculated based on current time to ensure accuracy.
    """

    db = get_db_session()
    try:
        # Get user preferences for status thresholds
        from backend.models import UserPreferences
        prefs = db.query(UserPreferences).filter_by(id=1).first()
        status_ok_threshold = prefs.status_ok_threshold_hours if prefs else 12
        status_pending_threshold = prefs.status_pending_threshold_hours if prefs else 24

        roster = {r.id: r for r in db.query(RosterUnit).all()}
        emitters = {e.id: e for e in db.query(Emitter).all()}
        ignored = {i.id for i in db.query(IgnoredUnit).all()}

        # Active-assignment location lookup for all roster units (direct only;
        # modems inherit from their paired device below in the derive loop).
        active_locs = bulk_active_locations(db, list(roster.values()))

        # SFM event-forwards are now the primary "last seen" signal for
        # seismographs.  Watcher heartbeats stay as a backup — if SFM is down
        # or hasn't seen a serial, we fall back to Emitter.last_seen.
        sfm_last_seen_map, sfm_reachable = fetch_sfm_unit_last_seen()

        units = {}

        # --- Merge roster entries first ---
        for unit_id, r in roster.items():
            e = emitters.get(unit_id)
            if r.retired:
                # Retired units get separated later
                status = "Retired"
                age = "N/A"
                last_seen = None
                fname = ""
            elif r.out_for_calibration:
                # Out for calibration units get separated later
                status = "Out for Calibration"
                age = "N/A"
                last_seen = None
                fname = ""
            elif getattr(r, 'allocated', False) and not r.deployed:
                # Allocated: staged for an upcoming job, not yet physically deployed
                status = "Allocated"
                age = "N/A"
                last_seen = None
                fname = ""
            else:
                device_type = r.device_type or "seismograph"
                emitter_last_seen = ensure_utc(e.last_seen) if e else None
                fname = e.last_file if e else ""

                # SFM-primary, heartbeat-backup logic — only for seismographs.
                # (SLMs / modems aren't forwarded into SFM's events store.)
                sfm_last_seen = sfm_last_seen_map.get(unit_id) if device_type == "seismograph" else None

                if sfm_last_seen and emitter_last_seen:
                    # Both sources reported — use whichever is more recent.
                    if sfm_last_seen >= emitter_last_seen:
                        last_seen = sfm_last_seen
                        last_seen_source = "sfm"
                    else:
                        last_seen = emitter_last_seen
                        last_seen_source = "heartbeat"
                elif sfm_last_seen:
                    last_seen = sfm_last_seen
                    last_seen_source = "sfm"
                elif emitter_last_seen:
                    last_seen = emitter_last_seen
                    # If SFM was reachable but doesn't have this serial, it
                    # means the unit is calling home to the watcher but not
                    # being forwarded — still a working state for now.
                    last_seen_source = "heartbeat"
                else:
                    last_seen = None
                    last_seen_source = "none"

                if last_seen is not None:
                    status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold)
                    age = format_age(last_seen)
                else:
                    status = "Missing"
                    age = "N/A"

                units[unit_id] = {
                    "id": unit_id,
                    "status": status,
                    "age": age,
                    "last": last_seen.isoformat() if last_seen else None,
                    "last_seen_source": last_seen_source,
                    "sfm_reachable": sfm_reachable,
                    "fname": fname,
                "deployed": r.deployed,
                "note": r.note or "",
                "retired": r.retired,
                "out_for_calibration": r.out_for_calibration or False,
                "allocated": getattr(r, 'allocated', False) or False,
                "allocated_to_project_id": getattr(r, 'allocated_to_project_id', None) or "",
                # Device type and type-specific fields
                "device_type": r.device_type or "seismograph",
                "last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
                "next_calibration_due": r.next_calibration_due.isoformat() if r.next_calibration_due else None,
                "deployed_with_modem_id": r.deployed_with_modem_id,
                "deployed_with_unit_id": r.deployed_with_unit_id,
                "ip_address": r.ip_address,
                "phone_number": r.phone_number,
                    "hardware_model": r.hardware_model,
                    # Location for mapping — sourced from active UnitAssignment
                    # → MonitoringLocation.  Empty for benched / unassigned.
                    "address":       (active_locs.get(unit_id) or {}).get("address") or "",
                    "coordinates":   (active_locs.get(unit_id) or {}).get("coordinates") or "",
                    "location_name": (active_locs.get(unit_id) or {}).get("name") or "",
                    "project_id":    (active_locs.get(unit_id) or {}).get("project_id") or "",
                    "location_id":   (active_locs.get(unit_id) or {}).get("location_id") or "",
                }

        # --- Add unexpected emitter-only units ---
        for unit_id, e in emitters.items():
            if unit_id not in roster:
                emitter_last_seen = ensure_utc(e.last_seen)
                sfm_last_seen = sfm_last_seen_map.get(unit_id)
                if sfm_last_seen and (not emitter_last_seen or sfm_last_seen >= emitter_last_seen):
                    last_seen = sfm_last_seen
                    last_seen_source = "sfm"
                else:
                    last_seen = emitter_last_seen
                    last_seen_source = "heartbeat"
                # RECALCULATE status for unknown units too
                status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold)
                units[unit_id] = {
                    "id": unit_id,
                    "status": status,
                    "age": format_age(last_seen),
                    "last": last_seen.isoformat() if last_seen else None,
                    "last_seen_source": last_seen_source,
                    "sfm_reachable": sfm_reachable,
                    "fname": e.last_file,
                    "deployed": False,            # default
                    "note": "",
                    "retired": False,
                    "out_for_calibration": False,
                    "allocated": False,
                    "allocated_to_project_id": "",
                    # Device type and type-specific fields (defaults for unknown units)
                    "device_type": "seismograph",  # default
                    "last_calibrated": None,
                    "next_calibration_due": None,
                    "deployed_with_modem_id": None,
                    "deployed_with_unit_id": None,
                    "ip_address": None,
                    "phone_number": None,
                    "hardware_model": None,
                    # Location fields — unknown units have no assignment
                    "address": "",
                    "coordinates": "",
                    "location_name": "",
                    "project_id": "",
                    "location_id": "",
                }

        # --- Derive modem status from paired devices ---
        # Modems don't have their own check-in system, so we inherit status
        # from whatever device they're paired with (seismograph or SLM)
        # Check both directions: modem.deployed_with_unit_id OR device.deployed_with_modem_id
        for unit_id, unit_data in units.items():
            if unit_data.get("device_type") == "modem" and not unit_data.get("retired"):
                paired_unit_id = None
                roster_unit = roster.get(unit_id)

                # First, check if modem has deployed_with_unit_id set
                if roster_unit and roster_unit.deployed_with_unit_id:
                    paired_unit_id = roster_unit.deployed_with_unit_id
                else:
                    # Fallback: check if any device has this modem in deployed_with_modem_id
                    for other_id, other_roster in roster.items():
                        if other_roster.deployed_with_modem_id == unit_id:
                            paired_unit_id = other_id
                            break

                if paired_unit_id:
                    paired_unit = units.get(paired_unit_id)
                    if paired_unit:
                        # Inherit status from paired device
                        unit_data["status"] = paired_unit.get("status", "Missing")
                        unit_data["age"] = paired_unit.get("age", "N/A")
                        unit_data["last"] = paired_unit.get("last")
                        unit_data["last_seen_source"] = paired_unit.get("last_seen_source", "none")
                        unit_data["derived_from"] = paired_unit_id
                        # Inherit deployment location too — modems don't carry
                        # their own UnitAssignment.
                        for k in ("address", "coordinates", "location_name", "project_id", "location_id"):
                            if not unit_data.get(k):
                                unit_data[k] = paired_unit.get(k, "")

        # Separate buckets for UI
        active_units = {
            uid: u for uid, u in units.items()
            if not u["retired"] and not u["out_for_calibration"] and u["deployed"] and uid not in ignored
        }

        benched_units = {
            uid: u for uid, u in units.items()
            if not u["retired"] and not u["out_for_calibration"] and not u["allocated"] and not u["deployed"] and uid not in ignored
        }

        allocated_units = {
            uid: u for uid, u in units.items()
            if not u["retired"] and not u["out_for_calibration"] and u["allocated"] and not u["deployed"] and uid not in ignored
        }

        retired_units = {
            uid: u for uid, u in units.items()
            if u["retired"]
        }

        out_for_calibration_units = {
            uid: u for uid, u in units.items()
            if u["out_for_calibration"]
        }

        # Unknown units - emitters that aren't in the roster and aren't ignored
        unknown_units = {
            uid: u for uid, u in units.items()
            if uid not in roster and uid not in ignored
        }

        return {
            "timestamp": datetime.utcnow().isoformat(),
            "units": units,
            "active": active_units,
            "benched": benched_units,
            "allocated": allocated_units,
            "retired": retired_units,
            "out_for_calibration": out_for_calibration_units,
            "unknown": unknown_units,
            "summary": {
                "total": len(active_units) + len(benched_units) + len(allocated_units),
                "active": len(active_units),
                "benched": len(benched_units),
                "allocated": len(allocated_units),
                "retired": len(retired_units),
                "out_for_calibration": len(out_for_calibration_units),
                "unknown": len(unknown_units),
                # Status counts only for deployed units (active_units)
                "ok": sum(1 for u in active_units.values() if u["status"] == "OK"),
                "pending": sum(1 for u in active_units.values() if u["status"] == "Pending"),
                "missing": sum(1 for u in active_units.values() if u["status"] == "Missing"),
            }
        }
    finally:
        db.close()
