<?php
declare(strict_types=1);
date_default_timezone_set('Asia/Tokyo');

/**
 * sender_child.php — 子サーバー送信エージェント
 *
 * 単一ファイルで動作。外部サーバーのドキュメントルートに配置。
 * DB不要のファイルキュー方式。
 *
 * CLIモード:
 *   php sender_child.php
 *     → 初回起動: マスター自動検出 + 自己登録
 *   php sender_child.php --master=https://master.example.com
 *     → 初期セットアップ + マスターへ自己登録（手動指定）
 *   php sender_child.php --process
 *     → queue/new/ のファイルを処理して送信
 *
 * HTTPモード（Webサーバー経由）:
 *   ?action=check  (GET)  — 死活確認 + キュー状態
 *   ?action=send   (POST) — メールキュー受信 + 送信開始
 */

// ─── 定数・設定 ──────────────────────────────────────────────

// マスター自動検出用の登録コード（環境に合わせて変更）
define('REGISTER_CODE', 'dev');
define('MASTER_LOOKUP_URL', 'http://master.nextstep-relay.com/get_master.php');

define('CHILD_DIR', __DIR__);
define('CONF_FILE', CHILD_DIR . '/child.conf');
define('QUEUE_DIR', CHILD_DIR . '/queue');

// ─── ユーティリティ ──────────────────────────────────────────

function childLog(string $msg): void
{
    $line = '[' . date('Y-m-d H:i:s') . '] ' . $msg . "\n";
    @file_put_contents(CHILD_DIR . '/sender_child.log', $line, FILE_APPEND | LOCK_EX);
}

function jsonResponse(array $data, int $code = 200): void
{
    http_response_code($code);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}

function loadConf(): array
{
    if (!is_file(CONF_FILE)) return [];
    $json = @file_get_contents(CONF_FILE);
    if ($json === false) return [];
    $data = json_decode($json, true);
    return is_array($data) ? $data : [];
}

