"""Helper functions for NAT type classification."""
import asyncio
import math
import random
import time
from .nat_defs import (
    STUN_PORT,
    MAX_MAP_NO,
    OPEN_INTERNET,
    SYMMETRIC_UDP_FIREWALL,
    RESTRICT_PORT_NAT,
    SYMMETRIC_NAT,
    EQUAL_DELTA,
    PRESERV_DELTA,
    INDEPENDENT_DELTA,
    DEPENDENT_DELTA,
    RANDOM_DELTA,
    EASY_NATS,
    PREDICTABLE_NATS,
)
from ...utility.utils import (
    MAX_PORT,
    log,
    log_exception,
    async_wrap_errors,
    field_dist,
    fstr,
    range_intersects,
    intersect_range,
    in_range,
)
from ...net.net_defs import UDP


# Convenience funcs.
# delta, nat_type
def f_is_open(n, d):
    """Return True if nat type n represents an open internet connection or a symmetric UDP firewall."""
    return n in [OPEN_INTERNET, SYMMETRIC_UDP_FIREWALL]


def f_can_predict(n, d):
    """Return True if nat type n has predictable port mappings that enable hole-punching."""
    return n in PREDICTABLE_NATS


def f_is_hard(n, d):
    """Return True if nat type n is not in the easy NAT set and its delta is neither preserving nor equal."""
    not_easy = n not in EASY_NATS
    return not_easy and d["type"] not in [PRESERV_DELTA, EQUAL_DELTA]


def delta_info(delta_type=None, delta_value=None, confidence="measured"):
    """Return a delta dict with the given type, value, and confidence.

    confidence is one of:
      "measured"      - the classifier had enough data and committed.
      "probationary"  - cold-start fallback (delta_test would have
                        committed to RANDOM_DELTA but did not because
                        there was no prior cache entry; PRESERV_DELTA
                        is returned instead under the asymmetric-
                        error argument that a wrong PRESERV costs one
                        failed punch attempt while a wrong RANDOM
                        permanently disables predictive punch).  The
                        cache stores this verbatim; punch-attempt
                        failure feedback overwrites with RANDOM so
                        the next startup commits honestly.
    """
    return {"type": delta_type, "value": delta_value, "confidence": confidence}


def nat_info(
    nat_type=None,
    delta=None,
    map_range=None,
):
    """Build and return a NAT descriptor dict with type, delta, port range, and derived capability flags.

    The default (RESTRICT_PORT + EQUAL_DELTA) is intentionally a sane
    middle ground rather than the worst case: it lets the punch / probe
    plugins fall through to a predictable-port path when classification
    hasn't run, instead of triggering the SYMMETRIC role assignments
    that strand random_probe / udp_punch on hosts where load_nat was
    skipped (e.g. Gate(ifs=[pre_built_nic])).  Real classification via
    nic.load_nat() always overwrites this default with the measured
    result.
    """
    # Defaults.
    delta = delta or delta_info(None, None)
    map_range = map_range or [1, MAX_PORT]
    nat_type = nat_type

    # Main NAT dic with simple lookup types.
    nat = {
        "type": nat_type,
        "delta": delta,  # type, value
        "range": map_range,  # start, stop
        "is_open": f_is_open(nat_type, delta),
        "can_predict": f_can_predict(nat_type, delta),
        "is_hard": f_is_hard(nat_type, delta),
    }

    # Setup whether this NAT type is good for concurrent punches.
    bad_delta = delta["type"] in [INDEPENDENT_DELTA, DEPENDENT_DELTA]
    nat["is_concurrent"] = nat_type in EASY_NATS or not bad_delta

    # Return results.
    return nat


def valid_mappings_len(mappings):
    """Return 1 if mappings has a valid non-empty length within MAX_MAP_NO, else 0."""
    if not len(mappings):
        return 0

    if len(mappings) > MAX_MAP_NO:
        return 0

    return 1


