<?php
declare(strict_types=1);
require_once "/var/www/iptv-diag/.env.php";
require_once __DIR__ . '/lib/allowlist.php';
require_once __DIR__ . '/lib/storage.php';

/**
 * NewEraSCTV Domain Diagnostic Engine — v4 (2026-04-18)
 *
 * Tests customer domains using the real Xtream Codes API paths:
 *   - Auth:     /player_api.php?username=X&password=Y
 *   - Channels: /player_api.php?username=X&password=Y&action=get_live_streams
 *   - Stream:   /live/username/password/streamID.ts (follows 302 redirects)
 *
 * Two vantage points: portal (this server) and Cloudflare Worker.
 *
 * CRITICAL DESIGN: All checks run SEQUENTIALLY per domain to respect
 * max_connections=1 panel limits. The flow for each domain is:
 *   1. DNS check
 *   2. TCP check
 *   3. Auth check (player_api.php)
 *   4. Stream ID fetch (once, on first domain that passes auth)
 *   5. Stream test (/live/u/p/streamID.ts)
 *   6. Brief pause to let the panel release the connection
 *   7. Move to next domain
 *
 * No curl_multi anywhere. No /get.php, /xmltv.php, or speed check.
 * Scoring: DNS(20) + TCP(20) + Auth(30) + Stream(30) = 100.
 */

// ═══════════════════════════════════════════════════════════
// HTTP plumbing
// ═══════════════════════════════════════════════════════════
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
header('X-Content-Type-Options: nosniff');

if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    echo json_encode(['error' => 'method_not_allowed']);
    exit;
}

// ═══════════════════════════════════════════════════════════
// Maintenance mode check
// ═══════════════════════════════════════════════════════════
$diag_config = ServiceAllowlist::config();
if (!empty($diag_config['maintenance_mode'])) {
    http_response_code(503);
    echo json_encode(['error' => 'maintenance', 'message' => 'The diagnostic system is currently undergoing maintenance. Please try again later.']);
    exit;
}

// ═══════════════════════════════════════════════════════════
// Rate limit (10/hour/IP)
// ═══════════════════════════════════════════════════════════
$ip = trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ($_SERVER['REMOTE_ADDR'] ?? 'unknown'))[0]);
$rate_file = sys_get_temp_dir() . '/iptv_diag_rate_' . md5($ip);
$now = time();
$rate_data = is_file($rate_file) ? (json_decode((string)file_get_contents($rate_file), true) ?: []) : [];
$rate_data = array_values(array_filter($rate_data, fn($t) => $t > $now - 3600));
if (count($rate_data) >= 10) {
    http_response_code(429);
    echo json_encode(['error' => 'rate_limited', 'retry_after' => 3600 - ($now - min($rate_data))]);
    exit;
}
$rate_data[] = $now;
file_put_contents($rate_file, json_encode($rate_data));

// ═══════════════════════════════════════════════════════════
// Parse input
// ═══════════════════════════════════════════════════════════
$input = json_decode((string)file_get_contents('php://input'), true);
if (!is_array($input)) {
    http_response_code(400);
    echo json_encode(['error' => 'invalid_json']);
    exit;
}

// Discord prefill token
$prefill_context = [];
if (!empty($input['token'])) {
    $prefill = DiagStorage::consumeToken((string)$input['token']);
    if (!$prefill) {
        http_response_code(400);
        echo json_encode(['error' => 'invalid_or_expired_token']);
        exit;
    }
    $browser_vantage = $input['browser_vantage'] ?? null;
    $input = $prefill + $input;
    if ($browser_vantage !== null) $input['browser_vantage'] = $browser_vantage;
    $prefill_context = [
        'ticket_id'  => $prefill['ticket_id']  ?? null,
        'discord_id' => $prefill['discord_id'] ?? null,
        'username'   => $prefill['username']   ?? null,
    ];
}

$service  = (string)($input['service']  ?? '');
$domains  = $input['domains']           ?? [];
$username = (string)($input['username'] ?? '');
$password = (string)($input['password'] ?? '');
$mode     = (string)($input['mode']     ?? 'variations');

if (!$service || !ServiceAllowlist::isKnownService($service)) {
    http_response_code(400);
    echo json_encode(['error' => 'service_required', 'message' => 'Select which service you have: Strong8K / SuperStrong, Dream4K / DayDream, or T-Rex / TyrannosaurusRex.']);
    exit;
}
if (!is_array($domains) || count($domains) === 0) {
    http_response_code(400);
    echo json_encode(['error' => 'no_domains_provided']);
    exit;
}
if (!$username || !$password) {
    http_response_code(400);
    echo json_encode(['error' => 'credentials_required', 'message' => 'Your username and password are required to verify the service works for your account.']);
    exit;
}