function saveConf(array $conf): void
{
    file_put_contents(CONF_FILE, json_encode($conf, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
}

function ensureQueueDirs(): void
{
    foreach (['new', 'cur', 'defer', 'result_pending'] as $sub) {
        $dir = QUEUE_DIR . '/' . $sub;
        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }
    }
}

function authenticateRequest(array $conf): bool
{
    $apiKey = $conf['api_key'] ?? '';
    if ($apiKey === '') return false;

    // X-Nextstep-Api-Key ヘッダーで認証
    $headerKey = $_SERVER['HTTP_X_NEXTSTEP_API_KEY'] ?? '';
    if ($headerKey === '') return false;

    return hash_equals($apiKey, $headerKey);
}

function countFiles(string $dir): int
{
    if (!is_dir($dir)) return 0;
    $count = 0;
    $dh = opendir($dir);
    if ($dh === false) return 0;
    while (($f = readdir($dh)) !== false) {
        if ($f === '.' || $f === '..') continue;
        if (str_ends_with($f, '.json')) $count++;
    }
    closedir($dh);
    return $count;
}

function listJsonFiles(string $dir): array
{
    if (!is_dir($dir)) return [];
    $files = [];
    $dh = opendir($dir);
    if ($dh === false) return [];
    while (($f = readdir($dh)) !== false) {
        if ($f === '.' || $f === '..') continue;
        if (str_ends_with($f, '.json')) {
            $files[] = $dir . '/' . $f;
        }
    }
    closedir($dh);
    sort($files);
    return $files;
}

// ─── MX解決 ──────────────────────────────────────────────────

function resolveMxHost(string $domain): string
{
    $mxRecords = @dns_get_record($domain, DNS_MX);
    if (is_array($mxRecords) && count($mxRecords) > 0) {
        usort($mxRecords, fn($a, $b) => ($a['pri'] ?? 9999) <=> ($b['pri'] ?? 9999));
        $host = $mxRecords[0]['target'] ?? '';
        if ($host !== '') return $host;
    }
    $aRecords = @dns_get_record($domain, DNS_A);
    if (is_array($aRecords) && count($aRecords) > 0) {
        return $domain;
    }
    throw new RuntimeException("MX解決失敗: {$domain}");
}

// ─── 送信処理 ────────────────────────────────────────────────

function processQueue(): void
{
    // PHPMailerのオートロード
    $autoloadPaths = [
        CHILD_DIR . '/vendor/autoload.php',
        dirname(CHILD_DIR) . '/vendor/autoload.php',
    ];
    $autoloaded = false;
    foreach ($autoloadPaths as $path) {
        if (is_file($path)) {
            require_once $path;
            $autoloaded = true;
            break;
        }
    }
    if (!$autoloaded) {
        childLog('ERROR: vendor/autoload.php が見つかりません。composer require phpmailer/phpmailer を実行してください。');
        return;
    }

    ensureQueueDirs();
    $newDir = QUEUE_DIR . '/new';
    $curDir = QUEUE_DIR . '/cur';
    $deferDir = QUEUE_DIR . '/defer';

    $files = listJsonFiles($newDir);
    if (empty($files)) return;

    // バッチごとの結果を集約
    $batchResults = []; // batch_id => ['master_result_url'=>..., 'results'=>[...]]

    foreach ($files as $filePath) {
        $basename = basename($filePath);
        $curPath = $curDir . '/' . $basename;

        // new/ → cur/ に移動（送信中）
        if (!@rename($filePath, $curPath)) {
            childLog("ERROR: {$basename} の移動に失敗");
            continue;
        }

        // メタデータ読み込み
        $json = @file_get_contents($curPath);
        if ($json === false) {
            childLog("ERROR: {$basename} の読み込みに失敗");
            continue;
        }
        $email = json_decode($json, true);
        if (!is_array($email)) {
            childLog("ERROR: {$basename} のJSON解析に失敗");
            @unlink($curPath);
            continue;
        }

        $outboundId = (int)($email['outbound_id'] ?? 0);
        $batchId = (string)($email['batch_id'] ?? '');
        $masterResultUrl = (string)($email['master_result_url'] ?? '');

        $result = processOneEmail($email);
        $result['outbound_id'] = $outboundId;

        // 結果をバッチに集約
        if ($batchId !== '' && $masterResultUrl !== '') {
            if (!isset($batchResults[$batchId])) {
                $batchResults[$batchId] = [
                    'master_result_url' => $masterResultUrl,
                    'child_id' => $email['child_id'] ?? null,
                    'results' => [],
                ];
            }
            $batchResults[$batchId]['results'][] = $result;
        }

        // 送信結果に応じてファイル処理
        if ($result['status'] === 'sent' || $result['status'] === 'failed') {
            @unlink($curPath);
        } elseif ($result['status'] === 'deferred') {
            // cur/ → defer/ に移動
            @rename($curPath, $deferDir . '/' . $basename);
        }
    }

    // バッチ結果をマスターにコールバック
    $conf = loadConf();
    foreach ($batchResults as $batchId => $batch) {
        $payload = [
            'batch_id' => $batchId,
            'child_id' => $batch['child_id'],
            'results'  => $batch['results'],
        ];

        $ok = callbackToMaster($batch['master_result_url'], $conf['api_key'] ?? '', $payload);
        if (!$ok) {
            // コールバック失敗 → result_pending/ に保存
            $pendingPath = QUEUE_DIR . '/result_pending/' . $batchId . '.json';
            file_put_contents($pendingPath, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX);
            childLog("WARN: コールバック失敗、result_pending に保存: {$batchId}");
        } else {
            childLog("OK: コールバック送信完了: {$batchId} ({$batch['master_result_url']})");
        }
    }
}

function processOneEmail(array $email): array
{
    $outboundId = (int)($email['outbound_id'] ?? 0);
    $envFrom = (string)($email['envelope_from'] ?? '');
    $rcpt = (string)($email['envelope_rcpt'] ?? '');

    if ($envFrom === '' || $rcpt === '') {
        return ['status' => 'failed', 'smtp_code' => 0, 'detail' => 'envelope_from/rcpt が空', 'smtp_log' => null];
    }

    // 宛先ドメイン
    $atPos = strrpos($rcpt, '@');
    if ($atPos === false) {
        return ['status' => 'failed', 'smtp_code' => 0, 'detail' => 'rcpt にドメインなし', 'smtp_log' => null];
    }
    $rcptDomain = substr($rcpt, $atPos + 1);

    try {
        $mxHost = resolveMxHost($rcptDomain);
    } catch (RuntimeException $e) {
        return ['status' => 'failed', 'smtp_code' => 0, 'detail' => $e->getMessage(), 'smtp_log' => null];
    }

    // PHPMailer設定
    $mail = new PHPMailer\PHPMailer\PHPMailer(true);
    $smtpDebug = '';
    $mail->Debugoutput = function (string $str, int $level) use (&$smtpDebug) {
        if (strlen($smtpDebug) < 256 * 1024) {
            $smtpDebug .= $str . "\n";
        }
    };

    try {
        $mail->isSMTP();
        $mail->Host = $mxHost;
        $mail->Port = 25;
        $mail->SMTPAuth = false;
        $mail->SMTPSecure = '';
        $mail->SMTPAutoTLS = true;
        $mail->Timeout = 30;
        $mail->SMTPOptions = [
            'ssl' => [
                'verify_peer' => false,
                'verify_peer_name' => false,
                'allow_self_signed' => true,
            ],
        ];
        $mail->XMailer = ' ';
        $mail->SMTPDebug = 2;

        // HELOホスト名（POST時にキューJSONへ埋め込まれた値を使用。child.confは参照しない）
        $heloHostname = (string)($email['helo_hostname'] ?? '');
        if ($heloHostname !== '') {
            $mail->Helo = $heloHostname;
        }

        // charset/encoding
        $sendCharset = (string)($email['send_charset'] ?? 'UTF-8');
        $sendEncoding = (string)($email['send_encoding'] ?? 'base64');
        if ($sendCharset === '') $sendCharset = 'UTF-8';
        if (!in_array($sendEncoding, ['7bit','8bit','quoted-printable','base64'], true)) $sendEncoding = 'base64';

        $mail->CharSet = $sendCharset;
        $mail->Encoding = $sendEncoding;

        $fromDisplayName = isset($email['from_display_name']) && $email['from_display_name'] !== '' ? (string)$email['from_display_name'] : '';
        $mail->setFrom($envFrom, $fromDisplayName);
        $mail->Sender = $envFrom;
        $mail->addAddress($rcpt);

        // Message-ID
        $messageId = (string)($email['message_id'] ?? '');
        if ($messageId !== '') {
            $mail->MessageID = $messageId;
        }

        // Subject / Body
        $subject = (string)($email['subject'] ?? '');
        $bodyText = (string)($email['body_text'] ?? '');
        $bodyHtml = (string)($email['body_html'] ?? '');

        // charset変換
        if (strtoupper($sendCharset) !== 'UTF-8') {
            $subject = @iconv('UTF-8', $sendCharset . '//IGNORE', $subject) ?: $subject;
            $bodyText = @iconv('UTF-8', $sendCharset . '//IGNORE', $bodyText) ?: $bodyText;
            $bodyHtml = @iconv('UTF-8', $sendCharset . '//IGNORE', $bodyHtml) ?: $bodyHtml;
        }

        $mail->Subject = $subject;
        if ($bodyHtml !== '') {
            $mail->isHTML(true);
            $mail->Body = $bodyHtml;
            $mail->AltBody = ($bodyText !== '') ? $bodyText : strip_tags($bodyHtml);
        } else {
            $mail->isHTML(false);
            $mail->Body = ($bodyText !== '') ? $bodyText : '';
        }

        // DKIM署名
        $dkimDomain = (string)($email['dkim_domain'] ?? '');
        $dkimSelector = (string)($email['dkim_selector'] ?? '');
        $dkimPrivateKey = (string)($email['dkim_private_key'] ?? '');
        if ($dkimDomain !== '' && $dkimSelector !== '' && $dkimPrivateKey !== '') {
            $mail->DKIM_domain = $dkimDomain;
            $mail->DKIM_selector = $dkimSelector;
            $mail->DKIM_private_string = $dkimPrivateKey;
            $mail->DKIM_identity = $envFrom;
        }

        $mail->send();

        childLog("SENT: outbound_id={$outboundId} to={$rcpt} via={$mxHost}");
        return ['status' => 'sent', 'smtp_code' => 250, 'detail' => 'OK', 'smtp_log' => null];

    } catch (Throwable $e) {
        $err = $e->getMessage();

        // SMTPコードをデバッグログから抽出
        $smtpCode = 0;
        if ($smtpDebug !== '' && preg_match_all('/SERVER -> CLIENT:\s*(\d{3})[ -]/m', $smtpDebug, $allMatches)) {
            foreach (array_reverse($allMatches[1]) as $code) {
                $c = (int)$code;
                if ($c >= 400 && $c <= 599) {
                    $smtpCode = $c;
                    break;
                }
            }
        }
        if ($smtpCode === 0 && preg_match('/\b([45]\d{2})\s+[45]\.\d+\.\d+\b/', $err, $codeMatch)) {
            $smtpCode = (int)$codeMatch[1];
        }

        // ログ切り詰め
        $logFull = $smtpDebug . "\n--- ErrorInfo ---\n" . ($mail->ErrorInfo ?? '') . "\n--- Exception ---\n" . $err;
        if (strlen($logFull) > 64 * 1024) {
            $logFull = substr($logFull, 0, 64 * 1024) . "\n...[truncated]";
        }

        // 5xx永続エラー → failed、4xx一時エラー → deferred
        $isPermanent = ($smtpCode >= 500 && $smtpCode <= 599);
        // キーワードによる永続エラー判定
        $permanentKeywords = ['User unknown', 'does not exist', 'Mailbox not found', 'No such user', 'invalid address', 'address rejected'];
        foreach ($permanentKeywords as $kw) {
            if (stripos($err, $kw) !== false) {
                $isPermanent = true;
                break;
            }
        }

        if ($isPermanent) {
            childLog("FAILED: outbound_id={$outboundId} smtp_code={$smtpCode} err={$err}");
            return ['status' => 'failed', 'smtp_code' => $smtpCode, 'detail' => mb_substr($err, 0, 1024), 'smtp_log' => $logFull];
        } else {
            childLog("DEFERRED: outbound_id={$outboundId} smtp_code={$smtpCode} err={$err}");
            return ['status' => 'deferred', 'smtp_code' => $smtpCode, 'detail' => mb_substr($err, 0, 1024), 'smtp_log' => $logFull];
        }
    }
}

function callbackToMaster(string $url, string $apiKey, array $payload): bool
{
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
    curl_setopt($ch, CURLOPT_TIMEOUT, 30);
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        'Content-Type: application/json',
        'X-Nextstep-Api-Key: ' . $apiKey,
    ]);

    $response = curl_exec($ch);
    $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($response === false || $httpCode < 200 || $httpCode >= 300) {
        childLog("CALLBACK FAILED: url={$url} http={$httpCode}");
        return false;
    }
    return true;
}