async def delta_test(
    stun_clients,
    test_no=8,
    threshold=5,
    concurrency=True,
    min_ratio=0.6,
    min_valid_pairs=3,
    max_extra_rounds=1,
    early_return_confidence=1.5,
    early_return_yield=0.75,
    dynamics=None,
    is_cold_start=False,
    cold_start_retry_delay_s=5.0,
):
    """
    Probe the NAT to determine what kind of port delta behaviour it exhibits.

    Symmetric NATs allocate a new mapping for each (destination, port) pair.
    This function tries to detect whether those mappings follow a predictable
    pattern, which is needed for TCP hole-punching.

    Delta types detected:
      EQUAL_DELTA     - external port equals local source port (no NAT offset).
      PRESERV_DELTA   - distance between successive mapped ports mirrors local
                        distance (port-preserving NAT).
      INDEPENDENT_DELTA - mapped ports increment by a fixed delta regardless of
                          local source port (easy to predict).
      DEPENDENT_DELTA - mapped ports increment by the same delta as local ports
                        (harder to exploit but still predictable).
      RANDOM_DELTA    - no detectable pattern; prediction is not possible.

    Note: detection of DEPENDENT_DELTA requires sequential (non-concurrent)
    probing because concurrent results arrive out of order and produce false
    negatives.  Pass concurrency=False to ensure correct results for that case.

    Port test planning:
      - start_port is chosen randomly to avoid conflicts with other test ranges.
      - Ports run from start_port to start_port + (test_no * port_dist) - 1.

    Resilience strategy (2026-05-23 rewrite):
      The classifier is two rounds of `test_no` STUN probes: round 1 uses
      RANDOM local source ports (best for EQUAL / PRESERV / INDEPENDENT),
      round 2 uses SEQUENTIAL local source ports (best for EQUAL / PRESERV /
      DEPENDENT).  Each round's counters are populated by `get_delta_value`
      which only credits a pair when BOTH consecutive probes returned --
      so a single dropped probe wipes one or two pair-positions.

      The old algorithm used an absolute `threshold` count (default 5).  On
      a mobile / flaky NIC where 2-3 of 8 probes time out, the valid pair
      count drops to 3-5 and the absolute threshold becomes mathematically
      unreachable even when 100% of valid pairs show PRESERV -- so a
      PRESERV NAT was classified as RANDOM 67% of cold-starts (measured
      against the Win10 dual-NIC matrix on 2026-05-23).

      The new rules:

      1. `valid_pairs` is counted explicitly per round.  A "pair" is
         results[i-1] AND results[i] both non-None with mapped ports.
      2. A classification wins when:
             count >= max(min_valid_pairs,
                          min(threshold, ceil(valid_pairs * min_ratio)))
         A clean 11-pair run still needs the original absolute 5 (because
         ceil(11*0.6)=7 caps at min(5,7)=5).  A noisy 4-pair run with 3
         PRESERV hits passes (3 >= max(3, min(5, ceil(2.4)))=3).
      3. EQUAL / PRESERV are evaluated against COMBINED valid_pairs from
         both rounds (either round can observe them).  INDEPENDENT is
         only evaluated against round-1 valid_pairs (random local ports);
         DEPENDENT is only evaluated against round-2 valid_pairs
         (sequential local ports).  Round-irrelevant counters aren't
         credited.
      4. If no classification wins after the standard two rounds AND
         `max_extra_rounds > 0`, run an additional sequential round and
         re-evaluate.  Sequential because PRESERV is the historical
         misclassification target and sequential rounds are where PRESERV
         signal is densest.
      5. Final fall-through: return RANDOM_DELTA only when BOTH rounds
         had at least `min_valid_pairs` valid pairs.  Otherwise return
         None (genuinely insufficient data, not a measured-random
         pattern).

      6. Optional Round-1 early-return when the signal is unambiguous.
         After Round 1 (random local ports), if some classification's
         count >= required * early_return_confidence AND Round 1's
         probe yield (valid_pairs / max possible) >= early_return_yield,
         skip Round 2 and return immediately.  This buys back the
         "clean EQUAL / INDEPENDENT NATs take 1 round" latency that the
         old early-return path had, without the historical false-
         positive risk: we only early-return when both signal strength
         and probe yield are clearly above the noise floor.  Borderline
         runs still go to Round 2 + retry.  Set
         `early_return_confidence=0` to disable.
    """
    # Pull adaptive per-probe timeout + effective probe count from the
    # connection_dynamics model if supplied.  Cold-start (no samples yet
    # in the WAN-UDP-AF bucket) returns the caller-supplied defaults so
    # behaviour matches the pre-dynamics codepath for the first run.
    per_probe_timeout_s = 2.0
    if dynamics is not None and stun_clients:
        dest_ip_for_bucket = stun_clients[0].dest[0]
        af_for_bucket = stun_clients[0].af
        per_probe_timeout_s = dynamics.timeout_s(
            dest_ip_for_bucket, af_for_bucket, UDP, default=2.0,
        )
        suggested_n = dynamics.probe_count(
            dest_ip_for_bucket, af_for_bucket, UDP, default=test_no,
        )
        # Never exceed the available stun_clients (the assert below).
        effective_test_no = min(suggested_n, len(stun_clients))
        if effective_test_no != test_no:
            log(fstr(
                "[delta_test] dynamics adjusted test_no {0} -> {1} "
                "(probe_timeout_s={2:.2f})",
                (test_no, effective_test_no, per_probe_timeout_s),
            ))
        test_no = effective_test_no
    elif dynamics is not None:
        # No stun_clients to source a dest_ip from -- still honour the
        # default-returning behaviour of dynamics for completeness.
        per_probe_timeout_s = dynamics.timeout_s(
            None, None, UDP, default=2.0,
        )

    assert len(stun_clients) >= test_no

    def get_start_port(port_dist, range_info=None):
        """Pick a random starting port that does not overlap any existing port-test ranges."""
        if range_info is None:
            range_info = []
        def rand_start_port():
            """Return a random port number safely below the upper bound for the test range."""
            return random.randrange(4000, MAX_PORT - (test_no * port_dist))

        # Return if no other port range to check for conflicts.
        if not range_info:
            return rand_start_port()
        new_start_port = None
        do_retry = 1
        while do_retry:
            # Try a rand port as the starting port.
            do_retry = 0
            new_start_port = rand_start_port()
            for other_range in range_info:
                # Range is other_start_port to other_end_port inclusive.
                other_dist, other_start_port = other_range
                other_end_port = other_start_port + (test_no * other_dist)

                # If it's in the same range as other_start_port retry.
                lower_bound = new_start_port >= other_start_port
                upper_bound = new_start_port <= other_end_port
                if lower_bound and upper_bound:
                    do_retry = 1
                    break

        return new_start_port

    # Create a list of tasks to get a mapping for a port range.
    def get_port_tests(start_port, port_dist=1):
        """Build a list of asyncio tasks that each send a STUN probe from a calculated source port."""
        # Return task list for tests.
        tasks = []
        for i in range(0, test_no):
            # If start is defined then calculate a list of ports.
            # Otherwise the OS assigns an unused port.
            if start_port:
                src_port = start_port + (i * port_dist)
            else:
                src_port = random.randrange(4000, MAX_PORT)

            # Get the mapping using STUN.
            async def result_wrapper(src_port, stun_idx=i):
                """Run one STUN mapping probe from src_port and return [local, mapped, socket] or None."""
                # Make sure port isn't in the reserved range.
                if src_port < 4000 and src_port != 0:
                    raise ValueError("src less than 4k in mapping behavior.")

                # Round-robin across servers so each probe hits a distinct
                # server; random.choice with replacement left ~36% unsampled.
                stun_client = stun_clients[stun_idx % len(stun_clients)]

                # Get mapping using specific source port.
                route = None
                if stun_client.interface is not None:
                    iface = stun_client.interface
                    route = iface.route(stun_client.af)
                    await route.bind(port=src_port)

                # TODO: manually chosen source ports may conflict with
                # ports already in use.  Consider checking for failure here.

                # Time the get_mapping call for dynamics observation.
                # Failure / loss is observed by the timed_probe wrapper
                # below (wait_for catches the timeout outside this body).
                probe_t0 = time.monotonic()
                ret = await stun_client.get_mapping(pipe=route)
                probe_rtt_s = time.monotonic() - probe_t0
                if ret is None:
                    if dynamics is not None:
                        dynamics.observe_loss(
                            stun_client.dest[0], stun_client.af, UDP,
                        )
                    return None
                if dynamics is not None:
                    dynamics.observe_ok(
                        stun_client.dest[0], stun_client.af, UDP, probe_rtt_s,
                    )

                local, mapped, s = ret

                # Return mapping results.
                return [local, mapped, s]

            # async_wrap_errors swallows asyncio.TimeoutError from
            # wait_for; to keep the dynamics loss-rate updated we wrap
            # with our own timeout-aware shim that calls observe_loss
            # on the outside if the inner probe timed out.  Same task
            # shape returned to callers (coroutine that yields a
            # result-list or None).
            async def timed_probe(src_port=src_port, stun_idx=i):
                stun_client = stun_clients[stun_idx % len(stun_clients)]
                try:
                    return await asyncio.wait_for(
                        result_wrapper(src_port, stun_idx),
                        per_probe_timeout_s,
                    )
                except asyncio.TimeoutError:
                    if dynamics is not None:
                        dynamics.observe_loss(
                            stun_client.dest[0], stun_client.af, UDP,
                        )
                    return None

            # Allow for tests to be done concurrently.
            tasks.append(
                async_wrap_errors(timed_probe(), logging=False)
            )

        return tasks

    def get_delta_value(delta_no, dist_no, local_dist, preserv_dist, results):
        """Classify STUN mapping results into delta counters (equal, preserving, independent, dependent)."""
        if results is None:
            return

        for i in range(0, len(results)):
            try:
                # Skip invalid results
                if results[i] is None:
                    continue

                # Unpack result.
                local, mapped, s = results[i]
                socks.append(s)
                if mapped is None:
                    continue

                # Set previous result if available.  results[i-1] can
                # be None when the previous STUN probe didn't complete
                # (timeout / parse failure); guard the subscript so we
                # don't raise TypeError in the loop body and trigger a
                # log_exception() per affected probe.  A None previous
                # result simply means "no comparison data available",
                # which is the correct semantics for this round.
                prev_result = None
                if i != 0 and results[i - 1] is not None:
                    if results[i - 1][MAPPED_INDEX] is not None:
                        prev_result = results[i - 1]

                # Preserving NAT.
                if local == mapped:
                    delta_no[EQUAL_DELTA] = delta_no.get(EQUAL_DELTA, 0) + 1

                # Comparison tests.
                if prev_result is not None:
                    # Skip invalid results.
                    prev_local = prev_result[LOCAL_INDEX]
                    prev_mapped = prev_result[MAPPED_INDEX]
                    if not prev_local or not prev_mapped:
                        continue

                    # Preserving delta if true.
                    _local_dist = abs(field_dist(local, prev_local, MAX_PORT))
                    mapped_dist = abs(field_dist(mapped, prev_mapped, MAX_PORT))
                    if mapped_dist == _local_dist:
                        # Otherwise its preserving.
                        if mapped != local:
                            preserv_dist[mapped_dist] = 1
                            delta_no[PRESERV_DELTA] = delta_no.get(PRESERV_DELTA, 0) + 1
                    else:
                        # Delta mapping dist.
                        # Plus one NAT type now here.
                        if _local_dist != 1:
                            dist_no[mapped_dist] = dist_no.get(mapped_dist, 0) + 1
                        else:
                            local_dist[mapped_dist] = local_dist.get(mapped_dist, 0) + 1
            except (ValueError, KeyError, TypeError):
                log_exception()

    # Offset names for port test results.
    LOCAL_INDEX = 0  # Source port.
    MAPPED_INDEX = 1  # External mapped port.
    socks = []

    # Used for info about port ranges used for tests.
    # [ [ dist, start_port ], ... ]
    range_info = []

    # Carry-merge counters across all rounds.  EQUAL / PRESERV credit
    # against combined valid_pairs (either round detects them); INDEPENDENT
    # credits only against random-port rounds; DEPENDENT only against
    # sequential-port rounds.
    delta_no = {}
    dist_no = {}
    preserv_dist = {}
    local_dist = {}
    valid_pairs_random = 0   # accumulated across random-port rounds
    valid_pairs_seq = 0      # accumulated across sequential-port rounds

    def count_valid_pairs(results):
        """Pairs where both consecutive probes returned a mapped port."""
        n = 0
        for i in range(1, len(results)):
            a = results[i - 1]
            b = results[i]
            if a is None or b is None:
                continue
            if a[MAPPED_INDEX] is None or b[MAPPED_INDEX] is None:
                continue
            n += 1
        return n

    def required_count(valid_pairs):
        """Adaptive threshold: small enough to win on noisy runs that
        nevertheless show a clean signal, large enough to demand real
        evidence on clean runs.  See module docstring for derivation."""
        ratio_floor = int(math.ceil(valid_pairs * min_ratio))
        return max(min_valid_pairs, min(threshold, ratio_floor))

    async def run_round(start_port_value):
        """One round of test_no probes; returns (results, valid_pairs)."""
        tasks = get_port_tests(start_port_value)
        if concurrency:
            results = await asyncio.gather(*tasks)
        else:
            results = []
            for task in tasks:
                r = await task
                results.append(r)
        return results, count_valid_pairs(results)

    async def close_round_socks():
        """Close any sockets accumulated by the most recent get_delta_value call."""
        for p in socks:
            if p is None:
                continue
            try:
                await p.close()
            except (OSError, ConnectionError, asyncio.TimeoutError):
                pass
        del socks[:]

    def try_classify():
        """Return delta_info or None given current counter state.

        EQUAL / PRESERV win on COMBINED pair count (either round detects).
        INDEPENDENT requires round-1 (random) signal.
        DEPENDENT requires round-2 (sequential) signal.
        """
        combined_pairs = valid_pairs_random + valid_pairs_seq

        # EQUAL / PRESERV across combined rounds.
        if combined_pairs >= min_valid_pairs:
            req = required_count(combined_pairs)
            for test_name in (EQUAL_DELTA, PRESERV_DELTA):
                no = delta_no.get(test_name, 0)
                if no >= req:
                    log(fstr(
                        "[delta_test] -> {0} (count={1} valid_pairs={2} req={3})",
                        (test_name, no, combined_pairs, req),
                    ))
                    return delta_info(test_name, 0)

        # INDEPENDENT: random-port rounds only.
        if valid_pairs_random >= min_valid_pairs:
            req = required_count(valid_pairs_random)
            for port_dist in list(dist_no.keys()):
                no = dist_no[port_dist]
                if no >= req:
                    return delta_info(INDEPENDENT_DELTA, port_dist)

        # DEPENDENT: sequential-port rounds only.
        if valid_pairs_seq >= min_valid_pairs:
            req = required_count(valid_pairs_seq)
            for port_dist in list(local_dist.keys()):
                no = local_dist[port_dist]
                if no >= req:
                    return delta_info(DEPENDENT_DELTA, port_dist)

        return None

    # ---- Round 1: random local source ports ---------------------------------
    results, vp = await run_round(0)
    get_delta_value(delta_no, dist_no, local_dist, preserv_dist, results)
    valid_pairs_random += vp
    await close_round_socks()

    # Optional early return on a Round 1 verdict that is BOTH well above
    # threshold and from a probe round that had high yield.  Restores the
    # 1-round latency for clean EQUAL / INDEPENDENT / (rarely) PRESERV
    # NATs without falling back into the old algorithm's "1 missing probe
    # = wrong answer" trap: a borderline result fails the confidence
    # multiplier, runs Round 2, and benefits from the resilience path.
    max_possible_pairs = max(test_no - 1, 1)
    yield_ratio = valid_pairs_random / float(max_possible_pairs)
    if (
        early_return_confidence > 0
        and yield_ratio >= early_return_yield
        and valid_pairs_random >= min_valid_pairs
    ):
        req_base = required_count(valid_pairs_random)
        confident_req = int(math.ceil(req_base * early_return_confidence))
        # EQUAL / PRESERV from Round 1 alone are credible when confident.
        for test_name in (EQUAL_DELTA, PRESERV_DELTA):
            no = delta_no.get(test_name, 0)
            if no >= confident_req:
                return delta_info(test_name, 0)
        # INDEPENDENT: random-port round is its native habitat.
        for port_dist in list(dist_no.keys()):
            no = dist_no[port_dist]
            if no >= confident_req:
                return delta_info(INDEPENDENT_DELTA, port_dist)

    # ---- Round 2: sequential local source ports -----------------------------
    start_port = get_start_port(1, range_info)
    range_info.append([1, start_port])
    results, vp = await run_round(start_port)
    get_delta_value(delta_no, dist_no, local_dist, preserv_dist, results)
    valid_pairs_seq += vp
    await close_round_socks()

    # Try to classify with two rounds of data.
    result = try_classify()
    if result is not None:
        return result

    # ---- Borderline: run extra sequential rounds and re-evaluate -----------
    # Sequential because PRESERV (historically the misclassified case) gets
    # denser signal from sequential local ports than from random.
    extra = 0
    while extra < max_extra_rounds:
        # On cold-start AND borderline, sleep before the extra round so
        # the new probes are temporally decorrelated from whatever
        # transient (allocator pool refresh, brief CGNAT eviction,
        # carrier state churn) made the first two rounds borderline.
        # Same-window probes all see the same "off" snapshot; a few
        # seconds of wall time lets short-lived NAT state settle.
        # Only on cold-start because: a re-classify of a known network
        # already has a confident cached answer to fall back on, so
        # paying the +5s wall is a worse trade than committing whatever
        # the synthetic probe gives us.  cold_start_retry_delay_s=0
        # disables.
        if is_cold_start and cold_start_retry_delay_s > 0:
            await asyncio.sleep(cold_start_retry_delay_s)

        start_port = get_start_port(1, range_info)
        range_info.append([1, start_port])
        results, vp = await run_round(start_port)
        get_delta_value(delta_no, dist_no, local_dist, preserv_dist, results)
        valid_pairs_seq += vp
        await close_round_socks()
        extra += 1
        result = try_classify()
        if result is not None:
            return result

    # ---- Final fall-through -------------------------------------------------
    # RANDOM_DELTA means "we measured the NAT and the pattern is genuinely
    # random" -- only credible when BOTH rounds delivered enough valid pairs
    # to have actually ruled out the other classifications.
    if valid_pairs_random < min_valid_pairs or valid_pairs_seq < min_valid_pairs:
        return None

    if is_cold_start:
        # Optimistic default: this is the first time we've classified
        # this network and the rounds were inconclusive enough that we
        # would otherwise commit to RANDOM_DELTA.  Asymmetric error
        # argument: a wrong PRESERV costs at most one failed punch
        # attempt (~10s, recoverable, fall back to TURN), but a wrong
        # RANDOM permanently disables predictive punch on this
        # network's cached classification.  Return PRESERV-probationary
        # so the caller's punch attempt acts as ground-truth; on
        # failure the cache invalidation hook (layer 4) overwrites
        # with RANDOM so subsequent startups commit honestly.
        return delta_info(PRESERV_DELTA, 0, confidence="probationary")

    return delta_info(RANDOM_DELTA, 0)


