<?php
// Match AirMax/receiver bridge MAC tables against known subscriber MACs without adding new device polls.
// It reads only existing AirMax probe caches + PPP/subscribers caches, then exposes matched counts and optional safe columns.

require_once __DIR__ . '/db_bootstrap.php';
require_once __DIR__ . '/airmax_monitor_lib.php';
require_once __DIR__ . '/ppp_active_cache_lib.php';
require_once __DIR__ . '/mac_ownership_lib.php';

if (!function_exists('ua_rx_norm_mac')) {
function ua_rx_norm_mac($mac): string {
    $mac = strtoupper(trim((string)$mac));
    if ($mac === '') return '';
    $mac = str_replace(['-', '.', ' '], ['', '', ''], $mac);
    $mac = preg_replace('/[^0-9A-F]/', '', $mac);
    if (strlen($mac) !== 12) return '';
    if ($mac === '000000000000' || $mac === 'FFFFFFFFFFFF') return '';
    return implode(':', str_split($mac, 2));
}
}

if (!function_exists('ua_rx_device_role')) {
function ua_rx_device_role($type): string {
    if (function_exists('airmax_device_role')) return airmax_device_role($type);
    $t = strtolower(trim((string)$type));
    $t = str_replace(['-', '_', ' '], '', $t);
    return in_array($t, ['station','receiver','client','powerbeam','cpe','استيشن','مستقبل'], true) ? 'station' : 'access';
}
}

if (!function_exists('ua_rx_device_is_station')) {
function ua_rx_device_is_station(array $device): bool {
    return ua_rx_device_role($device['device_type'] ?? '') === 'station';
}
}

if (!function_exists('ua_rx_col_exists')) {
function ua_rx_col_exists(mysqli $conn, string $table, string $col): bool {
    static $cache = [];
    $k = $table . '|' . $col;
    if (array_key_exists($k, $cache)) return $cache[$k];
    $t = $conn->real_escape_string($table);
    $c = $conn->real_escape_string($col);
    $rs = @$conn->query("SHOW COLUMNS FROM `{$t}` LIKE '{$c}'");
    return $cache[$k] = (bool)($rs && $rs->num_rows > 0);
}
}

if (!function_exists('ua_rx_table_exists')) {
function ua_rx_table_exists(mysqli $conn, string $table): bool {
    static $cache = [];
    if (array_key_exists($table, $cache)) return $cache[$table];
    $t = $conn->real_escape_string($table);
    $rs = @$conn->query("SHOW TABLES LIKE '{$t}'");
    return $cache[$table] = (bool)($rs && $rs->num_rows > 0);
}
}

if (!function_exists('ua_rx_cache_dir')) {
function ua_rx_cache_dir(): string {
    $dir = __DIR__ . '/cache/runtime/receiver_matches';
    if (!is_dir($dir)) @mkdir($dir, 0777, true);
    return $dir;
}
}

if (!function_exists('ua_rx_snapshot_file')) {
function ua_rx_snapshot_file(): string { return ua_rx_cache_dir() . '/snapshot.json'; }
}

if (!function_exists('ua_rx_lock_file')) {
function ua_rx_lock_file(): string { return ua_rx_cache_dir() . '/snapshot.lock'; }
}

if (!function_exists('ua_rx_json_read')) {
function ua_rx_json_read(string $path, $default = null) {
    if (!is_file($path)) return $default;
    $raw = @file_get_contents($path);
    $data = json_decode((string)$raw, true);
    return is_array($data) ? $data : $default;
}
}

if (!function_exists('ua_rx_json_write')) {
function ua_rx_json_write(string $path, array $data): void {
    $tmp = $path . '.tmp';
    if (@file_put_contents($tmp, json_encode($data, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES), LOCK_EX) !== false) {
        @rename($tmp, $path);
    }
}
}


if (!function_exists('ua_rx_cumulative_map_file')) {
function ua_rx_cumulative_map_file(): string { return ua_rx_cache_dir() . '/known_mac_map.json'; }
}

if (!function_exists('ua_rx_current_scan_file')) {
function ua_rx_current_scan_file(): string { return ua_rx_cache_dir() . '/current_scan.json'; }
}

if (!function_exists('ua_rx_current_scan_stage_file')) {
function ua_rx_current_scan_stage_file(): string { return ua_rx_cache_dir() . '/current_scan.stage.json'; }
}

if (!function_exists('ua_rx_current_scan_read')) {
function ua_rx_current_scan_read(): array {
    $scan = ua_rx_json_read(ua_rx_current_scan_file(), []);
    if (!is_array($scan)) $scan = [];
    if (!isset($scan['devices']) || !is_array($scan['devices'])) $scan['devices'] = [];
    return $scan + ['version'=>1,'ts'=>0,'scan_started_at'=>0,'scan_completed_at'=>0,'devices'=>[]];
}
}

if (!function_exists('ua_rx_current_scan_max_age')) {
function ua_rx_current_scan_max_age(): int {
    // receiver_match_worker is gated at 6 hours; 12 hours is a deliberate safety margin.
    return 43200;
}
}

if (!function_exists('ua_rx_current_scan_age')) {
function ua_rx_current_scan_age(array $scan): int {
    $ts = (int)($scan['scan_completed_at'] ?? $scan['ts'] ?? 0);
    if ($ts <= 0) return 999999;
    return max(0, time() - $ts);
}
}

if (!function_exists('ua_rx_current_scan_is_fresh')) {
function ua_rx_current_scan_is_fresh(array $scan): bool {
    $ts = (int)($scan['scan_completed_at'] ?? $scan['ts'] ?? 0);
    return $ts > 0 && ua_rx_current_scan_age($scan) <= ua_rx_current_scan_max_age();
}
}



if (!function_exists('ua_rx_router_port_sources_file')) {
function ua_rx_router_port_sources_file(): string { return ua_rx_cache_dir() . '/router_port_sources.json'; }
}

if (!function_exists('ua_rx_router_port_source_key')) {
function ua_rx_router_port_source_key(int $routerId, string $iface): string {
    $routerId = max(0, (int)$routerId);
    $ifaceKey = preg_replace('/[^A-Za-z0-9_.:@-]+/', '_', trim($iface));
    if ($ifaceKey === '') $ifaceKey = 'port';
    return 'router_' . $routerId . '__' . $ifaceKey;
}
}

if (!function_exists('ua_rx_router_port_pseudo_id')) {
function ua_rx_router_port_pseudo_id(int $routerId, string $iface): int {
    $routerId = max(1, (int)$routerId);
    $hash = (int)(sprintf('%u', crc32($iface)) % 9000);
    // Keep the id positive and away from real receiver ids, but still inside signed INT range.
    return 700000000 + (($routerId % 10000) * 10000) + $hash;
}
}

if (!function_exists('ua_rx_router_port_sources_read')) {
function ua_rx_router_port_sources_read(): array {
    $data = ua_rx_json_read(ua_rx_router_port_sources_file(), []);
    if (!is_array($data)) $data = [];
    if (!isset($data['ports']) || !is_array($data['ports'])) $data['ports'] = [];
    return $data + ['version'=>1,'ts'=>0,'ports'=>[]];
}
}

if (!function_exists('ua_rx_router_port_sources_write')) {
function ua_rx_router_port_sources_write(array $data): void {
    if (!isset($data['ports']) || !is_array($data['ports'])) $data['ports'] = [];
    $data['version'] = (int)($data['version'] ?? 1);
    $data['ts'] = time();
    $data['updated_at'] = date('Y-m-d H:i:s');
    ua_rx_json_write(ua_rx_router_port_sources_file(), $data);
}
}

if (!function_exists('ua_rx_router_port_sources_active')) {
function ua_rx_router_port_sources_active(): array {
    $data = ua_rx_router_port_sources_read();
    $out = [];
    foreach ((array)($data['ports'] ?? []) as $key => $entry) {
        if (!is_array($entry) || empty($entry['enabled'])) continue;
        $routerId = (int)($entry['router_id'] ?? 0);
        $iface = trim((string)($entry['interface'] ?? ''));
        if ($routerId <= 0 || $iface === '') continue;
        $macs = [];
        foreach ((array)($entry['macs'] ?? []) as $m) { $n = ua_rx_norm_mac($m); if ($n !== '') $macs[$n] = true; }
        $raw = [];
        foreach ((array)($entry['raw_macs'] ?? []) as $m) { $n = ua_rx_norm_mac($m); if ($n !== '') $raw[$n] = true; }
        foreach (array_keys($macs) as $m) $raw[$m] = true;
        $macList = array_keys($macs); sort($macList, SORT_STRING);
        $rawList = array_keys($raw); sort($rawList, SORT_STRING);
        $entry['key'] = (string)($entry['key'] ?? ua_rx_router_port_source_key($routerId, $iface));
        $entry['pseudo_id'] = (int)($entry['pseudo_id'] ?? ua_rx_router_port_pseudo_id($routerId, $iface));
        $entry['macs'] = $macList;
        $entry['raw_macs'] = $rawList;
        $entry['client_count'] = (int)($entry['client_count'] ?? count($macList));
        $entry['raw_count'] = (int)($entry['raw_count'] ?? count($rawList));
        $out[$entry['pseudo_id']] = $entry;
    }
    return $out;
}
}


if (!function_exists('ua_rx_router_port_source_remove')) {
function ua_rx_router_port_source_remove(string $key): array {
    $key = trim($key);
    if ($key === '') return ['ok'=>false, 'message'=>'missing_key'];
    $data = ua_rx_router_port_sources_read();
    if (empty($data['ports'][$key]) || !is_array($data['ports'][$key])) {
        return ['ok'=>false, 'message'=>'router_port_source_not_found'];
    }
    $removed = $data['ports'][$key];
    unset($data['ports'][$key]);
    ua_rx_router_port_sources_write($data);
    return [
        'ok'=>true,
        'key'=>$key,
        'removed'=>[
            'router_id'=>(int)($removed['router_id'] ?? 0),
            'router_name'=>(string)($removed['router_name'] ?? ''),
            'interface'=>(string)($removed['interface'] ?? ''),
            'name'=>(string)($removed['name'] ?? ''),
        ],
    ];
}
}

if (!function_exists('ua_rx_router_port_source_rename')) {
function ua_rx_router_port_source_rename(string $key, string $name): array {
    $key = trim($key);
    $name = trim($name);
    if ($key === '') return ['ok'=>false, 'message'=>'missing_key'];
    if ($name === '') return ['ok'=>false, 'message'=>'missing_name'];
    if (mb_strlen($name, 'UTF-8') > 160) $name = mb_substr($name, 0, 160, 'UTF-8');
    $data = ua_rx_router_port_sources_read();
    if (empty($data['ports'][$key]) || !is_array($data['ports'][$key])) {
        return ['ok'=>false, 'message'=>'router_port_source_not_found'];
    }
    $old = (string)($data['ports'][$key]['name'] ?? '');
    $data['ports'][$key]['name'] = $name;
    $data['ports'][$key]['display_name'] = $name;
    $data['ports'][$key]['renamed_at'] = date('Y-m-d H:i:s');
    ua_rx_router_port_sources_write($data);
    return [
        'ok'=>true,
        'key'=>$key,
        'old_name'=>$old,
        'name'=>$name,
    ];
}
}
if (!function_exists('ua_rx_current_scan_entry_normalize')) {
function ua_rx_current_scan_entry_normalize(array $entry): array {
    $macs = [];
    foreach ((array)($entry['macs'] ?? []) as $m) {
        $n = ua_rx_norm_mac($m);
        if ($n !== '') $macs[$n] = true;
    }
    $raw = [];
    foreach ((array)($entry['raw_macs'] ?? []) as $m) {
        $n = ua_rx_norm_mac($m);
        if ($n !== '') $raw[$n] = true;
    }
    foreach (array_keys($macs) as $m) $raw[$m] = true;
    $macList = array_keys($macs);
    $rawList = array_keys($raw);
    sort($macList, SORT_STRING);
    sort($rawList, SORT_STRING);
    $cacheTs = (int)($entry['cache_ts'] ?? $entry['ts'] ?? $entry['scan_completed_at'] ?? 0);
    return [
        'macs'=>$macList,
        'raw_macs'=>$rawList,
        'source'=>(string)($entry['source'] ?? ''),
        'cache_ts'=>$cacheTs,
        'raw_count'=>(int)($entry['raw_count'] ?? count($rawList)),
        'client_count'=>(int)($entry['client_count'] ?? count($macList)),
        'client_count_method'=>(string)($entry['client_count_method'] ?? ''),
        'ownership_policy'=>(string)($entry['ownership_policy'] ?? ''),
        'scan_id'=>(string)($entry['scan_id'] ?? ''),
    ];
}
}

if (!function_exists('ua_rx_timestamp_value')) {
function ua_rx_timestamp_value($value, int $fallback = 0): int {
    if (is_int($value) || ctype_digit((string)$value)) return (int)$value;
    $ts = @strtotime((string)$value);
    return $ts ? (int)$ts : $fallback;
}
}

if (!function_exists('ua_rx_sql_datetime')) {
function ua_rx_sql_datetime($ts): string {
    $ts = ua_rx_timestamp_value($ts, time());
    if ($ts <= 0) $ts = time();
    return date('Y-m-d H:i:s', $ts);
}
}

if (!function_exists('ua_rx_cumulative_map_read')) {
function ua_rx_cumulative_map_read(): array {
    $map = ua_rx_json_read(ua_rx_cumulative_map_file(), []);
    if (!is_array($map)) return ['version'=>1,'ts'=>0,'macs'=>[]];
    if (!isset($map['macs']) || !is_array($map['macs'])) $map['macs'] = [];
    return $map + ['version'=>1,'ts'=>0,'macs'=>[]];
}
}

if (!function_exists('ua_rx_cumulative_seed_from_snapshot')) {
function ua_rx_cumulative_seed_from_snapshot(array $snapshot): array {
    $seed = ['version'=>1,'ts'=>time(),'source'=>'seeded_from_official_snapshot','macs'=>[]];
    $snapTs = (int)($snapshot['ts'] ?? time());
    foreach ((array)($snapshot['subscriber_matches'] ?? []) as $m) {
        $mac = ua_rx_norm_mac($m['mac'] ?? '');
        $did = (int)($m['device_id'] ?? 0);
        if ($mac === '' || $did <= 0) continue;
        $lastSeen = ua_rx_timestamp_value($m['last_seen_at'] ?? ($m['airmax_match_last_seen_at'] ?? ''), $snapTs);
        $seed['macs'][$mac] = [
            'mac'=>$mac,
            'device_id'=>$did,
            'device_name'=>(string)($m['device_name'] ?? ''),
            'host'=>(string)($m['host'] ?? ''),
            'source'=>(string)($m['source'] ?? 'official_snapshot_seed'),
            'first_seen_at'=>$lastSeen,
            'last_seen_at'=>$lastSeen,
            'last_seen_iso'=>date('Y-m-d H:i:s', $lastSeen),
            'seeded_from_snapshot'=>true,
        ];
    }
    return $seed;
}
}


if (!function_exists('ua_rx_known_subscriber_mac_map_file')) {
function ua_rx_known_subscriber_mac_map_file(): string { return ua_rx_cache_dir() . '/known_subscriber_mac_map.json'; }
}

if (!function_exists('ua_rx_known_subscriber_mac_map_read')) {
function ua_rx_known_subscriber_mac_map_read(): array {
    $map = ua_rx_json_read(ua_rx_known_subscriber_mac_map_file(), []);
    if (!is_array($map)) $map = [];
    if (!isset($map['macs']) || !is_array($map['macs'])) $map['macs'] = [];
    return $map + ['version'=>1,'ts'=>0,'macs'=>[]];
}
}