$browser_vantage = is_array($input['browser_vantage'] ?? null) ? $input['browser_vantage'] : [];

// ═══════════════════════════════════════════════════════════
// Resolve allowlist
// ═══════════════════════════════════════════════════════════
$resolved = ServiceAllowlist::resolve(array_map('strval', $domains), $service, $mode);

if (!empty($resolved['error'])) {
    http_response_code(422);
    echo json_encode(['error' => $resolved['error']]);
    exit;
}
if (empty($resolved['plan'])) {
    http_response_code(422);
    echo json_encode([
        'error'    => 'no_valid_domains',
        'message'  => build_rejection_message($service, $resolved['rejected']),
        'service'  => $service,
        'rejected' => $resolved['rejected'],
    ]);
    exit;
}

// ═══════════════════════════════════════════════════════════
// Run diagnostics — two passes, best-of merge
//
// The panel's connection tracking isn't instant — a domain tested
// second can fail if the previous domain's slot hasn't fully released.
// Running two full passes and taking the best score per domain
// eliminates these timing flukes.
// ═══════════════════════════════════════════════════════════
$pass1 = run_all_checks($resolved['plan'], $username, $password);

// Brief pause between passes to let everything settle
usleep(1000000); // 1 second

$pass2 = run_all_checks($resolved['plan'], $username, $password);

// Merge: for each domain, keep whichever pass scored higher
$results = merge_best_passes($pass1, $pass2);

foreach ($results as &$r) {
    $r['score']   = score_result($r);
    $r['verdict'] = verdict_from_score($r['score'], $r['checks']);
}
unset($r);
usort($results, fn($a, $b) => $b['score'] <=> $a['score']);

// Legacy replacements (single pass is fine — these are separate domains)
$legacy_results = [];
if (!empty($resolved['legacy_plan'])) {
    $legacy_results = run_all_checks($resolved['legacy_plan'], $username, $password);
    foreach ($legacy_results as &$r) {
        $r['score']   = score_result($r);
        $r['verdict'] = verdict_from_score($r['score'], $r['checks']);
    }
    unset($r);
}

// Triangulate
$triangulation = triangulate($results, $legacy_results);

// Build + persist report
$report = [
    'timestamp' => $now,
    'input'     => ['domains' => $domains, 'service' => $service, 'mode' => $resolved['mode']],
    'browser'   => ['vantage' => $browser_vantage],
    'portal'    => [
        'vantage'        => 'contabo-eu',
        'plan'           => $resolved['plan'],
        'legacy_plan'    => $resolved['legacy_plan'],
        'results'        => $results,
        'legacy_results' => $legacy_results,
        'rejected'       => $resolved['rejected'],
    ],
    'verdict' => $triangulation,
];

$context = array_merge($prefill_context, ['username' => $username]);
$slug = DiagStorage::saveReport($report, $context);
$report['share_slug'] = $slug;
$report['share_url']  = 'https://' . ($_SERVER['HTTP_HOST'] ?? 'diag-api.neweras8kportal.space') . '/admin/report.php?slug=' . $slug;

echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);


// ═══════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════
//  CHECK FUNCTIONS — ALL SEQUENTIAL, NO curl_multi
// ═══════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════

/**
 * Run all checks for a list of candidates, fully sequential.
 *
 * For each domain:
 *   1. DNS
 *   2. TCP
 *   3. Auth (single curl call)
 *   4. If auth passed and we don't have a stream ID yet, fetch one
 *   5. Stream test (if we have a stream ID)
 *   6. 500ms pause to let panel release the connection slot
 *
 * This ensures only ONE connection is ever open to the panel at a time,
 * which is critical for accounts with max_connections=1.
 */
// ═══════════════════════════════════════════════════════════
// Slot-awareness tuning constants
// ═══════════════════════════════════════════════════════════
// When auth reports active_connections >= max_connections, the panel's
// streaming slot is occupied (customer's own player, a shared user, or an
// unknown third party). The engine polls briefly before giving up on the
// stream test for that domain.
const SLOT_POLL_ATTEMPTS   = 2;        // re-check auth this many times
const SLOT_POLL_INTERVAL   = 1500000;  // 1.5s between polls (microseconds)
// If this many domains in a row hit a persistently-occupied slot, the run
// stops early — every remaining domain would hit the same wall. The result
// becomes an 'account_in_use' verdict instead of a wall of slot_busy rows.
const SLOT_EARLY_STOP_AFTER = 2;

