"""
Carrier Strike Group OSINT Tracker
===================================
Maintains estimated positions for US Navy Carrier Strike Groups with
honest provenance and freshness signals.

Issues #244 / #245 / #246 (tg12 external audit):

The previous implementation baked a snapshot of USNI News Fleet &
Marine Tracker positions (March 9, 2026) into the registry as
``fallback_lat``/``fallback_lng`` and stamped ``updated = now()``
every time the dossier was rendered. That presented stale editorial
data as live state. It also persisted GDELT-derived positions to the
on-disk cache with no freshness signal, so a single news mention from
months ago could keep overriding the (already-stale) registry default
indefinitely.

Architecture after this PR:

::

    backend/data/carrier_seed.json   read-only, shipped with image,
                                     used ONCE on first-ever startup
                                     to bootstrap carrier_cache.json.

    backend/data/carrier_cache.json  mutable, lives in the runtime data
                                     volume, written by every GDELT
                                     refresh + any future source.

Startup flow:

1.  ``carrier_cache.json`` exists?  → load it.
2.  Otherwise, copy ``carrier_seed.json`` → ``carrier_cache.json``,
    then load it. (This happens once, ever, per install.)
3.  Background: GDELT fetch runs. Any carrier mentioned in fresh news
    gets its entry replaced with the news-derived position.
    ``position_source_at`` is set to the news article timestamp.

Freshness is a *labelling* decision, not an eviction decision:

- ``position_source_at`` within the configurable freshness window
  (default 14 days) → ``position_confidence = "recent"``.
- Older than that              → ``position_confidence = "stale"``.
- Bootstrapped from the seed file (never updated) → ``"seed"``.
- No cache entry at all (e.g. a carrier added to the registry after
  first install) → carrier renders at its homeport with
  ``"homeport_default"``.

Carriers are never hidden, never teleported, never disappeared. The
position the user sees is always the last position the system actually
observed, with an honest "as-of" timestamp the UI can render however
it likes. A year from now, the runtime cache reflects whatever this
install has observed via GDELT — not the seed snapshot.
"""

import os
import json
import time
import logging
import threading
import random
import shutil
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from services.network_utils import fetch_with_curl

logger = logging.getLogger(__name__)