if (!function_exists('ua_rx_known_owner_payload')) {
function ua_rx_known_owner_payload(int $sid, array $idx, array $fallback = []): array {
    $owner = (array)($idx['owners'][$sid] ?? []);
    if (!$owner) {
        $owner = [
            'id'=>$sid,
            'username'=>(string)($fallback['username'] ?? ''),
            'ppp_username'=>(string)($fallback['ppp_username'] ?? ''),
            'ppp_user'=>(string)($fallback['ppp_user'] ?? ''),
            'full_name'=>(string)($fallback['full_name'] ?? ''),
        ];
    }
    if (empty($owner['ppp_user'])) {
        $u = trim((string)($owner['ppp_username'] ?? '')) ?: trim((string)($owner['username'] ?? ''));
        $owner['ppp_user'] = preg_replace('/@.*/', '', $u);
    }
    return $owner;
}
}

if (!function_exists('ua_rx_known_subscriber_mac_seed_from_snapshot')) {
function ua_rx_known_subscriber_mac_seed_from_snapshot(array $snapshot, array $idx): array {
    $seed = ['version'=>1,'ts'=>time(),'source'=>'seeded_from_official_snapshot_subscriber_matches','macs'=>[]];
    $snapTs = (int)($snapshot['ts'] ?? time());
    foreach ((array)($snapshot['subscriber_matches'] ?? []) as $sid => $m) {
        $sid = (int)($m['subscriber_id'] ?? $sid);
        $mac = ua_rx_norm_mac($m['mac'] ?? '');
        $did = (int)($m['device_id'] ?? 0);
        if ($sid <= 0 || $mac === '' || $did <= 0) continue;
        if (!isset($idx['owners'][$sid])) continue; // deleted/unknown subscriber must not be resurrected
        $owner = ua_rx_known_owner_payload($sid, $idx, (array)($m['subscriber'] ?? []));
        $lastSeen = ua_rx_timestamp_value($m['last_seen_at'] ?? ($m['last_seen_iso'] ?? ''), $snapTs);
        $seed['macs'][$mac] = [
            'mac'=>$mac,
            'subscriber_id'=>$sid,
            'username'=>(string)($owner['username'] ?? ''),
            'ppp_username'=>(string)($owner['ppp_username'] ?? ''),
            'ppp_user'=>(string)($owner['ppp_user'] ?? ''),
            'full_name'=>(string)($owner['full_name'] ?? ''),
            'device_id'=>$did,
            'device_name'=>(string)($m['device_name'] ?? ''),
            'host'=>(string)($m['host'] ?? ''),
            'confidence'=>(string)($m['confidence'] ?? 'official_snapshot_seed'),
            'source'=>(string)($m['source'] ?? 'official_snapshot_seed'),
            'first_matched_at'=>$lastSeen,
            'last_matched_at'=>$snapTs,
            'last_seen_at'=>$lastSeen,
            'last_seen_iso'=>date('Y-m-d H:i:s', $lastSeen),
            'seeded_from_snapshot'=>true,
        ];
    }
    return $seed;
}
}

if (!function_exists('ua_rx_known_subscriber_mac_map_apply_to_index')) {
function ua_rx_known_subscriber_mac_map_apply_to_index(array $idx, array $cachedSnapshot = []): array {
    if (!isset($idx['by_mac']) || !is_array($idx['by_mac'])) $idx['by_mac'] = [];
    if (!isset($idx['owners']) || !is_array($idx['owners'])) $idx['owners'] = [];

    $map = ua_rx_known_subscriber_mac_map_read();
    if (empty($map['macs']) && is_array($cachedSnapshot) && !empty($cachedSnapshot['subscriber_matches'])) {
        $map = ua_rx_known_subscriber_mac_seed_from_snapshot($cachedSnapshot, $idx);
    }

    $added = 0;
    $skippedCurrentOwner = 0;
    $skippedMissingSubscriber = 0;
    foreach ((array)($map['macs'] ?? []) as $mac => $entry) {
        $mac = ua_rx_norm_mac($entry['mac'] ?? $mac);
        $sid = (int)($entry['subscriber_id'] ?? 0);
        if ($mac === '' || $sid <= 0) continue;
        if (!isset($idx['owners'][$sid])) { $skippedMissingSubscriber++; continue; }

        // Current DB fields / PPP-active / security ownership remain stronger than historical receiver memory.
        if (!empty($idx['by_mac'][$mac])) { $skippedCurrentOwner++; continue; }

        $owner = ua_rx_known_owner_payload($sid, $idx, (array)$entry);
        $owner['mac_source'] = 'known_subscriber_mac_map';
        $owner['known_subscriber_mac_last_seen_at'] = (int)ua_rx_timestamp_value($entry['last_seen_at'] ?? ($entry['last_seen_iso'] ?? ''), (int)($map['ts'] ?? 0));
        $owner['known_subscriber_mac_confidence'] = (string)($entry['confidence'] ?? 'last_known_subscriber_mac');
        $idx['by_mac'][$mac] = [$sid => $owner];
        $added++;
    }
    $idx['known_subscriber_mac_map_summary'] = [
        'file'=>basename(ua_rx_known_subscriber_mac_map_file()),
        'known_total'=>count((array)($map['macs'] ?? [])),
        'added_to_index'=>$added,
        'skipped_current_owner'=>$skippedCurrentOwner,
        'skipped_missing_subscriber'=>$skippedMissingSubscriber,
        'source'=>(string)($map['source'] ?? ''),
    ];
    return $idx;
}
}

if (!function_exists('ua_rx_known_subscriber_mac_map_commit')) {
function ua_rx_known_subscriber_mac_map_commit(array $subscriberMatches, array $idx): array {
    $now = time();
    $previous = ua_rx_known_subscriber_mac_map_read();
    $macs = [];
    $preserved = 0;

    foreach ((array)($previous['macs'] ?? []) as $mac => $entry) {
        $mac = ua_rx_norm_mac($entry['mac'] ?? $mac);
        $sid = (int)($entry['subscriber_id'] ?? 0);
        if ($mac === '' || $sid <= 0 || !isset($idx['owners'][$sid])) continue;
        $owner = ua_rx_known_owner_payload($sid, $idx, (array)$entry);
        $entry['mac'] = $mac;
        $entry['subscriber_id'] = $sid;
        $entry['username'] = (string)($owner['username'] ?? '');
        $entry['ppp_username'] = (string)($owner['ppp_username'] ?? '');
        $entry['ppp_user'] = (string)($owner['ppp_user'] ?? '');
        $entry['full_name'] = (string)($owner['full_name'] ?? '');
        $entry['preserved_last_known'] = true;
        $macs[$mac] = $entry;
        $preserved++;
    }

    $updated = 0;
    foreach ($subscriberMatches as $sid => $m) {
        $sid = (int)($m['subscriber_id'] ?? $sid);
        $mac = ua_rx_norm_mac($m['mac'] ?? '');
        $did = (int)($m['device_id'] ?? 0);
        if ($sid <= 0 || $mac === '' || $did <= 0 || !isset($idx['owners'][$sid])) continue;
        $owner = ua_rx_known_owner_payload($sid, $idx, (array)($m['subscriber'] ?? []));
        $prev = (array)($macs[$mac] ?? []);
        $first = ua_rx_timestamp_value($prev['first_matched_at'] ?? ($m['first_seen_at'] ?? ''), (int)($m['first_seen_at'] ?? $now));
        $lastSeen = ua_rx_timestamp_value($m['last_seen_at'] ?? ($m['last_seen_iso'] ?? ''), $now);
        $macs[$mac] = [
            'mac'=>$mac,
            'subscriber_id'=>$sid,
            'username'=>(string)($owner['username'] ?? ''),
            'ppp_username'=>(string)($owner['ppp_username'] ?? ''),
            'ppp_user'=>(string)($owner['ppp_user'] ?? ''),
            'full_name'=>(string)($owner['full_name'] ?? ''),
            'device_id'=>$did,
            'device_name'=>(string)($m['device_name'] ?? ''),
            'host'=>(string)($m['host'] ?? ''),
            'confidence'=>(string)($m['confidence'] ?? ''),
            'source'=>(string)($m['source'] ?? ''),
            'observed_now'=>!empty($m['observed_now']),
            'cumulative_origin'=>(string)($m['cumulative_origin'] ?? ''),
            'first_matched_at'=>$first > 0 ? $first : $now,
            'last_matched_at'=>$now,
            'last_seen_at'=>$lastSeen > 0 ? $lastSeen : $now,
            'last_seen_iso'=>date('Y-m-d H:i:s', $lastSeen > 0 ? $lastSeen : $now),
            'updated_from_official_snapshot'=>true,
        ];
        $updated++;
    }

    ksort($macs, SORT_STRING);
    $out = [
        'version'=>1,
        'ts'=>$now,
        'updated_at'=>date('Y-m-d H:i:s', $now),
        'source'=>'official_receiver_subscriber_match_memory',
        'macs'=>$macs,
        'summary'=>[
            'known_total'=>count($macs),
            'preserved_previous'=>$preserved,
            'updated_from_snapshot'=>$updated,
        ],
    ];
    ua_rx_json_write(ua_rx_known_subscriber_mac_map_file(), $out);
    return $out['summary'];
}
}

if (!function_exists('ua_rx_cumulative_prepare')) {
function ua_rx_cumulative_prepare(array $currentDeviceMacs, array $deviceInfoById, array $activeStationIds, array $cachedSnapshot = []): array {
    $now = time();
    $active = [];
    foreach ($activeStationIds as $id) { $id = (int)$id; if ($id > 0) $active[$id] = true; }

    $known = ua_rx_cumulative_map_read();
    if (empty($known['macs']) && is_array($cachedSnapshot) && !empty($cachedSnapshot['subscriber_matches'])) {
        $known = ua_rx_cumulative_seed_from_snapshot($cachedSnapshot);
    }

    $previous = [];
    foreach ((array)($known['macs'] ?? []) as $mac => $entry) {
        $mac = ua_rx_norm_mac($entry['mac'] ?? $mac);
        $did = (int)($entry['device_id'] ?? 0);
        if ($mac === '' || $did <= 0 || empty($active[$did])) continue;
        $lastSeen = ua_rx_timestamp_value($entry['last_seen_at'] ?? ($entry['last_seen_iso'] ?? ''), (int)($known['ts'] ?? $now));
        $entry['mac'] = $mac;
        $entry['device_id'] = $did;
        $entry['last_seen_at'] = $lastSeen > 0 ? $lastSeen : $now;
        if (empty($entry['first_seen_at'])) $entry['first_seen_at'] = $entry['last_seen_at'];
        $previous[$mac] = $entry;
    }

    $currentSightings = [];
    foreach ($currentDeviceMacs as $id => $macs) {
        $id = (int)$id;
        if ($id <= 0 || empty($active[$id])) continue;
        $meta = (array)($deviceInfoById[$id] ?? []);
        $cacheTs = (int)($meta['cache_ts'] ?? 0);
        if ($cacheTs <= 0) $cacheTs = $now - max(0, (int)($meta['age'] ?? 0));
        if ($cacheTs <= 0 || $cacheTs > ($now + 300)) $cacheTs = $now;
        $isFresh = ((int)($meta['age'] ?? 999999) <= 900);
        foreach ((array)$macs as $mac) {
            $mac = ua_rx_norm_mac($mac);
            if ($mac === '') continue;
            $currentSightings[$mac][$id] = [
                'device_id'=>$id,
                'cache_ts'=>$cacheTs,
                'fresh'=>$isFresh,
                'source'=>(string)($meta['source'] ?? ''),
            ];
        }
    }

    $merged = [];
    foreach ($previous as $mac => $entry) {
        $did = (int)$entry['device_id'];
        $merged[$did][$mac] = [
            'observed_now'=>false,
            'last_seen_at'=>(int)$entry['last_seen_at'],
            'first_seen_at'=>(int)($entry['first_seen_at'] ?? $entry['last_seen_at']),
            'map_source'=>(string)($entry['source'] ?? 'last_successful_cache'),
            'cumulative_origin'=>'last_known',
        ];
    }

    $oldSuppressedByNew = 0;
    $staleIgnored = 0;
    foreach ($currentSightings as $mac => $devs) {
        $bestTs = 0;
        foreach ($devs as $obs) $bestTs = max($bestTs, (int)($obs['cache_ts'] ?? 0));
        $prev = (array)($previous[$mac] ?? []);
        $prevTs = (int)($prev['last_seen_at'] ?? 0);
        // A newer/current receiver cache wins over last-known history. Older cache files must not move a MAC backwards.
        if ($prevTs > 0 && $bestTs > 0 && $bestTs < $prevTs) {
            $staleIgnored++;
            continue;
        }
        if (!empty($prev['device_id'])) {
            $oldDid = (int)$prev['device_id'];
            unset($merged[$oldDid][$mac]);
            $oldSuppressedByNew++;
        }
        foreach ($devs as $did => $obs) {
            $did = (int)$did;
            if ($did <= 0) continue;
            $first = (int)($prev['first_seen_at'] ?? $bestTs ?: $now);
            $merged[$did][$mac] = [
                'observed_now'=>true,
                'last_seen_at'=>(int)($obs['cache_ts'] ?? $bestTs ?: $now),
                'first_seen_at'=>$first,
                'map_source'=>(string)($obs['source'] ?? ''),
                'cumulative_origin'=>'current_scan',
            ];
        }
    }

    $deviceMacs = [];
    $observedNow = [];
    $lastSeenAt = [];
    $firstSeenAt = [];
    $origin = [];
    foreach ($merged as $did => $set) {
        $macList = array_keys((array)$set);
        sort($macList, SORT_STRING);
        $deviceMacs[(int)$did] = $macList;
        foreach ($set as $mac => $meta) {
            $observedNow[(int)$did][$mac] = !empty($meta['observed_now']);
            $lastSeenAt[(int)$did][$mac] = (int)($meta['last_seen_at'] ?? $now);
            $firstSeenAt[(int)$did][$mac] = (int)($meta['first_seen_at'] ?? ($meta['last_seen_at'] ?? $now));
            $origin[(int)$did][$mac] = (string)($meta['cumulative_origin'] ?? 'last_known');
        }
    }

    return [
        'known'=>$known,
        'previous'=>$previous,
        'current_sightings'=>$currentSightings,
        'device_macs'=>$deviceMacs,
        'observed_now'=>$observedNow,
        'last_seen_at'=>$lastSeenAt,
        'first_seen_at'=>$firstSeenAt,
        'origin'=>$origin,
        'summary'=>[
            'previous_known'=>count($previous),
            'current_observed'=>count($currentSightings),
            'merged_known'=>count(array_unique(array_merge(array_keys($previous), array_keys($currentSightings)))),
            'old_suppressed_by_new'=>$oldSuppressedByNew,
            'stale_current_ignored'=>$staleIgnored,
        ],
    ];
}
}