// ─── CLIモード ───────────────────────────────────────────────

if (php_sapi_name() === 'cli') {
    $opts = getopt('', ['master:', 'process']);

    // --process: キュー処理
    if (array_key_exists('process', $opts)) {
        ensureQueueDirs();

        // flockによる排他制御（多重起動防止）
        $lockFile = CHILD_DIR . '/sender_child.lock';
        $lockFp = fopen($lockFile, 'w');
        if ($lockFp === false) {
            childLog('ERROR: ロックファイルを開けません');
            exit(1);
        }
        if (!flock($lockFp, LOCK_EX | LOCK_NB)) {
            // 既に別プロセスが実行中
            fclose($lockFp);
            exit(0);
        }

        // defer/ → new/ に再キュー化（一時エラーの再送）
        $stuckSeconds = (int)(loadConf()['child_stuck_seconds'] ?? 300);
        $now = time();
        // cur/ の滞留チェック → defer/ に移動
        foreach (listJsonFiles(QUEUE_DIR . '/cur') as $file) {
            $mtime = @filemtime($file);
            if ($mtime !== false && ($now - $mtime) > $stuckSeconds) {
                $basename = basename($file);
                @rename($file, QUEUE_DIR . '/defer/' . $basename);
                childLog("STUCK→DEFER: {$basename}");
            }
        }
        // defer/ → new/ に移動
        foreach (listJsonFiles(QUEUE_DIR . '/defer') as $file) {
            $basename = basename($file);
            @rename($file, QUEUE_DIR . '/new/' . $basename);
            childLog("DEFER→NEW: {$basename}");
        }

        // キュー送信処理
        processQueue();

        // result_pending/ のコールバック再試行
        $conf = loadConf();
        $pendingFiles = listJsonFiles(QUEUE_DIR . '/result_pending');
        foreach ($pendingFiles as $file) {
            $json = @file_get_contents($file);
            if ($json === false) continue;
            $payload = json_decode($json, true);
            if (!is_array($payload)) { @unlink($file); continue; }
            $resultUrl = $payload['master_result_url'] ?? $conf['master_result_url'] ?? '';
            if ($resultUrl === '') { continue; }
            // master_result_url はペイロード外なので補完
            unset($payload['master_result_url']);
            $ok = callbackToMaster($resultUrl, $conf['api_key'] ?? '', $payload);
            if ($ok) {
                @unlink($file);
                childLog("RETRY OK: result_pending/" . basename($file));
            }
        }

        // ロック解放
        flock($lockFp, LOCK_UN);
        fclose($lockFp);
        exit(0);
    }

    // 初回起動判定: child.confが存在しない場合はセットアップモード
    $isFirstRun = !is_file(CONF_FILE);

    // --master: 手動指定セットアップ / 引数なし初回起動: 自動検出セットアップ
    if (isset($opts['master']) || ($isFirstRun && !array_key_exists('process', $opts))) {

        // マスターURL決定
        if (isset($opts['master'])) {
            // 手動指定
            $masterUrl = rtrim((string)$opts['master'], '/');
            if ($masterUrl === '') {
                fwrite(STDERR, "使い方: php sender_child.php --master=https://master.example.com\n");
                exit(1);
            }
            $childUrl = ''; // 後で手動入力
        } else {
            // 自動検出: get_master.php から取得
            echo "=== マスターサーバー自動検出 ===\n\n";
            $lookupUrl = MASTER_LOOKUP_URL . '?code=' . urlencode(REGISTER_CODE);
            echo "問い合わせ中: {$lookupUrl}\n";

            $ch = curl_init($lookupUrl);
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
            curl_setopt($ch, CURLOPT_TIMEOUT, 15);
            $lookupResponse = curl_exec($ch);
            $lookupHttpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
            $lookupError = curl_error($ch);
            curl_close($ch);

            if ($lookupResponse === false || $lookupHttpCode < 200 || $lookupHttpCode >= 300) {
                fwrite(STDERR, "✗ マスター検出失敗 (HTTP {$lookupHttpCode}): {$lookupError}\n");
                exit(1);
            }

            $lookupData = json_decode((string)$lookupResponse, true);
            if (!is_array($lookupData) || empty($lookupData['master']) || empty($lookupData['addr'])) {
                fwrite(STDERR, "✗ マスター検出レスポンスが不正: {$lookupResponse}\n");
                exit(1);
            }

            $masterUrl = rtrim($lookupData['master'], '/');
            $externalIp = $lookupData['addr'];
            $childUrl = 'http://' . $externalIp . '/sender_child.php';

            echo "✓ マスターURL: {$masterUrl}\n";
            echo "✓ 外部IP: {$externalIp}\n";
            echo "✓ 子サーバーURL: {$childUrl}\n\n";
        }

        echo "=== NEXTSTEP-RELAY 子サーバーセットアップ ===\n\n";

        // ディレクトリ作成
        ensureQueueDirs();
        echo "✓ キューディレクトリ作成完了\n";

        // APIキー生成
        $apiKey = bin2hex(random_bytes(32));

        // child.conf 保存
        $conf = [
            'api_key' => $apiKey,
            'master_url' => $masterUrl,
            'master_result_url' => $masterUrl . '/api/child_result.php',
            'child_stuck_seconds' => 300,
            'created_at' => date('Y-m-d H:i:s'),
        ];
        saveConf($conf);
        echo "✓ child.conf 生成完了\n";
        echo "  APIキー: {$apiKey}\n\n";

        // 子サーバーURL: 自動検出できなかった場合は手動入力
        if ($childUrl === '') {
            echo "この子サーバーのURL（sender_child.phpへのHTTPアクセスURL）を入力してください:\n";
            echo "例: https://child1.example.com/sender_child.php\n> ";
            $childUrl = trim((string)fgets(STDIN));
            if ($childUrl === '') {
                fwrite(STDERR, "URLが空です。中断します。\n");
                exit(1);
            }
        }

        // マスターへ登録リクエスト
        echo "マスターサーバーへ登録中...\n";
        $registerUrl = $masterUrl . '/api/child_register.php';
        $payload = [
            'child_url' => $childUrl,
            'api_key'   => $apiKey,
            'hostname'  => gethostname() ?: 'unknown',
            'php_version' => PHP_VERSION,
        ];

        $ch = curl_init($registerUrl);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
        curl_setopt($ch, CURLOPT_TIMEOUT, 30);
        curl_setopt($ch, CURLOPT_HTTPHEADER, [
            'Content-Type: application/json',
        ]);
        $response = curl_exec($ch);
        $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $curlError = curl_error($ch);
        curl_close($ch);

        if ($response === false) {
            fwrite(STDERR, "✗ マスターへの接続失敗: {$curlError}\n");
            exit(1);
        }

        $result = json_decode((string)$response, true);
        if ($httpCode >= 200 && $httpCode < 300 && is_array($result) && ($result['status'] ?? '') === 'ok') {
            $childId = $result['child_id'] ?? '?';
            echo "✓ 登録成功! (child_id={$childId})\n";
            echo "  {$result['message']}\n\n";

            // child_idをconfに保存
            $conf['child_id'] = (int)$childId;
            saveConf($conf);
        } else {
            $errMsg = $result['error'] ?? $response;
            fwrite(STDERR, "✗ 登録失敗 (HTTP {$httpCode}): {$errMsg}\n");
            exit(1);
        }

        exit(0);
    }

    echo "使い方:\n";
    echo "  php sender_child.php                                       # 初回自動セットアップ\n";
    echo "  php sender_child.php --master=https://master.example.com   # 手動セットアップ\n";
    echo "  php sender_child.php --process                             # キュー処理\n";
    exit(0);
}