# -----------------------------------------------------------------
# Carrier registry: hull number → identity only.
#
# Issue #244 (tg12): the previous registry carried hard-coded
# ``fallback_lat``/``fallback_lng`` that were dated editorial
# snapshots from a 2026-03-09 article. Those fields are DELETED. The
# registry is now identity + homeport only; positions are sourced
# exclusively from carrier_cache.json (and via that, from the
# bootstrap seed or live OSINT).
# -----------------------------------------------------------------
CARRIER_REGISTRY: Dict[str, dict] = {
    # --- Bremerton, WA (Naval Base Kitsap) ---
    "CVN-68": {
        "name": "USS Nimitz (CVN-68)",
        "wiki": "https://en.wikipedia.org/wiki/USS_Nimitz",
        "homeport": "Bremerton, WA",
        "homeport_lat": 47.5535,
        "homeport_lng": -122.6400,
    },
    "CVN-76": {
        "name": "USS Ronald Reagan (CVN-76)",
        "wiki": "https://en.wikipedia.org/wiki/USS_Ronald_Reagan",
        "homeport": "Bremerton, WA",
        "homeport_lat": 47.5580,
        "homeport_lng": -122.6360,
    },
    # --- Norfolk, VA (Naval Station Norfolk) ---
    "CVN-69": {
        "name": "USS Dwight D. Eisenhower (CVN-69)",
        "wiki": "https://en.wikipedia.org/wiki/USS_Dwight_D._Eisenhower",
        "homeport": "Norfolk, VA",
        "homeport_lat": 36.9465,
        "homeport_lng": -76.3265,
    },
    "CVN-78": {
        "name": "USS Gerald R. Ford (CVN-78)",
        "wiki": "https://en.wikipedia.org/wiki/USS_Gerald_R._Ford",
        "homeport": "Norfolk, VA",
        "homeport_lat": 36.9505,
        "homeport_lng": -76.3250,
    },
    "CVN-74": {
        "name": "USS John C. Stennis (CVN-74)",
        "wiki": "https://en.wikipedia.org/wiki/USS_John_C._Stennis",
        "homeport": "Norfolk, VA",
        "homeport_lat": 36.9540,
        "homeport_lng": -76.3235,
    },
    "CVN-75": {
        "name": "USS Harry S. Truman (CVN-75)",
        "wiki": "https://en.wikipedia.org/wiki/USS_Harry_S._Truman",
        "homeport": "Norfolk, VA",
        "homeport_lat": 36.9580,
        "homeport_lng": -76.3220,
    },
    "CVN-77": {
        "name": "USS George H.W. Bush (CVN-77)",
        "wiki": "https://en.wikipedia.org/wiki/USS_George_H.W._Bush",
        "homeport": "Norfolk, VA",
        "homeport_lat": 36.9620,
        "homeport_lng": -76.3210,
    },
    # --- San Diego, CA (Naval Base San Diego) ---
    "CVN-70": {
        "name": "USS Carl Vinson (CVN-70)",
        "wiki": "https://en.wikipedia.org/wiki/USS_Carl_Vinson",
        "homeport": "San Diego, CA",
        "homeport_lat": 32.6840,
        "homeport_lng": -117.1290,
    },
    "CVN-71": {
        "name": "USS Theodore Roosevelt (CVN-71)",
        "wiki": "https://en.wikipedia.org/wiki/USS_Theodore_Roosevelt_(CVN-71)",
        "homeport": "San Diego, CA",
        "homeport_lat": 32.6885,
        "homeport_lng": -117.1280,
    },
    "CVN-72": {
        "name": "USS Abraham Lincoln (CVN-72)",
        "wiki": "https://en.wikipedia.org/wiki/USS_Abraham_Lincoln_(CVN-72)",
        "homeport": "San Diego, CA",
        "homeport_lat": 32.6925,
        "homeport_lng": -117.1275,
    },
    # --- Yokosuka, Japan (CFAY) ---
    "CVN-73": {
        "name": "USS George Washington (CVN-73)",
        "wiki": "https://en.wikipedia.org/wiki/USS_George_Washington_(CVN-73)",
        "homeport": "Yokosuka, Japan",
        "homeport_lat": 35.2830,
        "homeport_lng": 139.6700,
    },
}

# -----------------------------------------------------------------
# Region → approximate center coordinates.
#
# Issue #245 (tg12): converting a region name straight into precise
# map coordinates is false precision. We still use this table to
# infer a coarse position from a headline mention, but the resulting
# carrier object is now stamped ``position_confidence = "approximate"``
# so the UI can render an uncertainty radius / dimmed icon. The
# centroid is a best-effort midpoint of the named body of water.
# -----------------------------------------------------------------
REGION_COORDS: Dict[str, tuple] = {
    # Oceans & Seas
    "eastern mediterranean": (34.0, 25.0),
    "mediterranean": (36.0, 15.0),
    "western mediterranean": (37.0, 2.0),
    "red sea": (18.0, 39.5),
    "arabian sea": (16.0, 64.0),
    "persian gulf": (26.5, 51.5),
    "gulf of oman": (24.5, 58.5),
    "north arabian sea": (20.0, 64.0),
    "south china sea": (15.0, 115.0),
    "east china sea": (28.0, 125.0),
    "philippine sea": (20.0, 130.0),
    "sea of japan": (40.0, 135.0),
    "taiwan strait": (24.0, 119.5),
    "western pacific": (20.0, 140.0),
    "pacific": (20.0, -150.0),
    "indian ocean": (-5.0, 70.0),
    "north atlantic": (40.0, -40.0),
    "atlantic": (30.0, -50.0),
    "gulf of aden": (12.5, 45.0),
    "horn of africa": (10.0, 50.0),
    "strait of hormuz": (26.5, 56.3),
    "bab el-mandeb": (12.6, 43.3),
    "suez canal": (30.5, 32.3),
    "baltic sea": (57.0, 18.0),
    "north sea": (56.0, 3.0),
    "black sea": (43.0, 34.0),
    "south atlantic": (-20.0, -20.0),
    "coral sea": (-18.0, 155.0),
    "gulf of mexico": (25.0, -90.0),
    "caribbean": (15.0, -75.0),
    # Specific bases / ports
    "norfolk": (36.95, -76.33),
    "san diego": (32.68, -117.15),
    "yokosuka": (35.28, 139.67),
    "pearl harbor": (21.35, -157.95),
    "guam": (13.45, 144.79),
    "bahrain": (26.23, 50.55),
    "rota": (36.62, -6.35),
    "naples": (40.85, 14.27),
    "bremerton": (47.56, -122.63),
    "puget sound": (47.56, -122.63),
    "newport news": (36.98, -76.43),
    # Areas of operation
    "centcom": (25.0, 55.0),
    "indopacom": (20.0, 130.0),
    "eucom": (48.0, 15.0),
    "southcom": (10.0, -80.0),
    "5th fleet": (25.0, 55.0),
    "6th fleet": (36.0, 15.0),
    "7th fleet": (25.0, 130.0),
    "3rd fleet": (30.0, -130.0),
    "2nd fleet": (35.0, -60.0),
}

