"""Local NAT-classification cache keyed on a network fingerprint.

NAT type + port-delta is a stable property of the network a host is
attached to -- it changes only when the host moves networks or the
router's behaviour shifts. Classifying it costs ~2s of STUN probing on
every node start.

This module lets a node skip that probe when it can cheaply prove it is
on the same network as a previous run: it fingerprints the current
network (interface names + local addressing + default gateways) and
looks the NAT result up in a small JSON file in the user's home dir.

Trust-first policy (2026-05-23):
  Previously the cache existed but `classify_nat_background` ran the
  real STUN probe on every cold start regardless, overwriting the
  cached value with whatever the latest measurement returned.  That
  exposed the cache to poisoning by a single transient bad measurement
  -- the same `delta_test` flakiness we just spent the day fixing
  could write RANDOM over a previously-confident PRESERV.  Asymmetric
  cost argument: trusting a stale cache too much costs at most one
  failed punch attempt (recoverable, ~10s, fall back to TURN); not
  trusting it costs full classification latency + STUN load on every
  cold start (unbounded), plus exposure to the classifier's own
  flakiness on every run.

  The new policy treats the cache as the primary source of truth:
    * Fresh hit (age < NAT_CACHE_DEFAULT_TTL_S): use it, skip
      classification entirely.
    * Stale hit (age >= TTL): use it for fast startup, refresh in
      background.
    * Miss: full classification, write to cache.
    * Punch failure: caller invalidates the entry via
      nat_cache_invalidate so the next cold start re-classifies.

Schema version 2 wraps each fingerprint entry with a timestamp:
    { fingerprint_hex: { "ts": <epoch>, "nics": { nic_name: nat_dict } } }

Schema version 1 (the original) had nat dicts directly at the
fingerprint key.  `nat_cache_get` detects the legacy shape and treats
it as stale (age unknown -> not fresh), so on the first run after
upgrade the BG classify refreshes and rewrites in v2 shape.
"""
import ipaddress
import os
import time

from ...utility.utils import log, log_exception, fstr
from ...utility.hashing import sha256_hex_short
from ...utility.jsonfile import load_json_or_default, atomic_write_json


# One small JSON object in the user's home dir:
#   v2 shape:  { fingerprint_hex: { "ts": <epoch>, "nics": { ... } } }
#   v1 shape:  { fingerprint_hex: { nic_name: nat_dict } }
NAT_CACHE_PATH = os.path.join(
    os.path.expanduser("~"), ".aionetiface_nat_cache.json"
)

# Cap on remembered networks so the file cannot grow without bound.
NAT_CACHE_MAX_NETWORKS = 16

# Cached entries older than this are still seedable for fast startup
# but the BG classify will refresh them.  7 days strikes a balance:
# long enough that day-to-day startups never re-classify on a stable
# network, short enough that a NAT behaviour shift gets re-measured
# within a week even if no punch failure triggered re-classification.
NAT_CACHE_DEFAULT_TTL_S = 7 * 24 * 3600


def gateway_ips(nic):
    """Return a sorted, de-duplicated list of default-gateway IP strings for a NIC."""
    out = []
    try:
        gws = nic.netifaces.gateways()
    except Exception:  # pylint: disable=broad-except
        return out
    if not isinstance(gws, dict):
        return out
    for af_key in list(gws.keys()):
        if af_key == "default":
            continue
        entries = gws.get(af_key)
        if not isinstance(entries, (list, tuple)):
            continue
        for entry in entries:
            # netifaces gateway entries look like (addr, ifname[, is_default]).
            if entry and isinstance(entry, (list, tuple)):
                out.append(str(entry[0]))
    return sorted(set(out))


def prefix6(addr):
    """Return the /64 network prefix of an IPv6 address, or '' on failure.

    The host's own IPv6 address is unstable -- SLAAC privacy extensions
    (RFC 4941) rotate the 64-bit interface suffix every few hours, even
    on the same network. Only the /64 prefix is stable; that is the
    actual network. Fingerprinting on the prefix avoids permanent false
    cache misses on IPv6-privacy hosts.
    """
    if not addr:
        return ""
    try:
        net = ipaddress.ip_network(addr + "/64", strict=False)
        return str(net.network_address)
    except (ValueError, TypeError):
        return ""


def network_fingerprint(ifs):
    """Return a stable hex string identifying the network `ifs` are attached to.

    Built from each interface's name, its primary IPv4 address, its
    IPv6 /64 prefix and the default gateway IPs it sees. Same LAN ->
    same fingerprint; moving networks changes the gateway and/or local
    addressing and so changes the fingerprint.

    The host's full IPv6 address is deliberately NOT used -- privacy
    extensions rotate it on a fixed network. The /64 prefix is the
    stable part. A hint for the NAT cache only.
    """
    from ...net.net_defs import IP4, IP6

    parts = []
    for nic in sorted(ifs, key=lambda n: str(getattr(n, "name", ""))):
        name = str(getattr(nic, "name", ""))
        ip4 = ""
        ip6net = ""
        try:
            ip4 = str(nic.nic(IP4) or "")
        except Exception:  # pylint: disable=broad-except
            ip4 = ""
        try:
            ip6net = prefix6(str(nic.nic(IP6) or ""))
        except Exception:  # pylint: disable=broad-except
            ip6net = ""
        gws = ",".join(gateway_ips(nic))
        parts.append("|".join((name, ip4, ip6net, gws)))
    raw = "\n".join(parts).encode("utf-8")
    return sha256_hex_short(raw, 32)