// ─── HTTPモード ──────────────────────────────────────────────

$action = $_GET['action'] ?? '';
$conf = loadConf();

if ($action === '') {
    jsonResponse(['status' => 'error', 'error' => 'action パラメータが必要です'], 400);
    exit;
}

// 認証チェック
if (!authenticateRequest($conf)) {
    jsonResponse(['status' => 'error', 'error' => '認証失敗'], 401);
    exit;
}

ensureQueueDirs();

// ─── ?action=check (GET) ─────────────────────────────────────

if ($action === 'check') {
    $stuckSeconds = (int)($conf['child_stuck_seconds'] ?? 300);
    $now = time();

    // cur/ の滞留チェック → defer/ に移動
    foreach (listJsonFiles(QUEUE_DIR . '/cur') as $file) {
        $mtime = @filemtime($file);
        if ($mtime !== false && ($now - $mtime) > $stuckSeconds) {
            $basename = basename($file);
            @rename($file, QUEUE_DIR . '/defer/' . $basename);
            childLog("STUCK→DEFER: {$basename}");
        }
    }

    // defer/ → new/ に移動（再送キュー化）
    foreach (listJsonFiles(QUEUE_DIR . '/defer') as $file) {
        $basename = basename($file);
        @rename($file, QUEUE_DIR . '/new/' . $basename);
        childLog("DEFER→NEW: {$basename}");
    }

    // 未報告結果の収集
    $pendingResults = [];
    $pendingFiles = listJsonFiles(QUEUE_DIR . '/result_pending');
    foreach ($pendingFiles as $file) {
        $json = @file_get_contents($file);
        if ($json !== false) {
            $data = json_decode($json, true);
            if (is_array($data)) {
                $pendingResults[] = $data;
            }
        }
        // 返却後に削除
        @unlink($file);
    }

    jsonResponse([
        'status' => 'ok',
        'queue' => [
            'new'   => countFiles(QUEUE_DIR . '/new'),
            'cur'   => countFiles(QUEUE_DIR . '/cur'),
            'defer' => countFiles(QUEUE_DIR . '/defer'),
        ],
        'php_version' => PHP_VERSION,
        'exec_available' => function_exists('exec') && !in_array('exec', array_map('trim', explode(',', ini_get('disable_functions'))), true),
        'pending_results' => $pendingResults,
    ]);
    exit;
}