# -----------------------------------------------------------------
# Files
# -----------------------------------------------------------------
#
# The seed lives in the read-only image data dir (it ships with each
# release). The cache lives in the same data dir but is written at
# runtime; under Docker compose this dir is volume-mounted so the
# cache persists across container restarts, which is the whole point
# of the seed-then-observe model — the user's runtime observations
# survive image upgrades.
SEED_FILE = Path(__file__).parent.parent / "data" / "carrier_seed.json"
CACHE_FILE = Path(__file__).parent.parent / "data" / "carrier_cache.json"

# -----------------------------------------------------------------
# Freshness window for position_confidence labeling. Issue #246 (tg12):
# previously persisted cache entries had no freshness signal at all.
# After this change, the position itself is preserved (we never lose
# what was last observed) but the confidence label flips from
# "recent" to "stale" once the underlying source is older than this
# window. Operator-overridable via env var.
# -----------------------------------------------------------------
_DEFAULT_FRESHNESS_WINDOW_DAYS = 14


def _freshness_window_days() -> int:
    raw = str(os.environ.get("SHADOWBROKER_CARRIER_FRESHNESS_DAYS", "") or "").strip()
    if not raw:
        return _DEFAULT_FRESHNESS_WINDOW_DAYS
    try:
        n = int(raw)
        return n if n > 0 else _DEFAULT_FRESHNESS_WINDOW_DAYS
    except (TypeError, ValueError):
        return _DEFAULT_FRESHNESS_WINDOW_DAYS


_carrier_positions: Dict[str, dict] = {}
_positions_lock = threading.Lock()
_last_update: Optional[datetime] = None
_last_gdelt_fetch_at = 0.0
_cached_gdelt_articles: List[dict] = []
_GDELT_FETCH_INTERVAL_SECONDS = 1800
_GDELT_REQUEST_DELAY_SECONDS = 1.25
_GDELT_REQUEST_JITTER_SECONDS = 0.35


def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


def _parse_iso(ts: str) -> Optional[datetime]:
    if not ts:
        return None
    try:
        # Python's fromisoformat accepts +00:00 but not 'Z' until 3.11.
        normalized = ts.replace("Z", "+00:00")
        dt = datetime.fromisoformat(normalized)
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        return dt
    except (TypeError, ValueError):
        return None