def load_nat_cache():
    """Read the whole NAT cache file; return an empty dict on any error."""
    data = load_json_or_default(NAT_CACHE_PATH, dict)
    if isinstance(data, dict):
        return data
    return {}


def nat_cache_get(fingerprint, max_age_s=NAT_CACHE_DEFAULT_TTL_S):
    """Return (nics_dict, is_fresh) for this fingerprint.

    nics_dict is the {nic_name: nat_dict} mapping (the same shape
    callers used to receive directly) or None on miss.  is_fresh is
    True only when the entry's recorded timestamp is younger than
    max_age_s.  Schema v1 entries (no timestamp) come back as
    (nics_dict, False) so legacy installations refresh on first run.

    Callers that need fast seeding regardless of freshness should
    consult nics_dict; callers deciding whether to skip the BG
    classify should also gate on is_fresh.
    """
    entry = load_nat_cache().get(fingerprint)
    if not isinstance(entry, dict) or not entry:
        log(fstr("[NAT-CACHE] miss fingerprint={0}", (fingerprint[:12],)))
        return (None, False)

    # v2 shape: {"ts": <epoch>, "nics": {...}}
    if "ts" in entry and "nics" in entry:
        ts = entry["ts"]
        nics = entry["nics"]
        age = max(0, int(time.time() - ts))
        is_fresh = age < max_age_s
        log(fstr(
            "[NAT-CACHE] hit fingerprint={0} age_s={1} fresh={2}",
            (fingerprint[:12], age, is_fresh),
        ))
        return (nics, is_fresh)

    # v1 legacy shape: the entry IS the nics dict.  No timestamp;
    # treat as stale so BG classify rewrites in v2.
    log(fstr(
        "[NAT-CACHE] hit (legacy v1) fingerprint={0}; will refresh in v2 shape",
        (fingerprint[:12],),
    ))
    return (entry, False)


def nat_cache_put(fingerprint, nat_by_nic):
    """Store {nic_name: nat_dict} under this fingerprint (best-effort).

    Always writes v2 shape with the current epoch as the timestamp.
    Atomic write via a temp file + os.replace so a crash mid-write
    cannot leave a half-written cache for the next run to choke on.
    """
    if not fingerprint or not isinstance(nat_by_nic, dict) or not nat_by_nic:
        return
    try:
        cache = load_nat_cache()
        cache[fingerprint] = {
            "ts": int(time.time()),
            "nics": nat_by_nic,
        }
        # Bound the file: keep the most-recently-inserted networks.
        if len(cache) > NAT_CACHE_MAX_NETWORKS:
            for stale in list(cache.keys())[:-NAT_CACHE_MAX_NETWORKS]:
                del cache[stale]
        atomic_write_json(NAT_CACHE_PATH, cache)
        log(fstr(
            "[NAT-CACHE] stored fingerprint={0} nics={1}",
            (fingerprint[:12], len(nat_by_nic)),
        ))
    except (OSError, ValueError):
        log_exception()
    except Exception:  # pylint: disable=broad-except
        log_exception()


def nat_cache_invalidate(fingerprint, nic_name=None):
    """Remove a cache entry to force re-classification on next startup.

    Two granularities:
      - nic_name=None: drop the whole fingerprint entry.
      - nic_name=<str>: drop just that NIC; other NICs under the same
        fingerprint stay valid.  If the resulting entry is empty,
        the fingerprint is removed entirely.

    Atomic write via temp file + os.replace.  No-op on errors.

    Intended caller: punch / direct-connect plugins on a failed
    attempt -- a punch that fails under the cached NAT assumption is
    the strongest evidence that the cache is stale, stronger than any
    synthetic STUN re-probe could produce.
    """
    if not fingerprint:
        return
    try:
        cache = load_nat_cache()
        entry = cache.get(fingerprint)
        if entry is None:
            return
        if nic_name is None:
            del cache[fingerprint]
            log(fstr(
                "[NAT-CACHE] invalidated fingerprint={0}",
                (fingerprint[:12],),
            ))
        else:
            # v2 vs v1 access path.
            nics = entry.get("nics") if isinstance(entry, dict) and "nics" in entry else entry
            if not isinstance(nics, dict):
                return
            if nic_name not in nics:
                return
            del nics[nic_name]
            if not nics:
                del cache[fingerprint]
                log(fstr(
                    "[NAT-CACHE] invalidated fingerprint={0} "
                    "(last nic '{1}' removed)",
                    (fingerprint[:12], nic_name),
                ))
            else:
                log(fstr(
                    "[NAT-CACHE] invalidated nic='{0}' under fingerprint={1}",
                    (nic_name, fingerprint[:12]),
                ))
        atomic_write_json(NAT_CACHE_PATH, cache)
    except (OSError, ValueError):
        log_exception()
    except Exception:  # pylint: disable=broad-except
        log_exception()