/**
 * Is this auth result reporting an occupied connection slot?
 */
function auth_slot_occupied(array $auth): bool
{
    $mc = $auth['user_max_connections']    ?? null;
    $ac = $auth['user_active_connections'] ?? null;
    if ($mc === null || $ac === null) return false;
    return $mc > 0 && $ac >= $mc;
}

function run_all_checks(array $candidates, string $u, string $p): array
{
    $results   = [];
    $stream_id = null;          // fetched once, reused for all domains
    $streamed_apexes = [];      // apexes that already got a successful stream test
    $consecutive_slot_busy = 0; // for early-stop escalation
    $stopped_early = false;

    foreach ($candidates as $c) {
        $entry = [
            'domain'   => $c['host'],
            'port'     => $c['port'],
            'scheme'   => $c['scheme'],
            'source'   => $c['source']   ?? 'variation',
            'label'    => $c['label']    ?? null,
            'service'  => $c['service']  ?? null,
            'apex'     => $c['apex']     ?? null,
            'geo_only' => $c['geo_only'] ?? null,
            'checks'   => [],
        ];

        // Phase 1: DNS
        $entry['checks']['dns'] = check_dns($c['host']);

        // Phase 2: TCP
        $entry['checks']['tcp'] = check_tcp($c['host'], $c['port']);

        // If TCP failed, skip auth and stream
        if (!$entry['checks']['tcp']['ok']) {
            $entry['checks']['auth'] = [
                'ok' => false, 'ms' => null, 'http_code' => 0, 'error' => 'tcp_failed',
                'user_valid' => false, 'user_status' => null, 'user_expires' => null,
                'user_max_connections' => null, 'user_active_connections' => null,
            ];
            $entry['checks']['stream'] = [
                'ok' => false, 'ms' => null, 'http_code' => 0,
                'bytes_read' => 0, 'error' => 'tcp_failed',
            ];
            $results[] = $entry;
            continue;
        }

        // Phase 3: Auth
        $auth = check_auth($c, $u, $p);
        $entry['checks']['auth'] = $auth;

        // Phase 3b: Slot check. If the panel reports the connection slot is
        // occupied, poll briefly — it may be a connection from the previous
        // domain still releasing, or the customer's player closing.
        $slot_busy = false;
        if (($auth['user_valid'] ?? false) && auth_slot_occupied($auth)) {
            for ($i = 0; $i < SLOT_POLL_ATTEMPTS; $i++) {
                usleep(SLOT_POLL_INTERVAL);
                $recheck = check_auth($c, $u, $p);
                if (($recheck['user_valid'] ?? false) && !auth_slot_occupied($recheck)) {
                    // Slot freed — adopt the fresh auth result and proceed.
                    $auth = $recheck;
                    $entry['checks']['auth'] = $auth;
                    break;
                }
                $auth = $recheck;
                $entry['checks']['auth'] = $auth;
            }
            $slot_busy = auth_slot_occupied($auth);
        }

        // Phase 4 + 5: Stream. Only test once per apex — cf./pro./bare all
        // hit the same panel, so one successful stream proves the panel
        // streams. Skip if the slot is busy.
        $apex = $c['apex'] ?? $c['host'];

        if ($slot_busy) {
            // Slot occupied even after polling — do not stream-test.
            // This is NOT a domain failure; it's an active-connection state.
            $entry['checks']['stream'] = [
                'ok' => false, 'ms' => null, 'http_code' => 0,
                'bytes_read' => 0, 'error' => 'slot_busy',
                'slot_busy' => true,
            ];
            $consecutive_slot_busy++;
        } elseif (!($auth['user_valid'] ?? false)) {
            $entry['checks']['stream'] = [
                'ok' => false, 'ms' => null, 'http_code' => 0,
                'bytes_read' => 0, 'error' => 'auth_failed',
            ];
            $consecutive_slot_busy = 0;
        } elseif (isset($streamed_apexes[$apex])) {
            // Already proved this apex streams — inherit that result.
            $entry['checks']['stream'] = $streamed_apexes[$apex]
                + ['inherited_from_apex' => true];
            $consecutive_slot_busy = 0;
        } else {
            // Fetch a stream ID once, globally.
            if ($stream_id === null) {
                usleep(300000); // 300ms
                $stream_id = fetch_stream_id($c, $u, $p);
            }
            if ($stream_id) {
                usleep(300000); // 300ms
                $stream = check_stream($c, $u, $p, $stream_id);
                $entry['checks']['stream'] = $stream;
                if ($stream['ok'] ?? false) {
                    // Cache the passing result for sibling prefixes of this apex.
                    $streamed_apexes[$apex] = $stream;
                }
            } else {
                $entry['checks']['stream'] = [
                    'ok' => false, 'ms' => null, 'http_code' => 0,
                    'bytes_read' => 0, 'error' => 'no_stream_id',
                ];
            }
            $consecutive_slot_busy = 0;
        }

        $results[] = $entry;

        // Early-stop escalation: a persistently-occupied slot means every
        // remaining domain will hit the same wall. Stop and let the verdict
        // logic surface this as 'account_in_use'.
        if ($consecutive_slot_busy >= SLOT_EARLY_STOP_AFTER) {
            $stopped_early = true;
            break;
        }

        // Pause between domains
        usleep(500000); // 500ms
    }

    // Annotate the result set so the caller / verdict logic can see an
    // early stop occurred (carried on the first result for simplicity).
    if ($stopped_early && !empty($results)) {
        $results[0]['_run_stopped_early'] = true;
    }

    return $results;
}