def _compute_position_confidence(entry: dict, *, now: Optional[datetime] = None) -> str:
    """Return the public confidence label for a carrier cache entry.

    Order of precedence:
      - explicit "homeport_default" / "seed" labels are preserved.
      - dated entries (with position_source_at) are "recent" if within
        the configured freshness window, else "stale".
      - missing position_source_at falls through to "stale".
    """
    raw_label = str(entry.get("position_confidence", "") or "").strip()
    # Explicit "kind of provenance" labels are preserved as-is. They
    # describe HOW we got the position, not WHEN — a fresh headline-to-
    # centroid match (#245) is still imprecise no matter how recently
    # it was observed, and the seed (#244) is always the seed.
    if raw_label in {"seed", "homeport_default", "approximate"}:
        # Approximate entries can still age into "stale_approximate" if
        # they fall out of the freshness window — that distinction lets
        # the UI render a different badge for old-and-imprecise vs
        # recent-and-imprecise. seed/homeport_default never age (they
        # were never timestamped against real observations).
        if raw_label == "approximate":
            source_at = _parse_iso(str(entry.get("position_source_at", "") or ""))
            if source_at is not None:
                reference = now or datetime.now(timezone.utc)
                if reference - source_at > timedelta(days=_freshness_window_days()):
                    return "stale_approximate"
        return raw_label

    source_at = _parse_iso(str(entry.get("position_source_at", "") or ""))
    if not source_at:
        return "stale"

    reference = now or datetime.now(timezone.utc)
    window = timedelta(days=_freshness_window_days())
    if reference - source_at <= window:
        return "recent"
    return "stale"


def _load_seed() -> Dict[str, dict]:
    """Load the read-only seed file shipped with the image.

    Returns a hull→entry dict (no _meta wrapper). Missing or malformed
    seed files yield an empty dict — the caller falls back to homeport
    defaults.
    """
    try:
        if not SEED_FILE.exists():
            logger.info("Carrier seed file not present at %s; first-run will fall back to homeport defaults", SEED_FILE)
            return {}
        raw = json.loads(SEED_FILE.read_text(encoding="utf-8"))
        carriers = raw.get("carriers", {}) if isinstance(raw, dict) else {}
        if not isinstance(carriers, dict):
            return {}
        logger.info("Carrier seed loaded: %d entries from %s", len(carriers), SEED_FILE)
        return carriers
    except (IOError, OSError, json.JSONDecodeError, ValueError) as e:
        logger.warning("Failed to load carrier seed file %s: %s", SEED_FILE, e)
        return {}


def _load_cache() -> Dict[str, dict]:
    """Load the mutable cache (last-known positions persisted between restarts)."""
    try:
        if CACHE_FILE.exists():
            data = json.loads(CACHE_FILE.read_text(encoding="utf-8"))
            if isinstance(data, dict):
                logger.info("Carrier cache loaded: %d carriers from %s", len(data), CACHE_FILE)
                return data
    except (IOError, OSError, json.JSONDecodeError, ValueError) as e:
        logger.warning("Failed to load carrier cache: %s", e)
    return {}


def _save_cache(positions: Dict[str, dict]) -> None:
    """Persist the mutable cache. Atomic write (temp + rename) so a crash
    mid-write can't leave the file truncated."""
    try:
        CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
        tmp = CACHE_FILE.with_suffix(CACHE_FILE.suffix + ".tmp")
        tmp.write_text(json.dumps(positions, indent=2), encoding="utf-8")
        # On Windows os.replace is atomic and overwrites existing files.
        os.replace(tmp, CACHE_FILE)
        logger.info("Carrier cache saved: %d carriers", len(positions))
    except (IOError, OSError) as e:
        logger.warning("Failed to save carrier cache: %s", e)


def _homeport_entry_for(hull: str) -> Optional[dict]:
    """Return a homeport-default cache entry for a hull, or None if the
    hull is not in the registry."""
    info = CARRIER_REGISTRY.get(hull)
    if not info:
        return None
    return {
        "lat": info["homeport_lat"],
        "lng": info["homeport_lng"],
        "heading": 0,
        "desc": f"{info['homeport']} (no observations yet)",
        "source": f"Homeport default ({info['homeport']})",
        "source_url": info.get("wiki", ""),
        "position_source_at": _now_iso(),
        "position_confidence": "homeport_default",
    }