if (!function_exists('ua_rx_cumulative_commit')) {
function ua_rx_cumulative_commit(array $prepared, array $macOwnerDevice, array $activeDevicesById, array $topology): array {
    $now = time();
    $active = [];
    foreach ($activeDevicesById as $id => $_d) { $id = (int)$id; if ($id > 0) $active[$id] = true; }
    $previous = (array)($prepared['previous'] ?? []);
    $currentSightings = (array)($prepared['current_sightings'] ?? []);
    $macs = [];

    foreach ($previous as $mac => $entry) {
        $did = (int)($entry['device_id'] ?? 0);
        if ($mac === '' || $did <= 0 || empty($active[$did])) continue;
        $entry['mac'] = $mac;
        $entry['device_id'] = $did;
        $entry['last_seen_at'] = ua_rx_timestamp_value($entry['last_seen_at'] ?? ($entry['last_seen_iso'] ?? ''), $now);
        $entry['last_seen_iso'] = date('Y-m-d H:i:s', (int)$entry['last_seen_at']);
        $entry['preserved_last_known'] = true;
        $macs[$mac] = $entry;
    }

    $updated = 0;
    $ambiguous = 0;
    foreach ($currentSightings as $mac => $devs) {
        $mac = ua_rx_norm_mac($mac);
        if ($mac === '') continue;
        $winner = (int)($macOwnerDevice[$mac] ?? 0);
        if ($winner <= 0 && count($devs) === 1) $winner = (int)array_key_first($devs);
        if ($winner <= 0 || empty($active[$winner])) { $ambiguous++; continue; }
        $obs = (array)($devs[$winner] ?? reset($devs));
        $d = (array)($activeDevicesById[$winner] ?? []);
        $prev = (array)($previous[$mac] ?? []);
        $lastSeen = (int)($obs['cache_ts'] ?? $now);
        if ($lastSeen <= 0) $lastSeen = $now;
        $firstSeen = (int)($prev['first_seen_at'] ?? $lastSeen);
        $macs[$mac] = [
            'mac'=>$mac,
            'device_id'=>$winner,
            'device_name'=>(string)($d['name'] ?? ''),
            'host'=>(string)($d['host'] ?? ''),
            'device_type'=>ua_rx_device_role($d['device_type'] ?? ''),
            'parent_device_id'=>ua_rx_device_parent_id($d),
            'topology_depth'=>(int)($topology['depths'][$winner] ?? 0),
            'source'=>(string)($obs['source'] ?? ($prev['source'] ?? 'receiver_cache')),
            'first_seen_at'=>$firstSeen,
            'last_seen_at'=>$lastSeen,
            'last_seen_iso'=>date('Y-m-d H:i:s', $lastSeen),
            'updated_from_current_scan'=>true,
        ];
        $updated++;
    }

    ksort($macs, SORT_STRING);
    $out = [
        'version'=>2,
        'ts'=>$now,
        'updated_at'=>date('Y-m-d H:i:s', $now),
        'source'=>'cumulative_receiver_mac_map_current_scan_wins',
        'macs'=>$macs,
        'summary'=>[
            'known_total'=>count($macs),
            'updated_from_current'=>$updated,
            'ambiguous_current_skipped'=>$ambiguous,
            'previous_known'=>count($previous),
        ],
    ];
    ua_rx_json_write(ua_rx_cumulative_map_file(), $out);
    return $out['summary'];
}
}

if (!function_exists('ua_rx_lock_acquire')) {
function ua_rx_lock_acquire(int $ttl = 20) {
    $path = ua_rx_lock_file();
    if (is_file($path) && (time() - (int)@filemtime($path)) > max(20, $ttl + 5)) @unlink($path);
    $fh = @fopen($path, 'c+');
    if (!$fh) return false;
    if (!@flock($fh, LOCK_EX | LOCK_NB)) { @fclose($fh); return false; }
    @ftruncate($fh, 0);
    @fwrite($fh, (string)getmypid());
    @touch($path);
    return $fh;
}
}

if (!function_exists('ua_rx_lock_release')) {
function ua_rx_lock_release($fh): void {
    if ($fh) { @flock($fh, LOCK_UN); @fclose($fh); }
}
}

if (!function_exists('ua_rx_ensure_subscriber_schema')) {
function ua_rx_ensure_subscriber_schema(mysqli $conn): void {
    $cols = [
        'airmax_device_id'       => "ALTER TABLE subscribers ADD COLUMN airmax_device_id INT NULL",
        'airmax_device_name'     => "ALTER TABLE subscribers ADD COLUMN airmax_device_name VARCHAR(160) NULL",
        'airmax_device_type'     => "ALTER TABLE subscribers ADD COLUMN airmax_device_type VARCHAR(32) NULL",
        'airmax_matched_mac'     => "ALTER TABLE subscribers ADD COLUMN airmax_matched_mac VARCHAR(32) NULL",
        'airmax_match_source'    => "ALTER TABLE subscribers ADD COLUMN airmax_match_source VARCHAR(80) NULL",
        'airmax_match_confidence'=> "ALTER TABLE subscribers ADD COLUMN airmax_match_confidence VARCHAR(32) NULL",
        'airmax_matched_at'      => "ALTER TABLE subscribers ADD COLUMN airmax_matched_at DATETIME NULL",
        'airmax_match_last_seen_at'=> "ALTER TABLE subscribers ADD COLUMN airmax_match_last_seen_at DATETIME NULL",
        'airmax_match_missing_since'=> "ALTER TABLE subscribers ADD COLUMN airmax_match_missing_since DATETIME NULL",
        'airmax_match_stale_cleared_at'=> "ALTER TABLE subscribers ADD COLUMN airmax_match_stale_cleared_at DATETIME NULL",
        'airmax_match_locked'    => "ALTER TABLE subscribers ADD COLUMN airmax_match_locked TINYINT(1) NOT NULL DEFAULT 0",
        'sector_id'              => "ALTER TABLE subscribers ADD COLUMN sector_id INT NULL",
        'vlan_id'                => "ALTER TABLE subscribers ADD COLUMN vlan_id INT NULL",
        'sector_auto_device_id'  => "ALTER TABLE subscribers ADD COLUMN sector_auto_device_id INT NULL",
        'sector_auto_matched_mac'=> "ALTER TABLE subscribers ADD COLUMN sector_auto_matched_mac VARCHAR(32) NULL",
        'sector_auto_source'     => "ALTER TABLE subscribers ADD COLUMN sector_auto_source VARCHAR(80) NULL",
        'sector_auto_confidence' => "ALTER TABLE subscribers ADD COLUMN sector_auto_confidence VARCHAR(32) NULL",
        'sector_auto_matched_at' => "ALTER TABLE subscribers ADD COLUMN sector_auto_matched_at DATETIME NULL",
    ];
    foreach ($cols as $col => $sql) {
        if (!ua_rx_col_exists($conn, 'subscribers', $col)) {
            @ $conn->query($sql);
        }
    }
}
}

if (!function_exists('ua_rx_subscriber_mac_index')) {
function ua_rx_subscriber_mac_index(mysqli $conn): array {
    /*
     * Subscriber identity source policy:
     * Receiver matching must use the same current MAC truth shown in subscriber view, but without
     * issuing a new MikroTik poll from this matcher. The receiver side tells us where a MAC is seen;
     * the subscriber side identifies that MAC from the already-built application/PPP cache and DB.
     *
     * Priority/order:
     *   1) admin/cache/ppp_active.json caller_id for currently online PPP users (file cache only)
     *   2) subscribers.current_caller_id / current_mac
     *   3) subscribers.last_caller_id / last_mac / legacy subscriber MAC columns
     *   4) subscriber_mac_history.latest ONLY as a fallback when no stronger source owns the MAC
     *   5) resolved application ownership table may override ambiguous old rows
     *
     * Important: this function reads the existing PPP active JSON cache only. It must not trigger
     * a MikroTik/API refresh and must not block receiver matching on live router access.
     */
    $cols = ['id','username'];
    $macColumns = ['current_caller_id','current_mac','last_caller_id','last_mac','caller_id','mac_address'];
    $identityColumns = ['ppp_username','full_name','status','is_online','last_seen_at','updated_at','mt_watch_sync_at'];
    foreach (array_merge($identityColumns, $macColumns) as $c) {
        if (ua_rx_col_exists($conn, 'subscribers', $c)) $cols[] = $c;
    }
    $where = ua_rx_col_exists($conn, 'subscribers', 'is_deleted') ? 'WHERE COALESCE(is_deleted,0)=0' : '';
    $sql = 'SELECT ' . implode(',', array_map(static fn($c) => '`'.$c.'`', array_unique($cols))) . " FROM subscribers {$where}";
    $rs = @ $conn->query($sql);
    $byMac = [];
    $byUser = [];
    $owners = [];
    $sourceStats = [];
    $historyStats = [
        'table_exists'=>false,
        'rows_scanned'=>0,
        'added_latest_fallback'=>0,
        'skipped_existing_current_or_last'=>0,
        'skipped_duplicate_older_history'=>0,
        'skipped_missing_subscriber'=>0,
        'skipped_bad_mac'=>0,
    ];
    $pppActiveStats = [
        'enabled'=>true,
        'cache_file'=>function_exists('ua_ppp_cache_file') ? basename(ua_ppp_cache_file()) : 'ppp_active.json',
        'ok'=>false,
        'source'=>'',
        'age'=>999999,
        'online'=>0,
        'rows_scanned'=>0,
        'added_or_overrode'=>0,
        'overrode_existing_mac_owner'=>0,
        'skipped_bad_mac'=>0,
        'skipped_missing_user'=>0,
        'skipped_unknown_subscriber'=>0,
    ];

    $addMac = static function($mac, array $owner, string $source) use (&$byMac, &$sourceStats): void {
        $n = ua_rx_norm_mac($mac);
        if ($n === '') return;
        $owner['mac_source'] = $source;
        $byMac[$n][(int)$owner['id']] = $owner;
        $sourceStats[$source] = (int)($sourceStats[$source] ?? 0) + 1;
    };

    $setMacOwner = static function($mac, array $owner, string $source) use (&$byMac, &$sourceStats): bool {
        $n = ua_rx_norm_mac($mac);
        if ($n === '') return false;
        $owner['mac_source'] = $source;
        $byMac[$n] = [(int)$owner['id'] => $owner];
        $sourceStats[$source] = (int)($sourceStats[$source] ?? 0) + 1;
        return true;
    };

    $ownerByUserKey = [];

    if ($rs) {
        while ($r = $rs->fetch_assoc()) {
            $sid = (int)($r['id'] ?? 0);
            if ($sid <= 0) continue;
            $user = trim((string)($r['ppp_username'] ?? '')) ?: trim((string)($r['username'] ?? ''));
            $user = preg_replace('/@.*/', '', $user);
            $owner = [
                'id'=>$sid,
                'username'=>(string)($r['username'] ?? ''),
                'ppp_username'=>(string)($r['ppp_username'] ?? ''),
                'ppp_user'=>$user,
                'full_name'=>(string)($r['full_name'] ?? ''),
                'status'=>(string)($r['status'] ?? ''),
                'is_online'=>(int)($r['is_online'] ?? 0),
                'last_seen_at'=>(string)($r['last_seen_at'] ?? ''),
                'updated_at'=>(string)($r['updated_at'] ?? ''),
                'mt_watch_sync_at'=>(string)($r['mt_watch_sync_at'] ?? ''),
            ];
            $owners[$sid] = $owner;
            $userCandidates = array_unique(array_filter([
                $user,
                preg_replace('/@.*/', '', trim((string)($r['username'] ?? ''))),
                preg_replace('/@.*/', '', trim((string)($r['ppp_username'] ?? ''))),
            ], static fn($v) => trim((string)$v) !== ''));
            foreach ($userCandidates as $candidateUser) {
                $candidateUser = trim((string)$candidateUser);
                if ($candidateUser === '') continue;
                $byUser[$candidateUser] = $owner;
                $ownerByUserKey[mb_strtolower($candidateUser, 'UTF-8')] = $owner;
            }

            foreach ($macColumns as $c) {
                if (array_key_exists($c, $r)) $addMac($r[$c], $owner, 'subscribers.' . $c);
            }
        }
    }

    /*
     * PPP active cache: this is the same current caller-id source used by subscriber view.
     * It is read from admin/cache/ppp_active.json only; no MikroTik/API refresh is started here.
     * Because it represents an online session, it is allowed to replace stale DB/history ownership for
     * the same MAC inside this runtime index. Manual/security ownership may still override later.
     */
    $pppCache = function_exists('ua_ppp_cache_snapshot') ? ua_ppp_cache_snapshot(3600) : [];
    if (is_array($pppCache) && !empty($pppCache['ok'])) {
        $pppActiveStats['ok'] = true;
        $pppActiveStats['source'] = (string)($pppCache['source'] ?? 'ppp_active_cache');
        $pppActiveStats['age'] = (int)($pppCache['age'] ?? 999999);
        $pppActiveStats['online'] = (int)($pppCache['online'] ?? count((array)($pppCache['users'] ?? [])));
        foreach ((array)($pppCache['rows'] ?? []) as $row) {
            if (!is_array($row)) continue;
            $pppActiveStats['rows_scanned']++;
            $u = trim((string)($row['name'] ?? $row['username'] ?? $row['user'] ?? ''));
            $u = trim(preg_replace('/@.*/', '', $u));
            if ($u === '') { $pppActiveStats['skipped_missing_user']++; continue; }
            $owner = (array)($ownerByUserKey[mb_strtolower($u, 'UTF-8')] ?? []);
            if (!$owner) { $pppActiveStats['skipped_unknown_subscriber']++; continue; }
            $mac = ua_rx_norm_mac($row['caller_id'] ?? ($row['caller-id'] ?? ''));
            if ($mac === '') { $pppActiveStats['skipped_bad_mac']++; continue; }
            if (!empty($byMac[$mac]) && !isset($byMac[$mac][(int)$owner['id']])) {
                $pppActiveStats['overrode_existing_mac_owner']++;
            }
            $owner['ppp_active_address'] = (string)($row['address'] ?? '');
            $owner['ppp_active_uptime'] = (string)($row['uptime'] ?? '');
            $owner['ppp_active_cache_age'] = (int)($pppCache['age'] ?? 999999);
            if ($setMacOwner($mac, $owner, 'ppp_active_cache.caller_id')) {
                $pppActiveStats['added_or_overrode']++;
            }
        }
    } else {
        $pppActiveStats['source'] = is_array($pppCache) ? (string)($pppCache['source'] ?? 'ppp_active_cache_unavailable') : 'ppp_active_cache_unavailable';
        $pppActiveStats['age'] = is_array($pppCache) ? (int)($pppCache['age'] ?? 999999) : 999999;
    }


    /*
     * History fallback: use only the latest history row per MAC and only if no direct subscriber field
     * (PPP-active/current/last caller id or equivalent) already owns that MAC. This prevents stale history from
     * creating conflicts or moving a MAC away from a current/last saved value, while still allowing
     * offline subscribers whose last MAC lives only in subscriber_mac_history to be matched.
     */
    if (ua_rx_table_exists($conn, 'subscriber_mac_history') && ua_rx_col_exists($conn, 'subscriber_mac_history', 'mac_address')) {
        $historyStats['table_exists'] = true;
        $deletedWhere = ua_rx_col_exists($conn, 'subscribers', 'is_deleted') ? ' AND COALESCE(s.is_deleted,0)=0' : '';
        $histSql = "SELECT h.id AS history_id, h.subscriber_id, h.username AS history_username, h.mac_address, h.source AS history_source, h.last_seen_at, h.seen_count, s.username AS app_username, s.ppp_username, s.full_name "
                 . "FROM subscriber_mac_history h "
                 . "INNER JOIN subscribers s ON s.id=h.subscriber_id{$deletedWhere} "
                 . "WHERE COALESCE(h.mac_address,'')<>'' "
                 . "ORDER BY h.last_seen_at DESC, h.seen_count DESC, h.id DESC";
        $hr = @ $conn->query($histSql);
        $historySeen = [];
        if ($hr) {
            while ($r = $hr->fetch_assoc()) {
                $historyStats['rows_scanned']++;
                $mac = ua_rx_norm_mac($r['mac_address'] ?? '');
                if ($mac === '') { $historyStats['skipped_bad_mac']++; continue; }
                if (isset($byMac[$mac])) { $historyStats['skipped_existing_current_or_last']++; continue; }
                if (isset($historySeen[$mac])) { $historyStats['skipped_duplicate_older_history']++; continue; }
                $historySeen[$mac] = true;

                $sid = (int)($r['subscriber_id'] ?? 0);
                if ($sid <= 0 || !isset($owners[$sid])) { $historyStats['skipped_missing_subscriber']++; continue; }
                $owner = $owners[$sid];
                $owner['history_source'] = (string)($r['history_source'] ?? '');
                $owner['history_last_seen_at'] = (string)($r['last_seen_at'] ?? '');
                $owner['history_seen_count'] = (int)($r['seen_count'] ?? 0);
                $owner['history_id'] = (int)($r['history_id'] ?? 0);
                $addMac($mac, $owner, 'subscriber_mac_history.latest_fallback');
                $historyStats['added_latest_fallback']++;
            }
        }
    }

    /*
     * Security ownership tables are intentionally not allowed to override receiver matching.
     * They belong to security monitoring/stability decisions only. If the same MAC has more
     * than one possible subscriber owner, matching will still choose the best runtime candidate
     * and mark/report the duplicate ownership as a security conflict instead of dropping the MAC.
     */
    $sourceStats['security_ownership_override_disabled'] = 1;

    return [
        'by_mac'=>$byMac,
        'by_user'=>$byUser,
        'owners'=>$owners,
        'source_policy'=>'ppp_active_cache_then_subscriber_db_current_last_history_fallback',
        'source_stats'=>$sourceStats,
        'ppp_active_summary'=>$pppActiveStats,
        'history_fallback_summary'=>$historyStats,
    ];
}
}