/**
 * Merge two passes of results, keeping the better-scoring result per domain.
 * If a domain passed in either run, it's considered working — this eliminates
 * false negatives from connection slot timing.
 */
function merge_best_passes(array $pass1, array $pass2): array
{
    // Index pass2 by domain key
    $p2_by_key = [];
    foreach ($pass2 as $r) {
        $key = $r['domain'] . ':' . $r['port'] . ':' . $r['scheme'];
        $p2_by_key[$key] = $r;
    }

    $merged = [];
    foreach ($pass1 as $r1) {
        $key = $r1['domain'] . ':' . $r1['port'] . ':' . $r1['scheme'];
        $r2 = $p2_by_key[$key] ?? null;

        if ($r2 === null) {
            $merged[] = $r1;
            continue;
        }

        $s1 = score_result($r1);
        $s2 = score_result($r2);

        // Take whichever pass scored higher
        // For individual checks, take the passing result if either passed
        if ($s2 > $s1) {
            $best = $r2;
            $other = $r1;
        } else {
            $best = $r1;
            $other = $r2;
        }

        // Promote individual check results: if a check failed in the
        // winning pass but passed in the other, use the passing result
        foreach (['dns', 'tcp', 'auth', 'stream'] as $check) {
            if (!($best['checks'][$check]['ok'] ?? false) && ($other['checks'][$check]['ok'] ?? false)) {
                $best['checks'][$check] = $other['checks'][$check];
            }
        }

        // Stream-specific: if the winning pass has a hard stream failure but
        // the other pass only hit a busy slot, prefer slot_busy — it's the
        // more accurate "couldn't test" state, not a domain fault.
        $best_stream  = $best['checks']['stream']  ?? [];
        $other_stream = $other['checks']['stream'] ?? [];
        if (!($best_stream['ok'] ?? false)
            && empty($best_stream['slot_busy'])
            && !empty($other_stream['slot_busy'])) {
            $best['checks']['stream'] = $other_stream;
        }

        // Drop any per-pass early-stop marker; recomputed below from both.
        unset($best['_run_stopped_early']);

        $merged[] = $best;
        unset($p2_by_key[$key]);
    }

    // Any domains only in pass2 (shouldn't happen, but safe)
    foreach ($p2_by_key as $r) {
        unset($r['_run_stopped_early']);
        $merged[] = $r;
    }

    // Escalate only if BOTH passes stopped early — a single completed pass
    // means the slot was not persistently occupied.
    $p1_stopped = false;
    $p2_stopped = false;
    foreach ($pass1 as $r) { if (!empty($r['_run_stopped_early'])) { $p1_stopped = true; break; } }
    foreach ($pass2 as $r) { if (!empty($r['_run_stopped_early'])) { $p2_stopped = true; break; } }
    if ($p1_stopped && $p2_stopped && !empty($merged)) {
        $merged[0]['_run_stopped_early'] = true;
    }

    return $merged;
}

// ─── DNS ──────────────────────────────────────────────────

function check_dns(string $host): array
{
    $start = microtime(true);
    $ips   = @gethostbynamel($host);
    $ms    = (int)((microtime(true) - $start) * 1000);

    $out = [
        'ok'  => $ips !== false && count($ips) > 0,
        'ms'  => $ms,
        'ips' => $ips ?: [],
    ];

    // Cloudflare DoH cross-check
    $doh = dns_over_https($host);
    $out['cloudflare_ips'] = $doh['ips'];
    $out['dns_match'] = $doh['ok'] && $ips ? count(array_intersect($ips, $doh['ips'])) > 0 : null;

    return $out;
}