def nats_intersect(
    our_nat, their_nat, test_no
):
    """
    Return the port range both NATs can agree on for test probes.

    Some NATs (e.g. Starlink's default router) only allocate mappings from
    a fixed sub-range of ports (e.g. 35000–65535), possibly to reduce
    visibility on port scans.  When planning hole-punch tests we need a
    range that both sides can reach.

    If an intersection exists and is at least test_no ports wide, it is
    returned.  Otherwise our_nat's full range is used as a fallback.
    Port ranges below 1024 are always shifted up to 2000 to avoid requiring
    root/admin privileges.
    """
    # Calculate intersection range.
    is_intersect = range_intersects(our_nat["range"], their_nat["range"])
    if is_intersect:
        # A range that represents an overlapping portion.
        # Between our[range] and their[range] if any.
        r = intersect_range(our_nat["range"], their_nat["range"])

        # If range is long enough then use it.
        range_no = r[1] - r[0]
        if range_no >= test_no:
            use_range = r
        else:
            use_range = our_nat["range"]
    else:
        use_range = our_nat["range"]

    # Ensure bind ports don't end up in low port ranges.
    if use_range[0] <= 1024:
        use_range[0] = 2000
        if use_range[0] >= use_range[1]:
            raise ValueError("Can't find intersecting port range.")

    return use_range