if (!function_exists('ua_rx_norm_name_key')) {
function ua_rx_norm_name_key($value): string {
    $v = trim(mb_strtolower((string)$value, 'UTF-8'));
    $v = preg_replace('/\s+/u', ' ', $v);
    return (string)$v;
}
}

if (!function_exists('ua_rx_sector_lookup')) {
function ua_rx_sector_lookup(mysqli $conn): array {
    $out = ['by_id'=>[], 'by_name'=>[]];
    if (!ua_rx_table_exists($conn, 'sectors')) return $out;
    $cols = ['id','name'];
    if (ua_rx_col_exists($conn, 'sectors', 'vlan_id')) $cols[] = 'vlan_id';
    $sql = 'SELECT ' . implode(',', array_map(static fn($c) => '`'.$c.'`', $cols)) . ' FROM sectors';
    $rs = @$conn->query($sql);
    if ($rs) {
        while ($r = $rs->fetch_assoc()) {
            $id = (int)($r['id'] ?? 0);
            if ($id <= 0) continue;
            $name = trim((string)($r['name'] ?? ''));
            $row = ['id'=>$id, 'name'=>$name, 'vlan_id'=>isset($r['vlan_id']) ? (int)$r['vlan_id'] : 0];
            $out['by_id'][$id] = $row;
            $key = ua_rx_norm_name_key($name);
            if ($key !== '') {
                if (!isset($out['by_name'][$key])) $out['by_name'][$key] = [];
                $out['by_name'][$key][$id] = $row;
            }
        }
    }
    return $out;
}
}

if (!function_exists('ua_rx_resolve_device_sector')) {
function ua_rx_resolve_device_sector(array $device, array $lookup): array {
    $sectorId = (int)($device['sector_id'] ?? 0);
    if ($sectorId > 0 && isset($lookup['by_id'][$sectorId])) {
        return $lookup['by_id'][$sectorId] + ['match_method'=>'airmax_device.sector_id'];
    }
    // Safe fallback only: exact device name equals exactly one sector name.
    $key = ua_rx_norm_name_key($device['name'] ?? '');
    if ($key !== '' && isset($lookup['by_name'][$key]) && count($lookup['by_name'][$key]) === 1) {
        $row = reset($lookup['by_name'][$key]);
        return $row + ['match_method'=>'exact_device_name'];
    }
    return ['id'=>0,'name'=>'','vlan_id'=>0,'match_method'=>'none'];
}
}

if (!function_exists('ua_rx_bridge_macs_from_cache')) {
function ua_rx_bridge_macs_from_cache(array $device): array {
    if (!ua_rx_device_is_station($device)) {
        return ['macs'=>[], 'raw_macs'=>[], 'source'=>'access_skipped', 'age'=>0, 'raw_count'=>0, 'client_count'=>0, 'skipped'=>true, 'reason'=>'access_device'];
    }
    $cache = function_exists('airmax_json_read') ? airmax_json_read(airmax_result_cache_path($device), null) : null;
    if (!is_array($cache)) return ['macs'=>[], 'raw_macs'=>[], 'source'=>'', 'age'=>999999, 'raw_count'=>0, 'client_count'=>0];
    $probe = is_array($cache['out']['probe'] ?? null) ? $cache['out']['probe'] : [];
    $bridge = is_array($probe['bridge'] ?? null) ? $probe['bridge'] : (is_array($cache['out']['bridge'] ?? null) ? $cache['out']['bridge'] : []);

    // Raw pool is kept only for diagnostics. Matching is allowed only after the client-port filter.
    $raw = [];
    foreach ((array)($bridge['raw_macs'] ?? []) as $m) {
        $n = ua_rx_norm_mac($m);
        if ($n !== '') $raw[$n] = true;
    }
    // Backward compatibility: old caches did not store raw_macs; use the client list as raw fallback until the next probe.
    foreach ((array)($bridge['macs'] ?? []) as $m) {
        $n = ua_rx_norm_mac($m);
        if ($n !== '') $raw[$n] = true;
    }
    $rawList = array_keys($raw);
    sort($rawList, SORT_STRING);

    // This is the only list that enters subscriber matching: proven customer-port MACs (LAN0/eth0 or equivalent parser output).
    $client = [];
    foreach ((array)($bridge['macs'] ?? []) as $m) {
        $n = ua_rx_norm_mac($m);
        if ($n !== '') $client[$n] = true;
    }
    $clientList = array_keys($client);
    sort($clientList, SORT_STRING);

    return [
        'macs'=>$clientList,
        'raw_macs'=>$rawList,
        'source'=>(string)($bridge['bridge_source'] ?? $bridge['source'] ?? ''),
        'age'=>time() - (int)($cache['ts'] ?? 0),
        'raw_count'=>(int)($bridge['raw_mac_count'] ?? count($rawList)),
        'client_count'=>count($clientList),
        'client_count_method'=>(string)($bridge['client_count_method'] ?? ''),
        'ownership_policy'=>(string)($bridge['ownership_policy'] ?? ''),
    ];
}
}


if (!function_exists('ua_rx_device_parent_id')) {
function ua_rx_device_parent_id(array $device): int {
    $pid = (int)($device['parent_device_id'] ?? 0);
    $id = (int)($device['id'] ?? 0);
    return ($pid > 0 && $pid !== $id) ? $pid : 0;
}
}

if (!function_exists('ua_rx_build_topology')) {
function ua_rx_build_topology(array $devices): array {
    $byId = [];
    foreach ($devices as $d) {
        $id = (int)($d['id'] ?? 0);
        if ($id > 0) $byId[$id] = $d;
    }
    $parents = [];
    foreach ($byId as $id => $d) {
        $pid = ua_rx_device_parent_id($d);
        $parents[$id] = ($pid > 0 && isset($byId[$pid])) ? $pid : 0;
    }
    $depths = [];
    $ancestors = [];
    foreach ($byId as $id => $_d) {
        $seen = [];
        $cur = $id;
        $depth = 0;
        $anc = [];
        while (!empty($parents[$cur])) {
            $p = (int)$parents[$cur];
            if ($p <= 0 || isset($seen[$p])) break;
            $seen[$p] = true;
            $anc[$p] = true;
            $depth++;
            $cur = $p;
        }
        $depths[$id] = $depth;
        $ancestors[$id] = $anc;
    }
    return ['by_id'=>$byId, 'parents'=>$parents, 'depths'=>$depths, 'ancestors'=>$ancestors];
}
}


if (!function_exists('ua_rx_device_refs')) {
function ua_rx_device_refs(array $ids, array $topology): array {
    $out = [];
    foreach (array_values(array_unique(array_filter(array_map('intval', $ids), static fn($v) => $v > 0))) as $id) {
        $d = (array)($topology['by_id'][$id] ?? []);
        $out[] = [
            'id'=>$id,
            'name'=>(string)($d['name'] ?? ('#' . $id)),
            'host'=>(string)($d['host'] ?? ''),
            'device_type'=>ua_rx_device_role($d['device_type'] ?? ''),
            'parent_device_id'=>(int)(ua_rx_device_parent_id($d)),
            'topology_depth'=>(int)($topology['depths'][$id] ?? 0),
        ];
    }
    return $out;
}
}

if (!function_exists('ua_rx_owner_refs')) {
function ua_rx_owner_refs(array $owners): array {
    $out = [];
    foreach ($owners as $owner) {
        $out[] = [
            'id'=>(int)($owner['id'] ?? 0),
            'username'=>(string)($owner['username'] ?? ''),
            'ppp_username'=>(string)($owner['ppp_username'] ?? ''),
            'ppp_user'=>(string)($owner['ppp_user'] ?? ''),
            'full_name'=>(string)($owner['full_name'] ?? ''),
            'mac_source'=>(string)($owner['mac_source'] ?? ''),
        ];
    }
    return $out;
}
}

if (!function_exists('ua_rx_conflict_make')) {
function ua_rx_conflict_make(string $type, string $mac, string $reason, array $extra = []): array {
    return array_merge([
        'type'=>$type,
        'mac'=>$mac,
        'reason'=>$reason,
    ], $extra);
}
}

if (!function_exists('ua_rx_conflict_label')) {
function ua_rx_conflict_label(array $c): string {
    $type = (string)($c['type'] ?? '');
    $mac = (string)($c['mac'] ?? '');
    if ($type === 'unrelated_stations') {
        $names = [];
        foreach ((array)($c['devices'] ?? []) as $d) {
            $names[] = '#' . (int)($d['id'] ?? 0) . ' ' . (string)($d['name'] ?? '');
        }
        return $mac . ' ظهر في مستقبلات غير مربوطة: ' . implode('، ', array_filter($names));
    }
    if ($type === 'multiple_subscribers_same_mac') {
        $names = [];
        foreach ((array)($c['subscribers'] ?? []) as $o) {
            $nm = trim((string)($o['full_name'] ?? '')) ?: (trim((string)($o['ppp_username'] ?? '')) ?: (string)($o['username'] ?? ''));
            $names[] = '#' . (int)($o['id'] ?? 0) . ' ' . $nm;
        }
        return $mac . ' مسجل لأكثر من مشترك: ' . implode('، ', array_filter($names));
    }
    if ($type === 'subscriber_unrelated_stations') {
        $sub = (array)($c['subscriber'] ?? []);
        $subName = trim((string)($sub['full_name'] ?? '')) ?: (trim((string)($sub['ppp_username'] ?? '')) ?: (string)($sub['username'] ?? ''));
        $names = [];
        foreach ((array)($c['devices'] ?? []) as $d) {
            $names[] = '#' . (int)($d['id'] ?? 0) . ' ' . (string)($d['name'] ?? '');
        }
        return 'المشترك #' . (int)($sub['id'] ?? 0) . ' ' . $subName . ' ظهر خلف مستقبلات غير مربوطة: ' . implode('، ', array_filter($names));
    }
    return trim($mac . ' ' . (string)($c['reason'] ?? 'تضارب غير محدد'));
}
}

if (!function_exists('ua_rx_conflict_preview')) {
function ua_rx_conflict_preview(array $details, int $limit = 3): string {
    if (!$details) return '';
    $labels = [];
    foreach (array_slice($details, 0, max(1, $limit)) as $c) $labels[] = ua_rx_conflict_label((array)$c);
    $more = count($details) - count($labels);
    if ($more > 0) $labels[] = 'و ' . $more . ' تضارب آخر';
    return implode(' | ', $labels);
}
}

if (!function_exists('ua_rx_owner_source_rank')) {
function ua_rx_owner_source_rank(string $source): int {
    $s = strtolower(trim($source));
    if ($s === 'ppp_active_cache.caller_id') return 1000;
    if (strpos($s, 'subscribers.current_caller_id') !== false) return 900;
    if (strpos($s, 'subscribers.current_mac') !== false) return 850;
    if (strpos($s, 'subscribers.last_caller_id') !== false) return 760;
    if (strpos($s, 'subscribers.last_mac') !== false) return 730;
    if (strpos($s, 'subscribers.caller_id') !== false) return 700;
    if (strpos($s, 'subscribers.mac_address') !== false) return 690;
    if (strpos($s, 'subscriber_mac_history') !== false) return 520;
    if (strpos($s, 'known_subscriber_mac_map') !== false) return 430;
    return 100;
}
}

if (!function_exists('ua_rx_owner_time_score')) {
function ua_rx_owner_time_score(array $owner): int {
    $best = 0;
    foreach (['history_last_seen_at','last_seen_at','mt_watch_sync_at','updated_at'] as $k) {
        $ts = strtotime((string)($owner[$k] ?? ''));
        if ($ts && $ts > $best) $best = (int)$ts;
    }
    return $best;
}
}

if (!function_exists('ua_rx_choose_best_owner')) {
function ua_rx_choose_best_owner(array $owners): array {
    /*
     * Matching policy:
     * Duplicate/unstable ownership must NOT block receiver matching. Security monitoring can
     * later ask for stable ownership, but this matcher only needs the best subscriber candidate
     * for a MAC seen on a receiver/router-port.
     */
    $best = [];
    $bestScore = PHP_INT_MIN;
    foreach ($owners as $owner) {
        if (!is_array($owner)) continue;
        $source = (string)($owner['mac_source'] ?? '');
        $score = ua_rx_owner_source_rank($source);
        if (!empty($owner['ppp_active_address']) || !empty($owner['ppp_active_uptime'])) $score += 200;
        if ((int)($owner['is_online'] ?? 0) === 1) $score += 80;
        $status = trim((string)($owner['status'] ?? ''));
        if ($status !== '' && !preg_match('/متوقف|محذوف|ملغ|disabled|deleted|cancel/i', $status)) $score += 25;
        $score += min(60, (int)floor(ua_rx_owner_time_score($owner) / 86400 / 30));
        $sid = (int)($owner['id'] ?? 0);
        $score += max(0, 1000000 - $sid) / 100000000; // deterministic tie-break, older row first

        if ($score > $bestScore) {
            $bestScore = $score;
            $best = $owner;
            $best['_match_owner_score'] = $score;
        }
    }
    return $best ?: (array)reset($owners);
}
}