function dns_over_https(string $host): array
{
    $ch = curl_init("https://cloudflare-dns.com/dns-query?name=" . urlencode($host) . "&type=A");
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 3,
        CURLOPT_HTTPHEADER => ['Accept: application/dns-json'],
    ]);
    $r = curl_exec($ch);
    $ok = curl_getinfo($ch, CURLINFO_RESPONSE_CODE) === 200;
    curl_close($ch);
    if (!$ok || !$r) return ['ok' => false, 'ips' => []];
    $data = json_decode((string)$r, true);
    $ips = [];
    foreach (($data['Answer'] ?? []) as $ans) {
        if (($ans['type'] ?? 0) === 1 && !empty($ans['data'])) $ips[] = $ans['data'];
    }
    return ['ok' => count($ips) > 0, 'ips' => $ips];
}

// ─── TCP (single, blocking) ─────────────────────────────

function check_tcp(string $host, int $port): array
{
    $start = microtime(true);
    $errno = 0;
    $errstr = '';
    $sock = @stream_socket_client(
        "tcp://{$host}:{$port}",
        $errno, $errstr, 5,
        STREAM_CLIENT_CONNECT
    );
    $ms = (int)((microtime(true) - $start) * 1000);

    if ($sock) {
        @fclose($sock);
        return ['ok' => true, 'ms' => $ms, 'error' => null];
    }
    return ['ok' => false, 'ms' => $ms, 'error' => $errstr ?: 'timeout'];
}

// ─── Auth (single curl call) ─────────────────────────────

function check_auth(array $c, string $u, string $p): array
{
    $base = "{$c['scheme']}://{$c['host']}:{$c['port']}";
    $url  = "$base/player_api.php?username=" . rawurlencode($u) . "&password=" . rawurlencode($p);

    $start = microtime(true);
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_MAXREDIRS      => 3,
        CURLOPT_TIMEOUT        => 15,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_SSL_VERIFYPEER => false,
        CURLOPT_SSL_VERIFYHOST => 0,
        CURLOPT_USERAGENT      => 'NewEraSCTV-Diagnostic/4.0',
    ]);
    $body = curl_exec($ch);
    $info = curl_getinfo($ch);
    $err  = curl_error($ch);
    $ms   = (int)((microtime(true) - $start) * 1000);
    $code = $info['http_code'] ?? 0;
    curl_close($ch);

    $result = [
        'ok'            => false,
        'ms'            => $ms,
        'http_code'     => $code,
        'tls_handshake' => isset($info['appconnect_time']) && $info['appconnect_time'] > 0
                           ? (int)($info['appconnect_time'] * 1000) : null,
        'error'         => $err ?: null,
        'user_valid'    => false,
        'user_status'   => null,
        'user_expires'  => null,
        'user_max_connections'    => null,
        'user_active_connections' => null,
    ];

    if ($code === 200 && $body) {
        $json = json_decode((string)$body, true);
        if (isset($json[0]) && is_array($json[0])) $json = $json[0]; // Dream4K array-wrap
        $ui = $json['user_info'] ?? null;
        if (is_array($ui)) {
            $result['ok']           = true;
            $result['user_valid']   = (int)($ui['auth'] ?? 0) === 1;
            $result['user_status']  = $ui['status']     ?? null;
            $result['user_expires'] = $ui['exp_date']   ?? null;
            if (isset($ui['max_connections']))  $result['user_max_connections']    = (int)$ui['max_connections'];
            if (isset($ui['active_cons']))      $result['user_active_connections'] = (int)$ui['active_cons'];
        }
    }

    return $result;
}

// ─── Stream ID fetch (from get_live_streams, first 4KB) ──

function fetch_stream_id(array $c, string $u, string $p): ?int
{
    $base = "{$c['scheme']}://{$c['host']}:{$c['port']}";
    $url  = "$base/player_api.php?username=" . rawurlencode($u) . "&password=" . rawurlencode($p) . "&action=get_live_streams";

    $captured = '';
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_MAXREDIRS      => 3,
        CURLOPT_TIMEOUT        => 15,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_SSL_VERIFYPEER => false,
        CURLOPT_SSL_VERIFYHOST => 0,
        CURLOPT_USERAGENT      => 'NewEraSCTV-Diagnostic/4.0',
        CURLOPT_WRITEFUNCTION  => function($ch, $data) use (&$captured) {
            $captured .= $data;
            if (strlen($captured) > 4096) return -1;
            return strlen($data);
        },
    ]);
    curl_exec($ch);
    curl_close($ch);

    if ($captured && preg_match('/"stream_id"\s*:\s*(\d+)/', $captured, $m)) {
        return (int)$m[1];
    }
    return null;
}