def _bootstrap_cache_if_missing() -> Dict[str, dict]:
    """One-shot: if no cache exists, materialize one from the seed file.

    Returns the cache contents (hull→entry). On first-ever startup,
    this writes ``carrier_cache.json`` so subsequent restarts skip the
    seed entirely. Operator-deleted caches re-bootstrap the same way —
    operators can use that to "reset" carrier positions, but it's an
    explicit operator action.
    """
    if CACHE_FILE.exists():
        return _load_cache()

    seed = _load_seed()
    if not seed:
        # No seed file either. Build a homeport-default cache so the
        # first save_cache call still produces something honest.
        homeports: Dict[str, dict] = {}
        for hull in CARRIER_REGISTRY:
            entry = _homeport_entry_for(hull)
            if entry is not None:
                homeports[hull] = entry
        if homeports:
            _save_cache(homeports)
        return homeports

    # Persist the seed as the first cache so subsequent runs skip this branch.
    _save_cache(seed)
    logger.info("Carrier cache bootstrapped from seed (first-ever startup)")
    return dict(seed)


def _match_region(text: str) -> Optional[tuple]:
    """Match a text string against known regions, return (lat, lng) or None."""
    text_lower = text.lower()
    for region, coords in sorted(REGION_COORDS.items(), key=lambda x: -len(x[0])):
        if region in text_lower:
            return coords
    return None


def _match_carrier(text: str) -> Optional[str]:
    """Match a text string against known carrier names/hull numbers."""
    text_lower = text.lower()
    for hull, info in CARRIER_REGISTRY.items():
        hull_check = hull.lower().replace("-", "")
        name_parts = info["name"].lower()
        if hull.lower() in text_lower or hull_check in text_lower.replace("-", ""):
            return hull
        ship_name = name_parts.split("(")[0].strip()
        last_name = ship_name.split()[-1] if ship_name else ""
        if last_name and len(last_name) > 3 and last_name in text_lower:
            return hull
    return None


def _fetch_gdelt_carrier_news() -> List[dict]:
    """Search GDELT for recent carrier movement news."""
    global _last_gdelt_fetch_at, _cached_gdelt_articles

    now = time.time()
    if _cached_gdelt_articles and (now - _last_gdelt_fetch_at) < _GDELT_FETCH_INTERVAL_SECONDS:
        logger.info("Carrier OSINT: using cached GDELT article set to avoid startup bursts")
        return list(_cached_gdelt_articles)

    results = []
    search_terms = [
        "aircraft+carrier+deployed",
        "carrier+strike+group+navy",
        "USS+Nimitz+carrier",
        "USS+Ford+carrier",
        "USS+Eisenhower+carrier",
        "USS+Vinson+carrier",
        "USS+Roosevelt+carrier+navy",
        "USS+Lincoln+carrier",
        "USS+Truman+carrier",
        "USS+Reagan+carrier",
        "USS+Washington+carrier+navy",
        "USS+Bush+carrier",
        "USS+Stennis+carrier",
    ]

    for idx, term in enumerate(search_terms):
        try:
            url = f"https://api.gdeltproject.org/api/v2/doc/doc?query={term}&mode=artlist&maxrecords=5&format=json&timespan=14d"
            raw = fetch_with_curl(url, timeout=8)
            if getattr(raw, "status_code", 500) == 429:
                logger.warning(
                    "GDELT returned 429 for '%s'; preserving cached carrier OSINT results",
                    term,
                )
                continue
            if not raw or not hasattr(raw, "text"):
                continue
            data = raw.json()
            articles = data.get("articles", [])
            for art in articles:
                title = art.get("title", "")
                article_url = art.get("url", "")
                article_at = art.get("seendate") or art.get("date") or ""
                results.append({"title": title, "url": article_url, "seendate": article_at})
        except (ConnectionError, TimeoutError, ValueError, KeyError, OSError) as e:
            logger.debug(f"GDELT search failed for '{term}': {e}")
            continue
        if idx < len(search_terms) - 1:
            time.sleep(
                _GDELT_REQUEST_DELAY_SECONDS
                + random.uniform(0.0, _GDELT_REQUEST_JITTER_SECONDS)
            )

    _cached_gdelt_articles = list(results)
    _last_gdelt_fetch_at = time.time()
    logger.info(f"Carrier OSINT: found {len(results)} GDELT articles")
    return results