if (!function_exists('ua_rx_topology_pick_deepest')) {
function ua_rx_topology_pick_deepest(array $ids, array $topology, array $deviceWeights = []): array {
    $ids = array_values(array_unique(array_filter(array_map('intval', $ids), static fn($v) => $v > 0)));
    if (count($ids) <= 1) {
        return ['device_id'=>(int)($ids[0] ?? 0), 'ambiguous'=>false, 'method'=>'single_station', 'suppressed'=>[]];
    }
    $winners = [];
    foreach ($ids as $id) {
        $isDescendantOfAll = true;
        foreach ($ids as $other) {
            if ($other === $id) continue;
            if (empty($topology['ancestors'][$id][$other])) {
                $isDescendantOfAll = false;
                break;
            }
        }
        if ($isDescendantOfAll) $winners[] = $id;
    }
    if (count($winners) === 1) {
        $winner = (int)$winners[0];
        return [
            'device_id'=>$winner,
            'ambiguous'=>false,
            'method'=>'topology_nearest_station',
            'suppressed'=>array_values(array_diff($ids, [$winner])),
        ];
    }
    $weighted = [];
    foreach ($ids as $id) {
        $w = (int)($deviceWeights[$id] ?? 0);
        if ($w <= 0) { $weighted = []; break; }
        $weighted[] = ['id'=>$id, 'weight'=>$w];
    }
    if (count($weighted) === count($ids) && count($weighted) > 1) {
        usort($weighted, static function($a, $b) {
            return ($a['weight'] <=> $b['weight']) ?: ($a['id'] <=> $b['id']);
        });
        $best = $weighted[0];
        $next = $weighted[1];
        if ((int)$best['weight'] <= 20 && (int)$next['weight'] >= max(((int)$best['weight'] * 3), ((int)$best['weight'] + 20))) {
            $winner = (int)$best['id'];
            return [
                'device_id'=>$winner,
                'ambiguous'=>false,
                'method'=>'specific_receiver_smaller_client_set',
                'suppressed'=>array_values(array_diff($ids, [$winner])),
                'weights'=>$weighted,
            ];
        }
    }
    return ['device_id'=>0, 'ambiguous'=>true, 'method'=>'unrelated_multi_station', 'candidates'=>$ids, 'suppressed'=>[]];
}
}