def nats_can_predict(our_nat, their_nat):
    """
    Validate that mapping prediction is possible given the two NAT profiles.

    Returns the port that should be used as a reply port for STUN probes
    (1 = use STUN port 3478, 0 = use an arbitrary port).
    Raises if the NAT combination makes prediction provably impossible.
    """
    # Handle restricted port NATs (RESTRICT_PORT_NAT).
    our_strict = our_nat["type"] == RESTRICT_PORT_NAT
    other_strict = their_nat["type"] == RESTRICT_PORT_NAT
    if our_strict and other_strict:
        # Error scenario for two strict port NATs with rand deltas.
        our_rand_delta = our_nat["delta"]["type"] == RANDOM_DELTA
        their_rand_delta = their_nat["delta"]["type"] == RANDOM_DELTA
        if our_rand_delta or their_rand_delta:
            raise ValueError("Two strict port nats need non-rand deltas.")

    # If either side is port restrict make sure its partner can satisfy reply port.
    use_stun_port = 0
    if our_strict or other_strict:
        for nats in [[our_nat, their_nat], [their_nat, our_nat]]:
            # Switch which side is restricted.
            # Both may be restricted -- in which case both need non-rand delta.
            strict, unrestrict = nats
            if unrestrict["type"] == RESTRICT_PORT_NAT:
                strict, unrestrict = unrestrict, strict

            # If strict side has rand delta and partner cant satisfy reply port.
            # There are multiple ways to satisfy the reply port which are checked.
            # Raise an error condition if certain failure.
            strict_rand_delta = strict["delta"]["type"] == RANDOM_DELTA
            if strict_rand_delta and unrestrict["is_hard"]:
                raise ValueError("Unable to satisfy mapping for strict port type.")

            # The reply port here is the STUN port as they use STUN to get a mapping.
            # Unable to satisfy reply port due to allocation range.
            if strict_rand_delta and not in_range(STUN_PORT, unrestrict["range"]):
                raise ValueError("Can't support reply port 3478 for strict NAT.")

            # Use STUN port (3478) when generating probe mappings.
            # STUN's port is above 1024, so no root/admin is required — which
            # is lucky, because restricted-port NATs need us to match the
            # exact port the peer expects to receive replies on.
            if strict_rand_delta:
                use_stun_port = 1

    return use_stun_port