def _gdelt_seendate_to_iso(seendate: str) -> Optional[str]:
    """GDELT returns YYYYMMDDhhmmss (UTC). Convert to ISO8601 for
    position_source_at. Returns None if the input is unparseable."""
    raw = (seendate or "").strip()
    if len(raw) < 8 or not raw.isdigit():
        return None
    try:
        dt = datetime.strptime(raw[:14] if len(raw) >= 14 else raw[:8] + "000000", "%Y%m%d%H%M%S")
        return dt.replace(tzinfo=timezone.utc).isoformat()
    except (TypeError, ValueError):
        return None


def _parse_carrier_positions_from_news(articles: List[dict]) -> Dict[str, dict]:
    """Parse carrier positions from news article titles.

    Issue #245 (tg12): the position is a region centroid, which is
    coarse — we now stamp ``position_confidence = "approximate"`` so
    the UI can render that uncertainty. Issue #244: the
    ``position_source_at`` field is the news article's actual seen
    date, NOT now(), so the freshness check correctly flips entries
    to "stale" once they age past the configured window.
    """
    updates: Dict[str, dict] = {}

    for article in articles:
        title = article.get("title", "")
        hull = _match_carrier(title)
        if not hull:
            continue
        coords = _match_region(title)
        if not coords:
            continue

        # First match wins (most recent article, GDELT returns newest first
        # per term).
        if hull not in updates:
            iso_at = _gdelt_seendate_to_iso(str(article.get("seendate", ""))) or _now_iso()
            updates[hull] = {
                "lat": coords[0],
                "lng": coords[1],
                "heading": 0,
                "desc": title[:100],
                "source": "GDELT News API (headline region match — approximate)",
                "source_url": article.get("url", "https://api.gdeltproject.org"),
                "position_source_at": iso_at,
                # Headline-to-centroid match is explicitly approximate.
                "position_confidence": "approximate",
            }
            logger.info(
                "Carrier update: %s → %s (from: %s)",
                CARRIER_REGISTRY[hull]["name"],
                coords,
                title[:80],
            )

    return updates


def _enrich_for_rendering(hull: str, entry: dict, *, now: Optional[datetime] = None) -> dict:
    """Add live computed fields (confidence label, last_osint_update)
    on top of the persisted cache entry. The persisted entry is left
    untouched; this function builds the public-facing object.
    """
    info = CARRIER_REGISTRY.get(hull, {})
    confidence = _compute_position_confidence(entry, now=now)
    return {
        "name": entry.get("name", info.get("name", hull)),
        "lat": entry["lat"],
        "lng": entry["lng"],
        "heading": entry.get("heading", 0),
        "desc": entry.get("desc", ""),
        "wiki": entry.get("wiki", info.get("wiki", "")),
        "source": entry.get("source", "OSINT estimated position"),
        "source_url": entry.get("source_url", ""),
        "position_source_at": entry.get("position_source_at", ""),
        "position_confidence": confidence,
        # Existing field preserved for backward compatibility with the
        # current frontend ShipPopup; now reflects the SOURCE's observed
        # time (not now()), so "last reported X days ago" is honest.
        "last_osint_update": entry.get("position_source_at", ""),
        # Convenience boolean for the UI: true when the position is
        # NOT live OSINT (used to render dimmed icons / badges).
        "is_fallback": confidence in {"seed", "stale", "stale_approximate", "homeport_default"},
    }