if (!function_exists('ua_rx_build_snapshot_now')) {
function ua_rx_build_snapshot_now(mysqli $conn, bool $applyToSubscribers = true): array {
    ua_rx_ensure_subscriber_schema($conn);
    if (function_exists('airmax_ensure_probe_schema')) airmax_ensure_probe_schema($conn);
    $idx = ua_rx_subscriber_mac_index($conn);
    $sectorLookup = ua_rx_sector_lookup($conn);
    $allDevices = function_exists('airmax_get_all') ? airmax_get_all($conn, false) : [];
    $topology = ua_rx_build_topology($allDevices);

    $activeDevices = [];
    foreach ($allDevices as $d) {
        if (!empty($d['is_enabled'])) $activeDevices[] = $d;
    }

    $currentDeviceMacs = [];
    $currentDeviceInfo = [];
    $deviceMacs = [];
    $macDevices = [];
    $skippedAccess = [];
    $activeStationsById = [];
    $cachedOfficialSnapshot = ua_rx_json_read(ua_rx_snapshot_file(), []);
    $idx = function_exists('ua_rx_known_subscriber_mac_map_apply_to_index') ? ua_rx_known_subscriber_mac_map_apply_to_index($idx, is_array($cachedOfficialSnapshot) ? $cachedOfficialSnapshot : []) : $idx;
    $byMac = $idx['by_mac'];
    $currentScan = function_exists('ua_rx_current_scan_read') ? ua_rx_current_scan_read() : ['devices'=>[]];
    $currentScanDevices = is_array($currentScan['devices'] ?? null) ? $currentScan['devices'] : [];
    $currentScanExpected = is_array($currentScan['expected_devices'] ?? null) ? $currentScan['expected_devices'] : [];
    $currentScanFailed = is_array($currentScan['failed_devices'] ?? null) ? $currentScan['failed_devices'] : [];
    $currentScanHasManifest = !empty($currentScanExpected);
    $currentScanId = (string)($currentScan['scan_id'] ?? '');
    $currentScanTs = (int)($currentScan['scan_completed_at'] ?? $currentScan['ts'] ?? 0);

    foreach ($activeDevices as $d) {
        $id = (int)($d['id'] ?? 0);
        if ($id <= 0) continue;
        if (!ua_rx_device_is_station($d)) {
            $skippedAccess[$id] = [
                'id'=>$id,
                'name'=>(string)($d['name'] ?? ''),
                'host'=>(string)($d['host'] ?? ''),
                'device_type'=>ua_rx_device_role($d['device_type'] ?? ''),
                'parent_device_id'=>ua_rx_device_parent_id($d),
                'topology_depth'=>(int)($topology['depths'][$id] ?? 0),
            ];
            continue;
        }
        $scanEntry = [];
        if (isset($currentScanDevices[$id]) && is_array($currentScanDevices[$id])) {
            $scanEntry = $currentScanDevices[$id];
        } elseif (isset($currentScanDevices[(string)$id]) && is_array($currentScanDevices[(string)$id])) {
            $scanEntry = $currentScanDevices[(string)$id];
        }
        if ($currentScanHasManifest && !$scanEntry) {
            // This station was part of the current full round but did not return a usable
            // fresh result. Do not carry forward last-known MACs for it in this build.
            continue;
        }
        $activeStationsById[$id] = $d;
        $m = $scanEntry ? ua_rx_current_scan_entry_normalize($scanEntry) : [
            'macs'=>[],
            'raw_macs'=>[],
            'source'=>'current_scan_missing',
            'cache_ts'=>0,
            'raw_count'=>0,
            'client_count'=>0,
            'client_count_method'=>'current_scan_missing',
            'ownership_policy'=>'',
            'scan_id'=>$currentScanId,
        ];
        $cacheTs = (int)($m['cache_ts'] ?? 0);
        $age = $cacheTs > 0 ? max(0, time() - $cacheTs) : 999999;
        $currentDeviceMacs[$id] = array_values((array)($m['macs'] ?? []));
        $currentDeviceInfo[$id] = [
            'device'=>$d,
            'sector'=>ua_rx_resolve_device_sector($d, $sectorLookup),
            'raw_macs'=>(array)($m['raw_macs'] ?? []),
            'source'=>(string)($m['source'] ?? ''),
            'age'=>$age,
            'cache_ts'=>$cacheTs,
            'raw_count'=>(int)($m['raw_count'] ?? 0),
            'current_client_count'=>(int)($m['client_count'] ?? count((array)($m['macs'] ?? []))),
            'client_count_method'=>(string)($m['client_count_method'] ?? ''),
            'ownership_policy'=>(string)($m['ownership_policy'] ?? ''),
            'scan_id'=>$currentScanId,
            'parent_device_id'=>ua_rx_device_parent_id($d),
            'topology_depth'=>(int)($topology['depths'][$id] ?? 0),
        ];
    }

    /*
     * Receiver matching is now cumulative and staged:
     * - The previous last-successful MAC map is copied first.
     * - The current probe output is treated as the working/current scan.
     * - Current sightings are merged over the preserved map; a new sighting wins if a MAC moved.
     * - MACs absent from this scan remain on their last-known receiver so offline subscribers do not lose their sector.
     */
    $cumulative = ua_rx_cumulative_prepare($currentDeviceMacs, $currentDeviceInfo, array_keys($activeStationsById), is_array($cachedOfficialSnapshot) ? $cachedOfficialSnapshot : []);
    foreach ($activeStationsById as $id => $d) {
        $base = (array)($currentDeviceInfo[$id] ?? []);
        $mergedMacs = array_values((array)($cumulative['device_macs'][$id] ?? []));
        $deviceMacs[$id] = [
            'device'=>$d,
            'sector'=>(array)($base['sector'] ?? ua_rx_resolve_device_sector($d, $sectorLookup)),
            'macs'=>$mergedMacs,
            'current_macs'=>array_values((array)($currentDeviceMacs[$id] ?? [])),
            'observed_now'=>(array)($cumulative['observed_now'][$id] ?? []),
            'last_seen_at'=>(array)($cumulative['last_seen_at'][$id] ?? []),
            'first_seen_at'=>(array)($cumulative['first_seen_at'][$id] ?? []),
            'origin'=>(array)($cumulative['origin'][$id] ?? []),
            'raw_macs'=>(array)($base['raw_macs'] ?? []),
            'source'=>(string)($base['source'] ?? ''),
            'age'=>(int)($base['age'] ?? 999999),
            'cache_ts'=>(int)($base['cache_ts'] ?? 0),
            'raw_count'=>(int)($base['raw_count'] ?? 0),
            'client_count'=>count($mergedMacs),
            'current_client_count'=>(int)($base['current_client_count'] ?? count((array)($currentDeviceMacs[$id] ?? []))),
            'last_known_client_count'=>max(0, count($mergedMacs) - (int)($base['current_client_count'] ?? count((array)($currentDeviceMacs[$id] ?? [])))),
            'client_count_method'=>(string)($base['client_count_method'] ?? ''),
            'ownership_policy'=>(string)($base['ownership_policy'] ?? ''),
            'parent_device_id'=>ua_rx_device_parent_id($d),
            'topology_depth'=>(int)($topology['depths'][$id] ?? 0),
        ];
        foreach ($mergedMacs as $mac) $macDevices[$mac][$id] = true;
    }

    // Add persisted MikroTik router-port sources as independent custom receiver rows.
    // These rows are included in the official snapshot/dashboard, but they do not compete with
    // receiver topology or overwrite subscriber receiver fields. Duplicate subscriber ownership is
    // still reported to security monitoring during the matching pass.
    $routerPortSources = function_exists('ua_rx_router_port_sources_active') ? ua_rx_router_port_sources_active() : [];
    foreach ($routerPortSources as $pid => $rp) {
        $pid = (int)$pid;
        if ($pid <= 0) continue;
        $routerName = trim((string)($rp['router_name'] ?? 'Router'));
        $iface = trim((string)($rp['interface'] ?? ''));
        $label = trim((string)($rp['interface_label'] ?? $iface));
        $name = trim((string)($rp['name'] ?? ''));
        if ($name === '') $name = 'منفذ راوتر: ' . trim($routerName . ' / ' . ($label !== '' ? $label : $iface));
        $d = [
            'id'=>$pid,
            'name'=>$name,
            'host'=>(string)($rp['router_host'] ?? ''),
            'device_type'=>'router_port',
            'is_enabled'=>1,
            'source_type'=>'router_port',
        ];
        $activeStationsById[$pid] = $d;
        $macList = array_values((array)($rp['macs'] ?? []));
        $currentDeviceMacs[$pid] = $macList;
        $cacheTs = (int)($rp['cache_ts'] ?? $rp['ts'] ?? 0);
        $age = $cacheTs > 0 ? max(0, time() - $cacheTs) : 999999;
        $currentDeviceInfo[$pid] = [
            'device'=>$d,
            'sector'=>['id'=>0,'name'=>'','vlan_id'=>0,'match_method'=>'router_port_custom'],
            'macs'=>$macList,
            'raw_macs'=>array_values((array)($rp['raw_macs'] ?? [])),
            'source'=>(string)($rp['source'] ?? 'router_port_bridge_host'),
            'source_type'=>'router_port',
            'router_id'=>(int)($rp['router_id'] ?? 0),
            'router_name'=>$routerName,
            'router_host'=>(string)($rp['router_host'] ?? ''),
            'interface'=>$iface,
            'interface_label'=>$label,
            'key'=>(string)($rp['key'] ?? ua_rx_router_port_source_key((int)($rp['router_id'] ?? 0), $iface)),
            'age'=>$age,
            'cache_ts'=>$cacheTs,
            'raw_count'=>(int)($rp['raw_count'] ?? count((array)($rp['raw_macs'] ?? []))),
            'current_client_count'=>(int)($rp['client_count'] ?? count($macList)),
            'client_count_method'=>(string)($rp['client_count_method'] ?? 'router_port_bridge_host_selected_interface'),
            'ownership_policy'=>(string)($rp['ownership_policy'] ?? 'custom_router_port_ownership_rows'),
            'scan_id'=>'router_port_' . (string)($rp['key'] ?? $pid),
            'parent_device_id'=>0,
            'topology_depth'=>0,
        ];
        $deviceMacs[$pid] = [
            'device'=>$d,
            'sector'=>(array)$currentDeviceInfo[$pid]['sector'],
            'macs'=>$macList,
            'current_macs'=>$macList,
            'observed_now'=>array_fill_keys($macList, true),
            'last_seen_at'=>array_fill_keys($macList, $cacheTs ?: time()),
            'first_seen_at'=>array_fill_keys($macList, $cacheTs ?: time()),
            'origin'=>array_fill_keys($macList, 'router_port_current_scan'),
            'raw_macs'=>(array)$currentDeviceInfo[$pid]['raw_macs'],
            'source'=>(string)$currentDeviceInfo[$pid]['source'],
            'source_type'=>'router_port',
            'router_id'=>(int)$currentDeviceInfo[$pid]['router_id'],
            'router_name'=>$routerName,
            'router_host'=>(string)$currentDeviceInfo[$pid]['router_host'],
            'interface'=>$iface,
            'interface_label'=>$label,
            'key'=>(string)($rp['key'] ?? ua_rx_router_port_source_key((int)($rp['router_id'] ?? 0), $iface)),
            'age'=>$age,
            'cache_ts'=>$cacheTs,
            'raw_count'=>(int)$currentDeviceInfo[$pid]['raw_count'],
            'client_count'=>count($macList),
            'current_client_count'=>(int)$currentDeviceInfo[$pid]['current_client_count'],
            'last_known_client_count'=>0,
            'client_count_method'=>(string)$currentDeviceInfo[$pid]['client_count_method'],
            'ownership_policy'=>(string)$currentDeviceInfo[$pid]['ownership_policy'],
            'parent_device_id'=>0,
            'topology_depth'=>0,
        ];
        // Router-port rows stay independent from receiver topology and are not added to the global MAC-device arbitration map.
    }

    // If the same MAC appears on an upstream Station and a downstream Station, the downstream/nearest Station wins.
    // If stations are unrelated in the configured topology, keep the MAC ambiguous and do not auto-assign it.
    $macOwnerDevice = [];
    $macPickMeta = [];
    $topologyResolvedMacs = 0;
    $topologySuppressedMacs = 0;
    $deviceSpecificityWeights = [];
    foreach ($deviceMacs as $did => $info) {
        $deviceSpecificityWeights[(int)$did] = max(0, (int)($info['current_client_count'] ?? count((array)($info['current_macs'] ?? $info['macs'] ?? []))));
    }
    foreach ($macDevices as $mac => $devSet) {
        $ids = array_keys((array)$devSet);
        $pick = ua_rx_topology_pick_deepest($ids, $topology, $deviceSpecificityWeights);
        $macPickMeta[$mac] = $pick;
        if (empty($pick['ambiguous']) && (int)($pick['device_id'] ?? 0) > 0) {
            $macOwnerDevice[$mac] = (int)$pick['device_id'];
            if (in_array(($pick['method'] ?? ''), ['topology_nearest_station','specific_receiver_smaller_client_set'], true)) {
                $topologyResolvedMacs++;
                $topologySuppressedMacs += count((array)($pick['suppressed'] ?? []));
            }
        }
    }

    $preItems = [];
    $sidDevices = [];
    foreach ($deviceMacs as $id => $info) {
        $matched = [];
        $knownMac = [];
        $unknown = [];
        $ambMac = [];
        $conflictDetails = [];
        $shadowMac = [];
        $isRouterPortItem = ((string)($info['source_type'] ?? '') === 'router_port');
        foreach ($info['macs'] as $mac) {
            $seenBy = $isRouterPortItem ? [(int)$id] : array_keys((array)($macDevices[$mac] ?? []));
            if (!$isRouterPortItem && count($seenBy) > 1) {
                $winner = (int)($macOwnerDevice[$mac] ?? 0);
                if ($winner <= 0) {
                    $ambMac[] = $mac;
                    $conflictDetails[] = ua_rx_conflict_make(
                        'unrelated_stations',
                        $mac,
                        'ظهر نفس MAC على أكثر من Station ولا توجد علاقة متصل خلف تحسم الأقرب.',
                        ['devices'=>ua_rx_device_refs($seenBy, $topology)]
                    );
                    continue;
                }
                if ($winner !== (int)$id) { $shadowMac[] = $mac; continue; }
            }
            $owners = $byMac[$mac] ?? [];
            if (!$owners) { $unknown[] = $mac; continue; }
            $knownMac[$mac] = true;
            if (count($owners) !== 1) {
                // لا نسقط MAC المتعارض من المطابقة. نطابقه مع أفضل مرشح حالي، ونحوّل
                // ثبات الملكية/الإنذار إلى مراقب الشبكة فقط.
                $ambMac[] = $mac;
                if (function_exists('uamac_register_conflict')) {
                    uamac_register_conflict($conn, $mac, $owners, 'رصدت المطابقة أن هذا MAC موجود في بيانات أكثر من مشترك. تم إدخاله في المطابقة مع أفضل مرشح، وتُترك ثبات الملكية للمراقبة الأمنية.', 'security_monitor_duplicate_ownership');
                }
                $conflictDetails[] = ua_rx_conflict_make(
                    'multiple_subscribers_same_mac',
                    $mac,
                    'هذا MAC له أكثر من مالك محتمل. تم احتسابه في المطابقة، أما ثبات الملكية فمحوّل للمراقبة الأمنية.',
                    ['subscribers'=>ua_rx_owner_refs($owners), 'security_monitoring'=>true]
                );
                $owner = function_exists('ua_rx_choose_best_owner') ? ua_rx_choose_best_owner($owners) : (array)reset($owners);
                if (is_array($owner)) {
                    $owner['ownership_conflict'] = 1;
                    $owner['ownership_candidates'] = ua_rx_owner_refs($owners);
                }
            } else {
                $owner = reset($owners);
            }
            $sid = (int)($owner['id'] ?? 0);
            if ($sid <= 0) continue;
            $observedNow = !empty($info['observed_now'][$mac]);
            $confidence = (count($seenBy) > 1) ? 'topology_nearest_station' : 'unique_mac';
            if (!empty($owner['ownership_conflict'])) $confidence .= '_duplicate_owner_security_monitor';
            if (!$observedNow) $confidence = 'last_known_' . $confidence;
            $matched[$sid] = [
                'subscriber'=>$owner,
                'mac'=>$mac,
                'confidence'=>$confidence,
                'seen_by'=>$seenBy,
                'observed_now'=>$observedNow,
                'last_seen_at'=>(int)($info['last_seen_at'][$mac] ?? time()),
                'first_seen_at'=>(int)($info['first_seen_at'][$mac] ?? ($info['last_seen_at'][$mac] ?? time())),
                'cumulative_origin'=>(string)($info['origin'][$mac] ?? ($observedNow ? 'current_scan' : 'last_known')),
            ];
            // Router-port rows are allowed to update the subscriber displayed source later,
            // but they must not participate in receiver topology suppression/nearest-station arbitration.
            if (!$isRouterPortItem) $sidDevices[$sid][$id] = true;
        }
        $preItems[$id] = ['matched'=>$matched, 'known_macs'=>array_keys($knownMac), 'unknown'=>$unknown, 'ambiguous_macs'=>$ambMac, 'conflict_details'=>$conflictDetails, 'topology_shadow_macs'=>$shadowMac];
    }

    $sidOwnerDevice = [];
    $sidPickMeta = [];
    foreach ($sidDevices as $sid => $devSet) {
        $ids = array_keys((array)$devSet);
        $pick = ua_rx_topology_pick_deepest($ids, $topology);
        $sidPickMeta[(int)$sid] = $pick;
        if (empty($pick['ambiguous']) && (int)($pick['device_id'] ?? 0) > 0) $sidOwnerDevice[(int)$sid] = (int)$pick['device_id'];
    }

    $items = [];
    $subscriberMatches = [];

    // Keep receiver counters and custom router-port counters strictly separate.
    // Router ports may update subscriber display/location and appear as independent rows,
    // but they must not inflate the receiver dashboard/report totals.
    $rxMatched = 0;
    $rxRaw = 0;
    $rxClient = 0;
    $rxCurrentClient = 0;
    $rxLastKnownClient = 0;
    $rxKnown = 0;
    $rxUnknown = 0;
    $rxAmbiguous = 0;
    $rxShadow = 0;
    $rxItems = 0;

    $portMatched = 0;
    $portRaw = 0;
    $portClient = 0;
    $portCurrentClient = 0;
    $portKnown = 0;
    $portUnknown = 0;
    $portAmbiguous = 0;
    $portShadow = 0;
    $portItems = 0;

    $topologyResolvedSubscribers = 0;
    foreach ($deviceMacs as $id => $info) {
        $d = $info['device'];
        $isRouterPortItem = ((string)($info['source_type'] ?? '') === 'router_port');
        $final = [];
        $ambiguousSubs = [];
        $subscriberShadow = [];
        $conflictDetails = (array)($preItems[$id]['conflict_details'] ?? []);
        foreach ($preItems[$id]['matched'] as $sid => $m) {
            $sid = (int)$sid;
            if (isset($sidDevices[$sid]) && count($sidDevices[$sid]) > 1) {
                $winner = (int)($sidOwnerDevice[$sid] ?? 0);
                if ($winner <= 0) {
                    $ambiguousSubs[] = $sid;
                    $conflictDetails[] = ua_rx_conflict_make(
                        'subscriber_unrelated_stations',
                        (string)($m['mac'] ?? ''),
                        'هذا المشترك ظهر خلف أكثر من Station ولا توجد علاقة متصل خلف تحسم الأقرب.',
                        ['subscriber'=>(array)($m['subscriber'] ?? []), 'devices'=>ua_rx_device_refs(array_keys((array)($sidDevices[$sid] ?? [])), $topology)]
                    );
                    continue;
                }
                if ($winner !== (int)$id) { $subscriberShadow[] = $sid; continue; }
                $topologyResolvedSubscribers++;
                $m['confidence'] = !empty($m['observed_now']) ? 'topology_nearest_station' : 'last_known_topology_nearest_station';
            }
            $final[$sid] = $m;
            $subscriberMatches[$sid] = [
                'subscriber_id'=>$sid,
                'device_id'=>$id,
                'device_name'=>(string)($d['name'] ?? ''),
                'device_type'=>ua_rx_device_role($d['device_type'] ?? ''),
                'source_type'=>(string)($info['source_type'] ?? 'receiver'),
                'router_id'=>(int)($info['router_id'] ?? 0),
                'router_name'=>(string)($info['router_name'] ?? ''),
                'interface'=>(string)($info['interface'] ?? ''),
                'interface_label'=>(string)($info['interface_label'] ?? ''),
                'host'=>(string)($d['host'] ?? ''),
                'parent_device_id'=>(int)$info['parent_device_id'],
                'topology_depth'=>(int)$info['topology_depth'],
                'sector_id'=>(int)($info['sector']['id'] ?? 0),
                'sector_name'=>(string)($info['sector']['name'] ?? ''),
                'sector_vlan_id'=>(int)($info['sector']['vlan_id'] ?? 0),
                'sector_match_method'=>(string)($info['sector']['match_method'] ?? ''),
                'mac'=>(string)$m['mac'],
                'source'=>(string)$info['source'],
                'confidence'=>(string)($m['confidence'] ?? 'unique_mac'),
                'observed_now'=>!empty($m['observed_now']),
                'ownership_conflict'=>!empty($m['subscriber']['ownership_conflict'] ?? false),
                'cumulative_origin'=>(string)($m['cumulative_origin'] ?? (!empty($m['observed_now']) ? 'current_scan' : 'last_known')),
                'last_seen_at'=>(int)($m['last_seen_at'] ?? time()),
                'last_seen_iso'=>ua_rx_sql_datetime($m['last_seen_at'] ?? time()),
                'first_seen_at'=>(int)($m['first_seen_at'] ?? ($m['last_seen_at'] ?? time())),
            ];
        }
        $shadowCount = count($preItems[$id]['topology_shadow_macs']) + count($subscriberShadow);
        $item = [
            'device_id'=>$id,
            'name'=>(string)($d['name'] ?? ''),
            'host'=>(string)($d['host'] ?? ''),
            'device_type'=>ua_rx_device_role($d['device_type'] ?? ''),
            'source_type'=>(string)($info['source_type'] ?? 'receiver'),
            'router_id'=>(int)($info['router_id'] ?? 0),
            'router_name'=>(string)($info['router_name'] ?? ''),
            'router_host'=>(string)($info['router_host'] ?? ''),
            'interface'=>(string)($info['interface'] ?? ''),
            'interface_label'=>(string)($info['interface_label'] ?? ''),
            'key'=>(string)($info['key'] ?? ''),
            'parent_device_id'=>(int)$info['parent_device_id'],
            'topology_depth'=>(int)$info['topology_depth'],
            'sector_id'=>(int)($info['sector']['id'] ?? 0),
            'sector_name'=>(string)($info['sector']['name'] ?? ''),
            'sector_vlan_id'=>(int)($info['sector']['vlan_id'] ?? 0),
            'sector_match_method'=>(string)($info['sector']['match_method'] ?? ''),
            'raw_mac_count'=>count((array)($info['raw_macs'] ?? [])),
            'client_mac_count'=>count((array)$info['macs']),
            'current_client_mac_count'=>(int)($info['current_client_count'] ?? 0),
            'last_known_client_mac_count'=>max(0, count((array)$info['macs']) - (int)($info['current_client_count'] ?? 0)),
            'raw_bridge_count'=>(int)$info['raw_count'],
            'client_count_method'=>(string)($info['client_count_method'] ?? ''),
            'ownership_policy'=>(string)($info['ownership_policy'] ?? ''),
            'known_mac_count'=>count((array)($preItems[$id]['known_macs'] ?? [])),
            'matched_count'=>count($final),
            'unknown_count'=>count($preItems[$id]['unknown']),
            'ambiguous_count'=>count($preItems[$id]['ambiguous_macs']) + count($ambiguousSubs),
            'conflict_details'=>array_values($conflictDetails),
            'conflict_preview'=>ua_rx_conflict_preview($conflictDetails, 3),
            'topology_shadow_count'=>$shadowCount,
            'cache_age'=>(int)$info['age'],
            'source'=>(string)$info['source'],
            // Store enough identity data for the dashboard live pair (base/live) to be
            // recalculated without rebuilding the snapshot.  Earlier snapshots stored only the
            // subscriber object; keep that shape, but enrich it with the exact matched MAC and
            // confidence so live PPP caller-id can be intersected with the same receiver row.
            'matched_macs'=>array_values(array_unique(array_filter(array_map(static function($m){
                return (string)($m['mac'] ?? '');
            }, $final), static fn($v) => trim((string)$v) !== ''))),
            'matched_subscribers'=>array_values(array_map(static function($m){
                $sub = (array)($m['subscriber'] ?? []);
                $sub['matched_mac'] = (string)($m['mac'] ?? '');
                $sub['match_confidence'] = (string)($m['confidence'] ?? '');
                $sub['observed_now'] = !empty($m['observed_now']) ? 1 : 0;
                $sub['match_last_seen_at'] = (int)($m['last_seen_at'] ?? 0);
                return $sub;
            }, $final)),
        ];
        $items[$id] = $item;
        if ($isRouterPortItem) {
            $portItems++;
            $portMatched += (int)$item['matched_count'];
            $portRaw += (int)$item['raw_mac_count'];
            $portClient += (int)$item['client_mac_count'];
            $portCurrentClient += (int)$item['current_client_mac_count'];
            $portKnown += (int)$item['known_mac_count'];
            $portUnknown += (int)$item['unknown_count'];
            $portAmbiguous += (int)$item['ambiguous_count'];
            $portShadow += (int)$item['topology_shadow_count'];
        } else {
            $rxItems++;
            $rxMatched += (int)$item['matched_count'];
            $rxRaw += (int)$item['raw_mac_count'];
            $rxClient += (int)$item['client_mac_count'];
            $rxCurrentClient += (int)$item['current_client_mac_count'];
            $rxLastKnownClient += (int)$item['last_known_client_mac_count'];
            $rxKnown += (int)$item['known_mac_count'];
            $rxUnknown += (int)$item['unknown_count'];
            $rxAmbiguous += (int)$item['ambiguous_count'];
            $rxShadow += (int)$item['topology_shadow_count'];
        }
    }

    $receiverSummary = [
        'devices'=>$rxItems,
        'stations'=>$rxItems,
        'raw_mac_total'=>$rxRaw,
        'client_mac_total'=>$rxClient,
        'current_client_mac_total'=>$rxCurrentClient,
        'last_known_client_mac_total'=>$rxLastKnownClient,
        'known_mac_total'=>$rxKnown,
        'matched_total'=>$rxMatched,
        'unknown_total'=>$rxUnknown,
        'ambiguous_total'=>$rxAmbiguous,
        'topology_shadow_total'=>$rxShadow,
    ];
    $routerPortSummary = [
        'devices'=>$portItems,
        'ports'=>$portItems,
        'raw_mac_total'=>$portRaw,
        'client_mac_total'=>$portClient,
        'current_client_mac_total'=>$portCurrentClient,
        'last_known_client_mac_total'=>0,
        'known_mac_total'=>$portKnown,
        'matched_total'=>$portMatched,
        'unknown_total'=>$portUnknown,
        'ambiguous_total'=>$portAmbiguous,
        'topology_shadow_total'=>$portShadow,
    ];
    $combinedSummary = [
        'devices'=>count($items),
        'stations'=>count($items),
        'raw_mac_total'=>$rxRaw + $portRaw,
        'client_mac_total'=>$rxClient + $portClient,
        'current_client_mac_total'=>$rxCurrentClient + $portCurrentClient,
        'last_known_client_mac_total'=>$rxLastKnownClient,
        'known_mac_total'=>$rxKnown + $portKnown,
        'matched_total'=>$rxMatched + $portMatched,
        'unknown_total'=>$rxUnknown + $portUnknown,
        'ambiguous_total'=>$rxAmbiguous + $portAmbiguous,
        'topology_shadow_total'=>$rxShadow + $portShadow,
    ];

    $snapshot = [
        'ok'=>true,
        'ts'=>time(),
        'source'=>'receiver_match_cumulative_current_scan_wins_topology_security_conflicts_not_blocking',
        'items'=>$items,
        'subscriber_matches'=>$subscriberMatches,
        // summary intentionally remains receiver-only for backward compatibility with the dashboard.
        // Custom router ports are exposed separately in router_port_summary and combined_summary.
        'summary'=>$receiverSummary + [
            'access_skipped'=>count($skippedAccess),
            'topology_resolved_macs'=>$topologyResolvedMacs,
            'topology_suppressed_macs'=>$topologySuppressedMacs,
            'topology_resolved_subscribers'=>$topologyResolvedSubscribers,
            'router_port_sources'=>count($routerPortSources ?? []),
        ],
        'receiver_summary'=>$receiverSummary + [
            'access_skipped'=>count($skippedAccess),
            'topology_resolved_macs'=>$topologyResolvedMacs,
            'topology_suppressed_macs'=>$topologySuppressedMacs,
            'topology_resolved_subscribers'=>$topologyResolvedSubscribers,
        ],
        'router_port_summary'=>$routerPortSummary + [
            'sources'=>count($routerPortSources ?? []),
        ],
        'combined_summary'=>$combinedSummary + [
            'access_skipped'=>count($skippedAccess),
            'topology_resolved_macs'=>$topologyResolvedMacs,
            'topology_suppressed_macs'=>$topologySuppressedMacs,
            'topology_resolved_subscribers'=>$topologyResolvedSubscribers,
            'router_port_sources'=>count($routerPortSources ?? []),
        ],
        'topology'=>[
            'parents'=>$topology['parents'] ?? [],
            'depths'=>$topology['depths'] ?? [],
        ],
        'skipped_access'=>$skippedAccess,
    ];

    $cumulativeCommit = function_exists('ua_rx_cumulative_commit') ? ua_rx_cumulative_commit($cumulative, $macOwnerDevice, $activeStationsById, $topology) : [];
    $snapshot['current_scan'] = [
        'file'=>function_exists('ua_rx_current_scan_file') ? basename(ua_rx_current_scan_file()) : 'current_scan.json',
        'scan_id'=>$currentScanId,
        'scan_completed_at'=>$currentScanTs,
        'age'=>$currentScanTs > 0 ? max(0, time() - $currentScanTs) : 999999,
        'devices'=>is_array($currentScan['devices'] ?? null) ? count($currentScan['devices']) : 0,
        'expected_devices'=>is_array($currentScanExpected ?? null) ? count($currentScanExpected) : 0,
        'failed_devices'=>is_array($currentScanFailed ?? null) ? count($currentScanFailed) : 0,
        'partial'=>!empty($currentScan['summary']['partial']),
        'summary'=>(array)($currentScan['summary'] ?? []),
        'policy'=>'official_matching_uses_only_last_committed_full_scan_not_mixed_result_caches',
        'ownership_policy'=>[
            'ubiquiti'=>'LAN0/eth0 client rows only; raw bridge MACs are diagnostics and never compete with ownership.',
            'mimosa'=>'Diagnostics FDB Ethernet/port 4 rows are ownership rows; Wireless/non-Ethernet rows remain diagnostics.',
            'conflicts'=>'Compare only receiver ownership rows. Duplicate subscriber ownership is reported to security monitoring but does not block matching.',
            'router_port'=>'Persisted custom RouterOS bridge host port rows are displayed as independent dashboard/report rows and may become the subscriber displayed location/port when matched.',
        ],
    ];
    $snapshot['cumulative_cache'] = [
        'enabled'=>true,
        'file'=>basename(ua_rx_cumulative_map_file()),
        'policy'=>'preserve_last_successful_map_current_scan_wins_on_conflict',
        'prepare_summary'=>(array)($cumulative['summary'] ?? []),
        'commit_summary'=>$cumulativeCommit,
    ];
    $snapshot['summary']['cumulative_previous_known'] = (int)($cumulative['summary']['previous_known'] ?? 0);
    $snapshot['summary']['cumulative_current_observed'] = (int)($cumulative['summary']['current_observed'] ?? 0);
    $snapshot['summary']['cumulative_known_total'] = (int)($cumulativeCommit['known_total'] ?? 0);
    $snapshot['summary']['cumulative_updated_from_current'] = (int)($cumulativeCommit['updated_from_current'] ?? 0);
    $snapshot['summary']['current_scan_partial'] = !empty($currentScan['summary']['partial']) ? 1 : 0;
    $snapshot['summary']['current_scan_expected_devices'] = is_array($currentScanExpected ?? null) ? count($currentScanExpected) : 0;
    $snapshot['summary']['current_scan_failed_devices'] = is_array($currentScanFailed ?? null) ? count($currentScanFailed) : 0;

    $knownSubscriberCommit = function_exists('ua_rx_known_subscriber_mac_map_commit') ? ua_rx_known_subscriber_mac_map_commit($subscriberMatches, $idx) : [];
    $snapshot['subscriber_identity_index'] = [
        'source_policy'=>(string)($idx['source_policy'] ?? 'ppp_active_cache_then_subscriber_db_last_known_mac'),
        'source_stats'=>(array)($idx['source_stats'] ?? []),
        'ppp_active'=>(array)($idx['ppp_active_summary'] ?? []),
        'by_mac_unique'=>is_array($idx['by_mac'] ?? null) ? count($idx['by_mac']) : 0,
        'subscriber_rows'=>is_array($idx['owners'] ?? null) ? count($idx['owners']) : 0,
        'history_fallback'=>(array)($idx['history_fallback_summary'] ?? []),
        'note'=>'المطابقة الرسمية تستخدم أولاً caller_id من كاش PPP النشط الموجود في admin/cache/ppp_active.json دون أي جلب حي جديد، ثم current/last MAC من جدول المشتركين، ثم subscriber_mac_history كاحتياط. تعارض ملكية MAC لا يمنع المطابقة ويُحوّل للمراقبة الأمنية.',
    ];
    $snapshot['known_subscriber_mac_cache'] = [
        'enabled'=>true,
        'file'=>function_exists('ua_rx_known_subscriber_mac_map_file') ? basename(ua_rx_known_subscriber_mac_map_file()) : 'known_subscriber_mac_map.json',
        'policy'=>'preserve_last_successful_subscriber_mac_owner_when_ppp_is_offline_current_db_sources_win',
        'index_summary'=>(array)($idx['known_subscriber_mac_map_summary'] ?? []),
        'commit_summary'=>$knownSubscriberCommit,
    ];
    $snapshot['summary']['subscriber_identity_index_unique_macs'] = (int)($snapshot['subscriber_identity_index']['by_mac_unique'] ?? 0);
    $snapshot['summary']['subscriber_ppp_active_added'] = (int)($idx['ppp_active_summary']['added_or_overrode'] ?? 0);
    $snapshot['summary']['subscriber_ppp_active_cache_age'] = (int)($idx['ppp_active_summary']['age'] ?? 999999);
    $snapshot['summary']['subscriber_history_fallback_added'] = (int)($idx['history_fallback_summary']['added_latest_fallback'] ?? 0);
    $snapshot['summary']['known_subscriber_mac_known_total'] = (int)($knownSubscriberCommit['known_total'] ?? 0);
    $snapshot['summary']['known_subscriber_mac_added_to_index'] = (int)($idx['known_subscriber_mac_map_summary']['added_to_index'] ?? 0);

    if ($applyToSubscribers) {
        $reset = function_exists('ua_rx_reset_auto_match_columns') ? ua_rx_reset_auto_match_columns($conn) : 0;
        $updated = ua_rx_apply_matches_to_subscribers($conn, $subscriberMatches);
        $clearedAccess = function_exists('ua_rx_clear_non_station_matches') ? ua_rx_clear_non_station_matches($conn, array_keys($deviceMacs), array_keys($skippedAccess)) : 0;
        $stale = function_exists('ua_rx_cleanup_stale_matches') ? ua_rx_cleanup_stale_matches($conn, $subscriberMatches, $items, 48) : ['marked'=>0,'cleared'=>0,'skipped'=>true];
        $snapshot['summary']['subscriber_auto_fields_reset'] = (int)$reset;
        $snapshot['summary']['subscriber_rows_updated'] = (int)$updated;
        $snapshot['summary']['non_station_rows_cleared'] = (int)$clearedAccess;
        $snapshot['summary']['stale_marked'] = (int)($stale['marked'] ?? 0);
        $snapshot['summary']['stale_cleared'] = (int)($stale['cleared'] ?? 0);
        $snapshot['stale_cleanup'] = $stale;
    }
    ua_rx_json_write(ua_rx_snapshot_file(), $snapshot);
    return $snapshot;
}
}