// ─── Stream test (single curl, follows 302 to edge/CDN) ──

function check_stream(array $c, string $u, string $p, int $stream_id): array
{
    $base = "{$c['scheme']}://{$c['host']}:{$c['port']}";
    $url  = "$base/live/$u/$p/$stream_id.ts";

    $captured = '';
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_MAXREDIRS      => 5,
        CURLOPT_TIMEOUT        => 15,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_SSL_VERIFYPEER => false,
        CURLOPT_SSL_VERIFYHOST => 0,
        CURLOPT_USERAGENT      => 'NewEraSCTV-Diagnostic/4.0',
        CURLOPT_WRITEFUNCTION  => function($ch, $data) use (&$captured) {
            $captured .= $data;
            if (strlen($captured) >= 32768) return -1; // 32KB is enough
            return strlen($data);
        },
    ]);

    $start = microtime(true);
    curl_exec($ch);
    $ms   = (int)((microtime(true) - $start) * 1000);
    $info = curl_getinfo($ch);
    $err  = curl_error($ch);
    $code = $info['http_code'] ?? 0;
    curl_close($ch);

    $bytes = strlen($captured);

    // The write callback aborting after 32KB causes curl to report an error
    // like "Failure writing output" — that's our intentional cutoff and
    // counts as success if we got data.
    $aborted_ok = stripos($err, 'aborted') !== false
               || stripos($err, 'callback') !== false
               || stripos($err, 'writing') !== false;

    $ok = ($code === 200 && $bytes > 0) || ($aborted_ok && $bytes > 0);

    return [
        'ok'         => $ok,
        'ms'         => $ms,
        'http_code'  => $code,
        'bytes_read' => $bytes,
        'error'      => ($ok || $aborted_ok) ? null : ($err ?: null),
    ];
}


// ═══════════════════════════════════════════════════════════
// Scoring
// ═══════════════════════════════════════════════════════════

/**
 * Score 0-100:
 *   DNS ok:         20
 *   TCP ok:         20
 *   Auth valid:     30
 *   Stream ok:      30
 */
function score_result(array $r): int
{
    $c = $r['checks'];
    $s = 0;
    if ($c['dns']['ok']  ?? false) $s += 20;
    if ($c['tcp']['ok']  ?? false) $s += 20;
    if (($c['auth']['ok'] ?? false) && ($c['auth']['user_valid'] ?? false)) $s += 30;
    if ($c['stream']['ok'] ?? false) $s += 30;
    return min(100, $s);
}

/**
 * A domain whose only "failure" is a busy connection slot — DNS, TCP and
 * auth all passed, the stream test was skipped because the slot was
 * occupied. This is reachable and verified-as-valid; not a domain fault.
 */
function is_slot_busy_result(array $r): bool
{
    $c = $r['checks'] ?? [];
    return ($c['dns']['ok'] ?? false)
        && ($c['tcp']['ok'] ?? false)
        && ($c['auth']['user_valid'] ?? false)
        && !empty($c['stream']['slot_busy']);
}

function verdict_from_score(int $score, array $checks): string
{
    $dns_ok    = $checks['dns']['ok'] ?? false;
    $tcp_ok    = $checks['tcp']['ok'] ?? false;
    $auth_code = (int)($checks['auth']['http_code'] ?? 0);

    // Slot occupied: reachable + valid login, stream test skipped. Distinct
    // from a degrade — the domain is fine, a connection was simply in use.
    if ($dns_ok && $tcp_ok
        && ($checks['auth']['user_valid'] ?? false)
        && !empty($checks['stream']['slot_busy'])) {
        return 'slot_busy';
    }

    // Reachable (DNS + TCP ok) but the panel returned a 5xx: the domain
    // itself is fine — flag it distinctly rather than as a generic degrade.
    if ($dns_ok && $tcp_ok && $auth_code >= 500 && $auth_code <= 599) {
        return 'panel_error';
    }

    if ($score >= 90) return 'excellent';
    if ($score >= 70) return 'good';
    if ($score >= 40) return 'degraded';
    if ($dns_ok === false)                                  return 'dns_failure';
    if ($tcp_ok === false)                                  return 'blocked';
    if (($checks['auth']['user_valid'] ?? false) === false) return 'auth_failure';
    return 'unreachable';
}