def update_carrier_positions() -> None:
    """Refresh carrier positions.

    Phase 1 (instant): publish whatever's in carrier_cache.json (or
    bootstrap from seed on first-ever run), so the map has carriers
    immediately.

    Phase 2 (slow): query GDELT and replace position entries for any
    carrier mentioned in fresh news. Persist back to cache.
    """
    global _last_update

    # --- Phase 1: instant cache (bootstrap from seed on first-ever run) ---
    positions = _bootstrap_cache_if_missing()

    # Ensure every registered hull has SOMETHING in the cache. A hull
    # the seed didn't cover (e.g. added after install) renders at its
    # homeport with "homeport_default" confidence.
    for hull in CARRIER_REGISTRY:
        if hull not in positions:
            entry = _homeport_entry_for(hull)
            if entry is not None:
                positions[hull] = entry

    with _positions_lock:
        if not _carrier_positions:
            _carrier_positions.update(positions)
            _last_update = datetime.now(timezone.utc)
    logger.info(
        "Carrier tracker: %d carriers loaded from cache (USNI + GDELT enrichment starting...)",
        len(positions),
    )

    # --- Phase 2: USNI Fleet & Marine Tracker (PRIMARY source) ---
    #
    # USNI publishes a weekly editorial tracker with each carrier's
    # actual operating area, parsed from explicit prose like
    #   "The Gerald R. Ford Carrier Strike Group is operating in the Red Sea"
    # These positions are tagged ``position_confidence: "recent"`` because
    # they reflect actual reporting, not headline-keyword centroids.
    # USNI updates are preferred over GDELT — they're authoritative on
    # US Navy positions where GDELT is just article-title text mining.
    try:
        from services.fetchers.usni_fleet_tracker import (
            fetch_latest_fleet_tracker_positions,
        )
        usni_positions = fetch_latest_fleet_tracker_positions()
        for hull, pos in usni_positions.items():
            positions[hull] = pos
            logger.info(
                "Carrier USNI update: %s → %s",
                CARRIER_REGISTRY[hull]["name"],
                pos.get("desc", ""),
            )
    except Exception as e:
        logger.warning("USNI fleet-tracker fetch failed: %s", e)

    # --- Phase 3: GDELT enrichment (SECONDARY — fills gaps) ---
    #
    # Used only to backfill carriers USNI didn't mention this week. The
    # position is stamped ``approximate`` so the UI knows it's a
    # headline-centroid match (Issue #245).
    try:
        articles = _fetch_gdelt_carrier_news()
        news_positions = _parse_carrier_positions_from_news(articles)
        for hull, pos in news_positions.items():
            # Only overwrite if the existing entry is NOT a recent USNI
            # observation. A "recent" USNI position is higher-confidence
            # than a GDELT headline-centroid match — don't let GDELT
            # demote a real position to an approximate one.
            existing = positions.get(hull, {})
            existing_conf = _compute_position_confidence(existing)
            if existing_conf == "recent":
                continue
            positions[hull] = pos
            logger.info(
                "Carrier OSINT: updated %s from GDELT news",
                CARRIER_REGISTRY[hull]["name"],
            )
    except (ValueError, KeyError, json.JSONDecodeError, OSError) as e:
        logger.warning("GDELT carrier fetch failed: %s", e)

    with _positions_lock:
        _carrier_positions.clear()
        _carrier_positions.update(positions)
        _last_update = datetime.now(timezone.utc)

    _save_cache(positions)

    confidences: Dict[str, int] = {}
    for entry in positions.values():
        label = _compute_position_confidence(entry)
        confidences[label] = confidences.get(label, 0) + 1
    logger.info("Carrier tracker: %d carriers updated. Confidence: %s", len(positions), confidences)