if (!function_exists('ua_rx_reset_auto_match_columns')) {
function ua_rx_reset_auto_match_columns(mysqli $conn): int {
    /*
     * Force-clean the automatically generated receiver/sector display fields before
     * writing a new official snapshot. This prevents old receiver names from staying
     * in the subscribers table after the matching policy changes or after a new full
     * receiver refresh. It deliberately does not touch manually locked rows and does
     * not erase manual sector_id unless that sector was previously written by the
     * automatic matcher (sector_auto_device_id > 0).
     */
    ua_rx_ensure_subscriber_schema($conn);
    $updated = 0;

    $sets = [
        "airmax_device_id=NULL",
        "airmax_device_name=NULL",
        "airmax_device_type=NULL",
        "airmax_matched_mac=NULL",
        "airmax_match_source=NULL",
        "airmax_match_confidence=NULL",
        "airmax_matched_at=NULL",
        "airmax_match_last_seen_at=NULL",
        "airmax_match_missing_since=NULL",
        "airmax_match_stale_cleared_at=NULL",
    ];
    $sql = "UPDATE subscribers SET " . implode(', ', $sets)
         . " WHERE COALESCE(airmax_match_locked,0)=0"
         . " AND (airmax_device_id IS NOT NULL OR airmax_device_name IS NOT NULL OR airmax_matched_mac IS NOT NULL)";
    if (@$conn->query($sql)) $updated += max(0, (int)$conn->affected_rows);

    if (ua_rx_col_exists($conn, 'subscribers', 'sector_auto_device_id')) {
        $sectorSets = [
            "sector_auto_device_id=NULL",
            "sector_auto_matched_mac=NULL",
            "sector_auto_source=NULL",
            "sector_auto_confidence=NULL",
            "sector_auto_matched_at=NULL",
        ];
        if (ua_rx_col_exists($conn, 'subscribers', 'sector_id')) $sectorSets[] = "sector_id=NULL";
        if (ua_rx_col_exists($conn, 'subscribers', 'vlan_id')) $sectorSets[] = "vlan_id=NULL";
        $sql2 = "UPDATE subscribers SET " . implode(', ', $sectorSets)
              . " WHERE COALESCE(airmax_match_locked,0)=0 AND COALESCE(sector_auto_device_id,0)>0";
        if (@$conn->query($sql2)) $updated += max(0, (int)$conn->affected_rows);
    }
    return $updated;
}
}

if (!function_exists('ua_rx_apply_matches_to_subscribers')) {
function ua_rx_apply_matches_to_subscribers(mysqli $conn, array $matches): int {
    ua_rx_ensure_subscriber_schema($conn);
    $updated = 0;
    foreach ($matches as $sid => $m) {
        $sid = (int)$sid;
        if ($sid <= 0) continue;
        $deviceId = (int)($m['device_id'] ?? 0);
        $name = $conn->real_escape_string((string)($m['device_name'] ?? ''));
        $type = $conn->real_escape_string((string)($m['device_type'] ?? ''));
        $mac = $conn->real_escape_string((string)($m['mac'] ?? ''));
        $source = $conn->real_escape_string((string)($m['source'] ?? ''));
        $conf = $conn->real_escape_string((string)($m['confidence'] ?? 'unique_mac'));
        $observedNow = !empty($m['observed_now']);
        $lastSeenSql = $conn->real_escape_string(ua_rx_sql_datetime($m['last_seen_at'] ?? time()));
        $lastSeenSet = $observedNow ? "airmax_match_last_seen_at=NOW()" : "airmax_match_last_seen_at='{$lastSeenSql}'";

        // Always update the non-destructive receiver match fields unless the row is locked.
        // For cumulative/last-known matches, keep the historical last_seen time instead of pretending the MAC was seen now.
        $sql = "UPDATE subscribers SET "
             . "airmax_device_id={$deviceId}, "
             . "airmax_device_name='{$name}', "
             . "airmax_device_type='{$type}', "
             . "airmax_matched_mac='{$mac}', "
             . "airmax_match_source='{$source}', "
             . "airmax_match_confidence='{$conf}', "
             . "airmax_matched_at=NOW(), "
             . $lastSeenSet . ", "
             . "airmax_match_missing_since=NULL "
             . "WHERE id={$sid} AND COALESCE(airmax_match_locked,0)=0 AND (airmax_device_id IS NULL OR airmax_device_id<> {$deviceId} OR COALESCE(airmax_matched_mac,'')<>'{$mac}' OR COALESCE(airmax_match_confidence,'')<>'{$conf}' OR airmax_match_missing_since IS NOT NULL OR COALESCE(DATE_FORMAT(airmax_match_last_seen_at, '%Y-%m-%d %H:%i:%s'),'')<>'{$lastSeenSql}')";
        if (@$conn->query($sql) && $conn->affected_rows > 0) $updated++;

        // Optional safe sector fill: never overwrite a manual sector. It only fills empty sector_id
        // or updates rows previously filled by this automatic matcher.
        $sectorId = (int)($m['sector_id'] ?? 0);
        if ($sectorId > 0 && ua_rx_col_exists($conn, 'subscribers', 'sector_id')) {
            $vlanId = (int)($m['sector_vlan_id'] ?? 0);
            $sectorSource = $conn->real_escape_string((string)($m['sector_match_method'] ?? 'receiver_mac_match'));
            $sets = [
                "sector_id={$sectorId}",
                "sector_auto_device_id={$deviceId}",
                "sector_auto_matched_mac='{$mac}'",
                "sector_auto_source='{$sectorSource}'",
                "sector_auto_confidence='{$conf}'",
                "sector_auto_matched_at=NOW()",
            ];
            if ($vlanId > 0 && ua_rx_col_exists($conn, 'subscribers', 'vlan_id')) $sets[] = "vlan_id={$vlanId}";
            $sql2 = "UPDATE subscribers SET " . implode(', ', $sets)
                  . " WHERE id={$sid} AND COALESCE(airmax_match_locked,0)=0"
                  . " AND (COALESCE(sector_id,0)=0 OR COALESCE(sector_auto_device_id,0)>0)";
            if (@$conn->query($sql2) && $conn->affected_rows > 0) $updated++;
        }
    }
    return $updated;
}
}

if (!function_exists('ua_rx_clear_non_station_matches')) {
function ua_rx_clear_non_station_matches(mysqli $conn, array $stationDeviceIds, array $accessDeviceIds = []): int {
    ua_rx_ensure_subscriber_schema($conn);
    $updated = 0;
    $stationDeviceIds = array_values(array_filter(array_map('intval', $stationDeviceIds), static fn($v) => $v > 0));
    $accessDeviceIds = array_values(array_filter(array_map('intval', $accessDeviceIds), static fn($v) => $v > 0));
    if (!$stationDeviceIds && !$accessDeviceIds) return 0;

    $where = "COALESCE(airmax_match_locked,0)=0 AND airmax_device_id IS NOT NULL";
    if ($stationDeviceIds) {
        $where .= " AND COALESCE(airmax_device_id,0) NOT IN (" . implode(',', $stationDeviceIds) . ")";
    }
    $sql = "UPDATE subscribers SET "
         . "airmax_device_id=NULL, airmax_device_name=NULL, airmax_device_type=NULL, "
         . "airmax_matched_mac=NULL, airmax_match_source=NULL, airmax_match_confidence=NULL, "
         . "airmax_matched_at=NULL, airmax_match_missing_since=NULL, airmax_match_stale_cleared_at=NOW() "
         . "WHERE {$where}";
    if (@$conn->query($sql)) $updated += max(0, (int)$conn->affected_rows);

    // If sector_id was previously filled by the automatic matcher for an Access/Sector, clear only those automatic fields.
    if ($accessDeviceIds && ua_rx_col_exists($conn, 'subscribers', 'sector_auto_device_id')) {
        $ids = implode(',', $accessDeviceIds);
        $sets = [
            "sector_auto_device_id=NULL",
            "sector_auto_matched_mac=NULL",
            "sector_auto_source=NULL",
            "sector_auto_confidence=NULL",
            "sector_auto_matched_at=NULL",
        ];
        if (ua_rx_col_exists($conn, 'subscribers', 'sector_id')) $sets[] = "sector_id=NULL";
        if (ua_rx_col_exists($conn, 'subscribers', 'vlan_id')) $sets[] = "vlan_id=NULL";
        $sql2 = "UPDATE subscribers SET " . implode(', ', $sets)
              . " WHERE COALESCE(airmax_match_locked,0)=0 AND COALESCE(sector_auto_device_id,0) IN ({$ids})";
        if (@$conn->query($sql2)) $updated += max(0, (int)$conn->affected_rows);
    }
    return $updated;
}
}