// ═══════════════════════════════════════════════════════════
// Triangulation — two vantage points (portal + worker)
//
// Verdict precedence (portal perspective):
//   - some_working   : a main-plan domain scored >= 70
//   - domain_retired : main plan is a retired/legacy domain that is dead,
//                      BUT a current standard replacement domain works.
//                      The customer's service is fine — they just need
//                      to switch domains. NOT a 'domain_down' outage.
//   - panel_error    : domains are reachable (DNS + TCP ok) but the panel
//                      returned a 5xx. The DOMAIN works; the provider's
//                      panel had a transient error. Not an outage.
//   - domain_down    : nothing is reachable from any path.
// ═══════════════════════════════════════════════════════════

function triangulate(array $portal_results, array $legacy_results = []): array
{
    $per_domain = [];

    foreach ($portal_results as $pr) {
        $dom = $pr['domain'];
        // A slot_busy result scores 70 but is NOT proven working — the
        // stream test was skipped. It's reachable + valid, not verified.
        $is_slot_busy = is_slot_busy_result($pr);
        $portal_ok = (($pr['score'] ?? 0) >= 70) && !$is_slot_busy;

        $per_domain[] = [
            'domain'   => $dom,
            'portal'   => ['ok' => $portal_ok, 'score' => $pr['score'] ?? 0],
            'worker'   => ['ok' => null], // filled by frontend after worker returns
            'category' => $portal_ok
                            ? 'working'
                            : ($is_slot_busy ? 'slot_busy' : categorize_failure($pr)),
        ];
    }

    // Best main-plan domain: highest portal score wins
    $best = null;
    $best_score = -1;
    foreach ($per_domain as $d) {
        $score = $d['portal']['score'] ?? 0;
        if ($score > $best_score) {
            $best = $d['domain'];
            $best_score = $score;
        }
    }

    // Is any main-plan domain actually working?
    $any_working = false;
    foreach ($per_domain as $d) {
        if ($d['portal']['ok']) { $any_working = true; break; }
    }

    // Best working replacement among legacy_results (the current standard domains)
    $best_replacement = null;
    $best_repl_score  = -1;
    foreach ($legacy_results as $lr) {
        $s = $lr['score'] ?? 0;
        if ($s >= 70 && $s > $best_repl_score) {
            $best_repl_score  = $s;
            $best_replacement = $lr['domain'];
        }
    }
    $replacement_works = $best_replacement !== null;

    // Did every main-plan domain reach DNS + TCP but fail at the panel (5xx)?
    $any_reachable    = false;   // DNS + TCP ok somewhere
    $any_panel_error  = false;   // reachable but auth returned a 5xx
    foreach ($portal_results as $pr) {
        $c = $pr['checks'] ?? [];
        $dns_ok = $c['dns']['ok'] ?? false;
        $tcp_ok = $c['tcp']['ok'] ?? false;
        if ($dns_ok && $tcp_ok) {
            $any_reachable = true;
            $auth_code = (int)($c['auth']['http_code'] ?? 0);
            if ($auth_code >= 500 && $auth_code <= 599) {
                $any_panel_error = true;
            }
        }
    }

    // Slot-occupied detection. The run may have stopped early because a
    // connection slot stayed persistently occupied; or every reachable
    // domain came back slot_busy. Either way the customer's account has an
    // active connection — their own player, a shared user, or someone else.
    $stopped_early    = false;
    $any_slot_busy    = false;
    $all_slot_or_down = true;   // every reachable domain is slot_busy (none truly working)
    foreach ($portal_results as $pr) {
        if (!empty($pr['_run_stopped_early'])) $stopped_early = true;
        if (is_slot_busy_result($pr)) $any_slot_busy = true;
        $c = $pr['checks'] ?? [];
        if (($c['dns']['ok'] ?? false) && ($c['tcp']['ok'] ?? false)
            && !is_slot_busy_result($pr) && !($pr['score'] >= 70)) {
            // a reachable domain that is neither working nor slot_busy
            $all_slot_or_down = false;
        }
    }
    $account_in_use = ($stopped_early || $any_slot_busy)
                      && !$any_working
                      && $any_reachable;

    // ── Verdict precedence ──────────────────────────────────
    if ($any_working) {
        $overall    = 'some_working';
        $verdict_best = $best;
    } elseif ($replacement_works) {
        // Main plan dead, but a current standard domain works → retired, switch.
        $overall    = 'domain_retired';
        $verdict_best = $best_replacement;
    } elseif ($account_in_use) {
        // Domains reachable + login valid, but a connection is in use so we
        // could not finish the stream test. NOT a domain or outage problem.
        $overall    = 'account_in_use';
        $verdict_best = $best;
    } elseif ($any_panel_error) {
        // Domain reachable; provider panel threw a 5xx. Not an outage.
        $overall    = 'panel_error';
        $verdict_best = $best;
    } else {
        $overall    = 'domain_down';
        $verdict_best = $best;
    }

    return [
        'overall'           => $overall,
        'best_domain'       => $verdict_best,
        'perDomain'         => $per_domain,
        'replacement'       => $best_replacement,   // null unless domain_retired
        'reachable'         => $any_reachable,
        'stopped_early'     => $stopped_early,
        'summary'           => build_summary($overall, $verdict_best),
    ];
}