// ─── ?action=send (POST) ─────────────────────────────────────

if ($action === 'send') {
    if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
        jsonResponse(['status' => 'error', 'error' => 'POST メソッドが必要です'], 405);
        exit;
    }

    $input = json_decode(file_get_contents('php://input'), true);
    if (!is_array($input)) {
        jsonResponse(['status' => 'error', 'error' => '不正なJSONボディ'], 400);
        exit;
    }

    $batchId = (string)($input['batch_id'] ?? '');
    $masterResultUrl = (string)($input['master_result_url'] ?? '');
    $childId = $input['child_id'] ?? $conf['child_id'] ?? null;
    $emails = $input['emails'] ?? [];

    if ($batchId === '' || !is_array($emails) || empty($emails)) {
        jsonResponse(['status' => 'error', 'error' => 'batch_id と emails が必要です'], 400);
        exit;
    }

    // POSTされたHELOホスト名（child.confには保存せず、各キューJSONに埋め込む）
    $heloHostname = (string)($input['helo_hostname'] ?? '');

    // master_result_url のみ conf 更新（helo_hostnameは不要）
    if ($masterResultUrl !== '' && ($conf['master_result_url'] ?? '') !== $masterResultUrl) {
        $conf['master_result_url'] = $masterResultUrl;
        saveConf($conf);
    }

    // 各メールを queue/new/{outbound_id}.json に保存
    $accepted = 0;
    foreach ($emails as $em) {
        $outboundId = (int)($em['outbound_id'] ?? 0);
        if ($outboundId <= 0) continue;

        $em['batch_id'] = $batchId;
        $em['master_result_url'] = $masterResultUrl;
        $em['child_id'] = $childId;
        $em['helo_hostname'] = $heloHostname;
        $em['queued_at'] = date('Y-m-d H:i:s');

        $filePath = QUEUE_DIR . '/new/' . $outboundId . '.json';
        file_put_contents($filePath, json_encode($em, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX);
        $accepted++;
    }

    childLog("ACCEPTED: batch={$batchId} count={$accepted}");

    // 受理レスポンスを先に返してからバックグラウンドで送信処理
    $response = ['status' => 'ok', 'accepted' => $accepted, 'batch_id' => $batchId];

    // exec利用可能ならバックグラウンド起動
    $execAvailable = function_exists('exec') && !in_array('exec', array_map('trim', explode(',', ini_get('disable_functions'))), true);

    if ($execAvailable) {
        // 先にレスポンスを返す
        jsonResponse($response);

        // バックグラウンドで処理起動
        // PHP_BINARYはphp-fpmのパスを返す場合があるため/usr/bin/phpを固定使用
        $phpBin = '/usr/bin/php';
        $script = escapeshellarg(__FILE__);
        exec("{$phpBin} {$script} --process > /dev/null 2>&1 &");
    } else {
        // exec不可: HTTP接続切断後にインプロセス実行
        ignore_user_abort(true);
        set_time_limit(0);

        $body = json_encode($response, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        header('Content-Type: application/json; charset=utf-8');
        header('Connection: close');
        header('Content-Length: ' . strlen($body));
        echo $body;

        if (ob_get_level() > 0) {
            ob_end_flush();
        }
        flush();

        if (function_exists('fastcgi_finish_request')) {
            fastcgi_finish_request();
        }

        // 送信処理
        processQueue();
    }
    exit;
}

// 不明なアクション
jsonResponse(['status' => 'error', 'error' => '不明なアクション: ' . $action], 400);