if (!function_exists('ua_rx_cleanup_stale_matches')) {
function ua_rx_cleanup_stale_matches(mysqli $conn, array $matches, array $items, int $holdHours = 48): array {
    ua_rx_ensure_subscriber_schema($conn);
    $holdHours = max(12, min(720, $holdHours));

    // Cleanup is intentionally conservative: only devices with fresh receiver cache can mark rows missing.
    // This prevents old probe caches or offline receivers from erasing valid mappings.
    $freshDeviceIds = [];
    foreach ($items as $item) {
        $did = (int)($item['device_id'] ?? 0);
        if ($did <= 0) continue;
        $age = (int)($item['cache_age'] ?? 999999);
        $raw = (int)($item['raw_mac_count'] ?? 0);
        $src = trim((string)($item['source'] ?? ''));
        if ($age >= 0 && $age <= 900 && ($raw > 0 || $src !== '')) $freshDeviceIds[$did] = true;
    }
    if (!$freshDeviceIds) {
        return ['ok'=>true,'marked'=>0,'cleared'=>0,'skipped'=>true,'reason'=>'no fresh receiver cache'];
    }

    $matchedIds = [];
    foreach ($matches as $sid => $_m) { $sid = (int)$sid; if ($sid > 0) $matchedIds[$sid] = true; }
    $deviceList = implode(',', array_map('intval', array_keys($freshDeviceIds)));
    $notIn = $matchedIds ? (' AND id NOT IN (' . implode(',', array_map('intval', array_keys($matchedIds))) . ')') : '';

    $marked = 0;
    $sqlMark = "UPDATE subscribers SET airmax_match_missing_since=COALESCE(airmax_match_missing_since,NOW()) "
             . "WHERE COALESCE(airmax_match_locked,0)=0 AND COALESCE(airmax_device_id,0) IN ({$deviceList}){$notIn} "
             . "AND airmax_device_id IS NOT NULL";
    if (@$conn->query($sqlMark)) $marked = max(0, (int)$conn->affected_rows);

    // After the hold window, clear only the automatic receiver-match fields. Manual sector_id is not touched.
    $cleared = 0;
    $sqlClear = "UPDATE subscribers SET "
              . "airmax_device_id=NULL, airmax_device_name=NULL, airmax_device_type=NULL, "
              . "airmax_matched_mac=NULL, airmax_match_source=NULL, airmax_match_confidence=NULL, "
              . "airmax_matched_at=NULL, airmax_match_stale_cleared_at=NOW() "
              . "WHERE COALESCE(airmax_match_locked,0)=0 AND airmax_match_missing_since IS NOT NULL "
              . "AND airmax_match_missing_since < (NOW() - INTERVAL {$holdHours} HOUR)";
    if (@$conn->query($sqlClear)) $cleared = max(0, (int)$conn->affected_rows);

    return [
        'ok'=>true,
        'marked'=>$marked,
        'cleared'=>$cleared,
        'hold_hours'=>$holdHours,
        'fresh_devices'=>array_keys($freshDeviceIds),
        'matched_subscribers'=>array_keys($matchedIds),
        'skipped'=>false,
    ];
}
}

if (!function_exists('ua_receiver_match_snapshot')) {
function ua_receiver_match_snapshot(mysqli $conn, int $ttl = 120, bool $applyToSubscribers = true): array {
    /*
     * Important consistency rule:
     * - Reading the report/dashboard must NOT rebuild the official receiver matching result.
     * - Probing one receiver updates only that receiver cache. If we rebuild globally during a read,
     *   the result can mix one fresh receiver with older receiver caches and temporarily distort counts.
     * - A rebuild/apply is allowed only when the caller passes ttl <= 0, which is what
     *   api/receiver_match_apply.php does with force=1. This builds one atomic official snapshot
     *   from the current last-good caches of all receivers, without polling devices.
     */
    $file = ua_rx_snapshot_file();
    $cached = ua_rx_json_read($file, null);
    $age = is_array($cached) ? (time() - (int)($cached['ts'] ?? 0)) : 999999;
    $scanForFreshness = ua_rx_current_scan_read();
    $sourceScanAge = ua_rx_current_scan_age($scanForFreshness);
    $sourceScanStale = !ua_rx_current_scan_is_fresh($scanForFreshness);

    // Normal reads: return the last official snapshot only, even if it is older than ttl.
    if ($ttl > 0) {
        if (is_array($cached)) {
            $cached['age'] = $age;
            $cached['stale'] = ($age >= $ttl);
            $cached['source_scan_age'] = $sourceScanAge;
            $cached['source_scan_stale'] = $sourceScanStale;
            $cached['source_scan_max_age'] = ua_rx_current_scan_max_age();
            $cached['official_snapshot'] = true;
            $cached['read_mode'] = 'official_snapshot_only';
            return $cached;
        }
        return [
            'ok'=>false,
            'ts'=>0,
            'age'=>999999,
            'stale'=>true,
            'official_snapshot'=>false,
            'read_mode'=>'no_official_snapshot',
            'items'=>[],
            'subscriber_matches'=>[],
            'summary'=>['devices'=>0,'matched_total'=>0,'unknown_total'=>0,'ambiguous_total'=>0],
            'message'=>'لا توجد نتيجة مطابقة معتمدة بعد. شغّل فحص المستقبلات ثم اضغط تطبيق المطابقة من الكاش.'
        ];
    }

    if ($sourceScanStale) {
        return [
            'ok'=>false,
            'ts'=>(int)($cached['ts'] ?? 0),
            'age'=>$age,
            'stale'=>true,
            'official_snapshot'=>is_array($cached),
            'read_mode'=>'current_scan_stale_rebuild_blocked',
            'items'=>[],
            'subscriber_matches'=>[],
            'summary'=>['devices'=>0,'matched_total'=>0,'unknown_total'=>0,'ambiguous_total'=>0],
            'source_scan_age'=>$sourceScanAge,
            'source_scan_max_age'=>ua_rx_current_scan_max_age(),
            'source_scan_stale'=>true,
            'message'=>'فحص المستقبلات الحالي قديم، لذلك تم منع بناء مطابقة رسمية حتى لا تظهر أرقام خاطئة. شغّل فحص المستقبلات ثم طبّق المطابقة من الكاش.'
        ];
    }

    // Force/apply path: build and save a new official snapshot from the current receiver caches.
    $lock = ua_rx_lock_acquire(20);
    if (!$lock) {
        if (is_array($cached)) {
            $cached['age'] = $age;
            $cached['stale'] = true;
            $cached['official_snapshot'] = true;
            $cached['read_mode'] = 'official_snapshot_lock_busy';
            return $cached;
        }
        return ['ok'=>false,'ts'=>0,'age'=>999999,'items'=>[],'subscriber_matches'=>[],'summary'=>['devices'=>0,'matched_total'=>0,'unknown_total'=>0,'ambiguous_total'=>0],'message'=>'match lock busy'];
    }
    try {
        $snap = ua_rx_build_snapshot_now($conn, $applyToSubscribers);
        $snap['official_snapshot'] = true;
        $snap['read_mode'] = 'rebuilt_from_cache';
        // ua_rx_build_snapshot_now already writes the snapshot before returning; rewrite only to persist read_mode markers.
        ua_rx_json_write(ua_rx_snapshot_file(), $snap);
        return $snap;
    } finally {
        ua_rx_lock_release($lock);
    }
}
}


if (!function_exists('ua_receiver_match_live_counts')) {
function ua_receiver_match_live_counts(mysqli $conn, array $snapshot = []): array {
    /*
     * Display-only live counts for the dashboard pair:  base/live.
     * Root rule: the base number comes from the official receiver snapshot; the live number
     * must be recalculated from the current PPP cache without touching the receiver snapshot.
     *
     * Older revisions lost the live side when custom router-port rows were added because the
     * dashboard tried to infer current sessions only from subscriber_matches.  That can be
     * incomplete or shifted by custom rows.  The correct order is:
     *   1) count per snapshot item from its own matched_subscribers, using PPP username OR
     *      the exact matched MAC against ppp_active.json caller_id;
     *   2) count matched_macs directly against active PPP caller_id when the item has no
     *      enriched subscriber rows yet;
     *   3) fall back per device to subscribers.is_online + airmax_device_id when reading an
     *      old snapshot.
     */
    $pppCache = function_exists('ua_ppp_cache_snapshot') ? ua_ppp_cache_snapshot(86400) : [];
    $activeUsers = [];
    $activeMacs = [];

    foreach ((array)($pppCache['users'] ?? []) as $u) {
        $u = trim(preg_replace('/@.*/', '', (string)$u));
        if ($u !== '') $activeUsers[mb_strtolower($u, 'UTF-8')] = true;
    }
    foreach ((array)($pppCache['rows'] ?? []) as $row) {
        if (!is_array($row)) continue;
        $u = trim((string)($row['name'] ?? $row['username'] ?? $row['user'] ?? ''));
        $u = trim(preg_replace('/@.*/', '', $u));
        if ($u !== '') $activeUsers[mb_strtolower($u, 'UTF-8')] = true;
        $mac = function_exists('ua_rx_norm_mac') ? ua_rx_norm_mac($row['caller_id'] ?? ($row['caller-id'] ?? '')) : '';
        if ($mac !== '') $activeMacs[$mac] = true;
    }

    $byDevice = [];
    $countedItemSubscribers = [];
    $itemHadIdentity = [];

    foreach ((array)($snapshot['items'] ?? []) as $it) {
        if (!is_array($it)) continue;
        $did = (int)($it['device_id'] ?? 0);
        if ($did <= 0) continue;
        $seenForThisItem = [];
        $itemHadIdentity[$did] = false;

        foreach ((array)($it['matched_subscribers'] ?? []) as $sub) {
            if (!is_array($sub)) continue;
            $itemHadIdentity[$did] = true;
            $sid = (int)($sub['id'] ?? $sub['subscriber_id'] ?? 0);
            $u = trim((string)($sub['ppp_username'] ?? $sub['ppp_user'] ?? $sub['username'] ?? ''));
            $u = trim(preg_replace('/@.*/', '', $u));
            $uKey = $u !== '' ? mb_strtolower($u, 'UTF-8') : '';
            $mac = function_exists('ua_rx_norm_mac') ? ua_rx_norm_mac($sub['matched_mac'] ?? $sub['mac'] ?? $sub['current_caller_id'] ?? $sub['last_caller_id'] ?? '') : '';
            $isLive = ($uKey !== '' && !empty($activeUsers[$uKey])) || ($mac !== '' && !empty($activeMacs[$mac]));
            if (!$isLive) continue;

            $localKey = $sid > 0 ? ('sid:' . $sid) : ($uKey !== '' ? ('u:' . $uKey) : ('mac:' . $mac));
            if ($localKey === 'mac:' || isset($seenForThisItem[$localKey])) continue;
            $byDevice[$did] = (int)($byDevice[$did] ?? 0) + 1;
            $seenForThisItem[$localKey] = true;
            $countedItemSubscribers[$localKey] = true;
        }

        // Direct MAC intersection for snapshots that include matched_macs but have no enriched rows.
        if (empty($byDevice[$did])) {
            $seenMacs = [];
            foreach ((array)($it['matched_macs'] ?? []) as $m) {
                $mac = function_exists('ua_rx_norm_mac') ? ua_rx_norm_mac($m) : '';
                if ($mac === '' || empty($activeMacs[$mac]) || isset($seenMacs[$mac])) continue;
                $byDevice[$did] = (int)($byDevice[$did] ?? 0) + 1;
                $seenMacs[$mac] = true;
                $countedItemSubscribers['mac:' . $mac] = true;
            }
        }
    }

    // Conservative DB fallback for old official snapshots or cases where matched_subscribers were absent.
    $assignedOnline = [];
    if (function_exists('ua_rx_col_exists')
        && ua_rx_col_exists($conn, 'subscribers', 'is_online')
        && ua_rx_col_exists($conn, 'subscribers', 'airmax_device_id')) {
        $hasDeleted = ua_rx_col_exists($conn, 'subscribers', 'is_deleted');
        $where = "WHERE COALESCE(is_online,0)=1 AND COALESCE(airmax_device_id,0)>0" . ($hasDeleted ? " AND COALESCE(is_deleted,0)=0" : "");
        $rs = @$conn->query("SELECT airmax_device_id AS device_id, COUNT(*) AS c FROM subscribers {$where} GROUP BY airmax_device_id");
        if ($rs) {
            while ($r = $rs->fetch_assoc()) {
                $did = (int)($r['device_id'] ?? 0);
                if ($did > 0) $assignedOnline[$did] = (int)($r['c'] ?? 0);
            }
        }
    }

    foreach ((array)($snapshot['items'] ?? []) as $it) {
        if (!is_array($it)) continue;
        $did = (int)($it['device_id'] ?? 0);
        if ($did <= 0) continue;
        $matchedCount = (int)($it['matched_count'] ?? 0);
        if ($matchedCount <= 0) continue;
        $dbLive = (int)($assignedOnline[$did] ?? 0);
        if ($dbLive <= 0) continue;
        // Fill only missing/zero live counts. Do not override a more precise PPP-cache intersection.
        if ((int)($byDevice[$did] ?? 0) <= 0) {
            $byDevice[$did] = min($matchedCount, $dbLive);
        }
    }

    return [
        'total'=>array_sum($byDevice),
        'by_device'=>$byDevice,
        'active_seen'=>count($activeUsers),
        'active_macs'=>count($activeMacs),
        'matched_active_subscribers'=>count($countedItemSubscribers),
        'source'=>'ppp_active_cache_plus_snapshot_identity_db_fallback',
        'cache_age'=>(int)($pppCache['age'] ?? 999999),
    ];
}
}

if (!function_exists('ua_receiver_match_display_pair')) {
function ua_receiver_match_display_pair($official, $live): string {
    $a = is_numeric($official) ? (string)(int)$official : '—';
    $b = is_numeric($live) ? (string)(int)$live : '—';
    return $a . '/' . $b;
}
}

if (!function_exists('ua_receiver_match_by_device')) {
function ua_receiver_match_by_device(array $snapshot): array {
    $out = [];
    foreach ((array)($snapshot['items'] ?? []) as $id => $item) $out[(int)$id] = $item;
    return $out;
}
}

if (!function_exists('ua_receiver_match_by_subscriber')) {
function ua_receiver_match_by_subscriber(array $snapshot): array {
    $out = [];
    foreach ((array)($snapshot['subscriber_matches'] ?? []) as $sid => $m) $out[(int)$sid] = $m;
    return $out;
}
}