def _deconflict_positions(result: List[dict]) -> List[dict]:
    """Offset carriers that share identical coordinates so they don't stack."""
    from collections import defaultdict

    groups: dict[str, list[int]] = defaultdict(list)
    for i, c in enumerate(result):
        key = f"{round(c['lat'], 2)},{round(c['lng'], 2)}"
        groups[key].append(i)

    for indices in groups.values():
        if len(indices) < 2:
            continue
        n = len(indices)
        sample = result[indices[0]]
        at_port = any(
            abs(sample["lat"] - info.get("homeport_lat", 0)) < 0.05
            and abs(sample["lng"] - info.get("homeport_lng", 0)) < 0.05
            for info in CARRIER_REGISTRY.values()
        )

        if at_port:
            for idx in indices:
                carrier = result[idx]
                hull = None
                for h, info in CARRIER_REGISTRY.items():
                    if info["name"] == carrier["name"]:
                        hull = h
                        break
                if hull:
                    info = CARRIER_REGISTRY[hull]
                    carrier["lat"] = info["homeport_lat"]
                    carrier["lng"] = info["homeport_lng"]
        else:
            spacing = 0.08
            start_offset = -(n - 1) * spacing / 2
            for j, idx in enumerate(indices):
                result[idx]["lng"] += start_offset + j * spacing

    return result


def get_carrier_positions() -> List[dict]:
    """Return current carrier positions for the data pipeline.

    Each entry has the full provenance + freshness fields; the UI can
    decide how to render them. Carriers are never hidden — only
    labeled.
    """
    now = datetime.now(timezone.utc)
    with _positions_lock:
        result: List[dict] = []
        for hull, entry in _carrier_positions.items():
            enriched = _enrich_for_rendering(hull, entry, now=now)
            result.append(
                {
                    "name": enriched["name"],
                    "type": "carrier",
                    "lat": enriched["lat"],
                    "lng": enriched["lng"],
                    "heading": None,  # OSINT cannot determine true heading.
                    "sog": 0,
                    "cog": 0,
                    "country": "United States",
                    "desc": enriched["desc"],
                    "wiki": enriched["wiki"],
                    "estimated": True,
                    "source": enriched["source"],
                    "source_url": enriched["source_url"],
                    "last_osint_update": enriched["last_osint_update"],
                    # New fields (additive — existing UI continues to work):
                    "position_source_at": enriched["position_source_at"],
                    "position_confidence": enriched["position_confidence"],
                    "is_fallback": enriched["is_fallback"],
                }
            )
        return _deconflict_positions(result)


# -----------------------------------------------------------------
# Scheduler: runs at startup, then at 00:00 and 12:00 UTC daily.
# -----------------------------------------------------------------
_scheduler_thread: Optional[threading.Thread] = None
_scheduler_stop = threading.Event()


def _scheduler_loop():
    """Background thread that triggers updates at 00:00 and 12:00 UTC."""
    try:
        update_carrier_positions()
    except Exception as e:
        logger.error(f"Carrier tracker initial update failed: {e}")

    while not _scheduler_stop.is_set():
        now = datetime.now(timezone.utc)
        hour = now.hour
        if hour < 12:
            next_hour = 12
        else:
            next_hour = 24  # midnight = next day 00:00

        next_run = now.replace(hour=next_hour % 24, minute=0, second=0, microsecond=0)
        if next_hour == 24:
            next_run = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)

        wait_seconds = (next_run - now).total_seconds()
        logger.info(
            "Carrier tracker: next update at %s (%.1fh)",
            next_run.isoformat(),
            wait_seconds / 3600,
        )

        if _scheduler_stop.wait(timeout=wait_seconds):
            break

        try:
            update_carrier_positions()
        except Exception as e:
            logger.error(f"Carrier tracker scheduled update failed: {e}")


def start_carrier_tracker():
    """Start the carrier tracker background thread."""
    global _scheduler_thread
    if _scheduler_thread and _scheduler_thread.is_alive():
        return
    _scheduler_stop.clear()
    _scheduler_thread = threading.Thread(
        target=_scheduler_loop, daemon=True, name="carrier-tracker"
    )
    _scheduler_thread.start()
    logger.info("Carrier tracker started")


def stop_carrier_tracker():
    """Stop the carrier tracker background thread."""
    _scheduler_stop.set()
    if _scheduler_thread:
        _scheduler_thread.join(timeout=5)
    logger.info("Carrier tracker stopped")