/**
 * Classify why a single non-working domain failed, from its check tree.
 * Used for per-domain category labels in the report.
 */
function categorize_failure(array $pr): string
{
    $c = $pr['checks'] ?? [];
    if (!($c['dns']['ok'] ?? false)) return 'dns_failure';
    if (!($c['tcp']['ok'] ?? false)) return 'blocked';
    if (!empty($c['stream']['slot_busy'])
        && ($c['auth']['user_valid'] ?? false)) return 'slot_busy';
    $auth_code = (int)($c['auth']['http_code'] ?? 0);
    if ($auth_code >= 500 && $auth_code <= 599) return 'panel_error';
    if (($c['auth']['user_valid'] ?? false) === false) return 'auth_failure';
    return 'unreachable';
}

function build_summary(string $overall, ?string $best): string
{
    switch ($overall) {
        case 'some_working':
        case 'all_working':
            return "At least one of your domains works from our datacenter. Use $best in your player. If you're still buffering, the issue is your player app, device, or network — not the domain.";
        case 'domain_retired':
            return "Your old domain is no longer active — this is expected, standard domains are rotated periodically. Your service itself is working fine. Switch your player to $best and you're set. No ticket needed.";
        case 'panel_error':
            return "Your domain is reachable — DNS and the connection both succeeded — but the provider's panel returned a temporary error. This is not a domain problem and usually clears on its own. Try again in a few minutes; if it persists, mention this report to support.";
        case 'account_in_use':
            return "Your domains are reachable and your login is valid — but an active connection is using your account right now, so the stream test could not be completed. This is NOT a domain problem. One of three things is happening: (1) your player app is still open on this or another device — close it completely and run the test again; (2) someone you share the account with is currently watching; or (3) if neither of those is true, your login may be in use by someone else — change your password and contact support.";
        case 'user_blocked':
            return "We can reach your domains from our datacenter. Your connection cannot. This is an ISP or DNS-level issue on your end.";
        case 'domain_down':
            return "None of your domains responded from any vantage point. Please check #announcements for known outages before opening a ticket.";
        case 'mixed':
            return $best
                ? "Some of your variations work, some don't. Use $best in your player."
                : "Mixed results across your domains. Please share this report with support.";
        default:
            return 'Results are inconclusive.';
    }
}


// ═══════════════════════════════════════════════════════════
// Rejection messages
// ═══════════════════════════════════════════════════════════

function build_rejection_message(string $service, array $rejected): string
{
    if (empty($rejected)) return 'None of the domains you provided match your declared service.';
    $svc_info = ServiceAllowlist::serviceInfo($service);
    $svc_name = $svc_info['display_name'] ?? $service;
    $lines = [];
    foreach ($rejected as $r) {
        $input  = $r['input'] ?? '?';
        $reason = $r['reason'] ?? '';
        switch ($reason) {
            case 'prefix_mismatch':
                $owner = ServiceAllowlist::serviceInfo($r['prefix_belongs_to'] ?? '');
                $owner_name = $owner['display_name'] ?? ($r['prefix_belongs_to'] ?? 'another service');
                $lines[] = "$input: the \"{$r['prefix']}\" prefix belongs to $owner_name, not $svc_name. Did you pick the wrong service?";
                break;
            case 'excluded_prefix':
                $lines[] = "$input: the \"{$r['prefix']}\" prefix requires staff assistance — please open a ticket.";
                break;
            case 'malformed':
                $lines[] = "$input: not a valid domain format.";
                break;
            case 'apex_not_allowed':
                $lines[] = "$input: this domain isn't recognised as one of our service domains.";
                break;
            default:
                $lines[] = "$input: $reason";
        }
    }
    return implode(' ', $lines);
}
