#!/usr/bin/env php
<?php
/**
 * tabpaynotify - CGI-приёмник вебхуков TabPay для BILLmanager 6 (ядро mgr5).
 *
 * Устанавливается в /usr/local/mgr5/cgi/, снаружи доступен по адресу
 * https://<панель>/mancgi/tabpaynotify - этот URL вставляется в поле
 * Webhook URL магазина в кабинете TabPay (вкладка Webhook).
 *
 * Протокол вебхука TabPay:
 *   POST, тело - JSON {id, orderId, status, amountKopecks, telegramId, metadata?}.
 *   Заголовки: X-Timestamp (unix-время, секунды),
 *     X-Signature-V2 = hex строчными от HMAC-SHA256(секрет, "{X-Timestamp}.{сырое тело}"),
 *     X-Signature (легаси) = hex от HMAC-SHA256(секрет, сырое тело).
 *   Проверка: сырое тело как есть (JSON не пересобирается), сравнение только
 *   hash_equals; для v2 окно времени 300 секунд; без заголовка v2 - легаси-схема
 *   без окна. Невалидная подпись - 401, успех обработки - 200. Повторы доставки
 *   идут до суток, обработка идемпотентна по паре (id, status).
 *
 * Требования: PHP 7.4+, simplexml. Секрет подписи в ответы и журнал не попадает.
 */

// Путь к mgrctl панели для локальных вызовов billmgr
const MGRCTL_BIN = '/usr/local/mgr5/sbin/mgrctl';

// Допустимое расхождение X-Timestamp с текущим временем, секунды
const SIGNATURE_TIME_WINDOW = 300;

// Файл журнала (секреты и ключи не пишутся)
const NOTIFY_LOG = '/usr/local/mgr5/var/tabpaynotify.log';

main();

/**
 * Основной поток обработки уведомления.
 */
function main(): void
{
    $raw = read_raw_body();
    if ($raw === '') {
        respond(400, 'empty body');
    }

    $secrets = panel_signing_secrets();
    if (count($secrets) === 0) {
        // без секрета проверить подпись нельзя - уведомление не обрабатываем
        nlog('нет настроенного секрета подписи, уведомление отклонено');
        respond(401, 'signing secret is not configured');
    }

    if (!signature_valid($raw, $secrets)) {
        nlog('невалидная подпись, уведомление отклонено');
        respond(401, 'invalid signature');
    }

    $data = json_decode($raw, true);
    if (!is_array($data)) {
        respond(400, 'invalid json');
    }

    $tabpayId = trim((string) ($data['id'] ?? ''));
    $orderId = trim((string) ($data['orderId'] ?? ''));
    $status = strtoupper(trim((string) ($data['status'] ?? '')));
    if ($tabpayId === '' || $orderId === '' || $status === '') {
        respond(400, 'missing fields');
    }

    // Идемпотентность по паре (id, status): повтор просто подтверждаем
    $dedupeKey = $tabpayId . ':' . $status;
    if (already_processed($dedupeKey)) {
        respond(200, 'OK (duplicate)');
    }

    // elid платежа в биллинге: orderId вида bill-<elid> или bill-<elid>-r<N>
    if (!preg_match('/^bill-(\d+)(?:-r\d+)?$/', $orderId, $m)) {
        // подпись валидна, но заказ не из этого модуля - подтверждаем,
        // чтобы TabPay не повторял доставку сутками
        nlog('orderId вне формата модуля: ' . $orderId);
        respond(200, 'OK (foreign orderId)');
    }
    $elid = $m[1];

    if ($status !== 'SUCCESS') {
        // неуспешные и промежуточные статусы платёж в биллинге не меняют
        mark_processed($dedupeKey);
        nlog('elid=' . $elid . ' orderId=' . $orderId . ' статус ' . $status . ' пропущен');
        respond(200, 'OK (status ' . $status . ' ignored)');
    }

    // SUCCESS: отмечаем платёж в биллинге оплаченным локальным вызовом mgrctl.
    // TODO(replace): сверить на тестовой панели имя функции и параметры отметки
    // оплаты (payment.setpaid elid=... info=...) - при расхождении заменить на
    // документированную команду протокола.
    $out = mgrctl_call('payment.setpaid', ['elid' => $elid, 'info' => $tabpayId]);
    if ($out === null) {
        nlog('elid=' . $elid . ' не удалось вызвать mgrctl');
        respond(500, 'mgrctl call failed');
    }
    if (strpos($out, '<error') !== false) {
        // платёж уже оплачен ранее - считаем успехом (идемпотентность)
        if (preg_match('/already|уже/ui', $out) || strpos($out, 'type="exists"') !== false) {
            mark_processed($dedupeKey);
            respond(200, 'OK (already paid)');
        }
        nlog('elid=' . $elid . ' ошибка payment.setpaid');
        // 500, чтобы TabPay повторил доставку после устранения проблемы
        respond(500, 'setpaid error');
    }

    mark_processed($dedupeKey);
    nlog('elid=' . $elid . ' orderId=' . $orderId . ' id=' . $tabpayId . ' отмечен оплаченным');
    respond(200, 'OK');
}

/**
 * Ответ CGI: заголовки печатаются вручную, скрипт исполняется как обычный CGI.
 */
function respond(int $code, string $body): void
{
    $reasons = [
        200 => 'OK',
        400 => 'Bad Request',
        401 => 'Unauthorized',
        500 => 'Internal Server Error',
    ];
    $reason = $reasons[$code] ?? 'OK';
    echo 'Status: ' . $code . ' ' . $reason . "\r\n";
    echo "Content-Type: text/plain; charset=utf-8\r\n";
    echo "\r\n";
    echo $body . "\n";
    exit(0);
}

/**
 * Сырое тело запроса как есть - для проверки подписи JSON не пересобирается.
 */
function read_raw_body(): string
{
    // основной путь по контракту
    $raw = @file_get_contents('php://input');
    if (is_string($raw) && $raw !== '') {
        return $raw;
    }

    // запасной путь: при запуске по шебангу (CLI SAPI) читаем ровно
    // CONTENT_LENGTH байт из stdin, как положено CGI-скрипту
    $len = (int) (getenv('CONTENT_LENGTH') ?: 0);
    if ($len <= 0) {
        return '';
    }
    $raw = '';
    $fh = @fopen('php://stdin', 'rb');
    if (!$fh) {
        return '';
    }
    while (strlen($raw) < $len && !feof($fh)) {
        $chunk = fread($fh, min(8192, $len - strlen($raw)));
        if ($chunk === false || $chunk === '') {
            break;
        }
        $raw .= $chunk;
    }
    fclose($fh);
    return $raw;
}

/**
 * Проверка подписи вебхука по контракту TabPay.
 */
function signature_valid(string $raw, array $secrets): bool
{
    $sigV2 = strtolower(trim((string) getenv('HTTP_X_SIGNATURE_V2')));
    $sigLegacy = strtolower(trim((string) getenv('HTTP_X_SIGNATURE')));
    $timestamp = trim((string) getenv('HTTP_X_TIMESTAMP'));

    if ($sigV2 !== '') {
        // схема v2: HMAC-SHA256(секрет, "{X-Timestamp}.{сырое тело}") с окном времени
        if ($timestamp === '' || !ctype_digit($timestamp)) {
            return false;
        }
        if (abs(time() - (int) $timestamp) > SIGNATURE_TIME_WINDOW) {
            return false;
        }
        $signedPayload = $timestamp . '.' . $raw;
        foreach ($secrets as $secret) {
            $expected = hash_hmac('sha256', $signedPayload, $secret);
            if (hash_equals($expected, $sigV2)) {
                return true;
            }
        }
        return false;
    }

    if ($sigLegacy !== '') {
        // легаси-схема без окна времени: HMAC-SHA256(секрет, сырое тело)
        foreach ($secrets as $secret) {
            $expected = hash_hmac('sha256', $raw, $secret);
            if (hash_equals($expected, $sigLegacy)) {
                return true;
            }
        }
        return false;
    }

    return false;
}

/**
 * Секреты подписи из настроек модуля в панели.
 *
 * Напрямую конфигурация метода оплаты внешнему CGI недоступна, поэтому читаем
 * её локальным вызовом mgrctl: список методов оплаты, фильтр по module=pmtabpay,
 * затем настройки каждого метода. Методов с типом TabPay может быть несколько -
 * подпись проверяется по каждому найденному секрету.
 *
 * TODO(replace): сверить на тестовой панели имена функций и полей:
 * список методов (paymethod, элементы elem с полями id и module) и чтение
 * настроек метода (paymethod.edit elid=N, поле signingsecret).
 */
function panel_signing_secrets(): array
{
    $secrets = [];

    $list = mgrctl_xml('paymethod');
    if ($list !== null) {
        $elems = $list->xpath('//elem');
        if (is_array($elems)) {
            foreach ($elems as $elem) {
                $module = trim((string) ($elem->module ?? ''));
                if ($module !== 'pmtabpay') {
                    continue;
                }
                $id = trim((string) ($elem->id ?? ''));
                if ($id === '') {
                    $id = trim((string) ($elem->elid ?? ''));
                }
                if ($id === '') {
                    continue;
                }
                $edit = mgrctl_xml('paymethod.edit', ['elid' => $id]);
                if ($edit === null) {
                    continue;
                }
                $nodes = $edit->xpath('//signingsecret');
                if (is_array($nodes)) {
                    foreach ($nodes as $node) {
                        $value = trim((string) $node);
                        if ($value !== '') {
                            $secrets[] = $value;
                        }
                    }
                }
            }
        }
    }

    return array_values(array_unique($secrets));
}

/**
 * Локальный вызов функции billmgr через mgrctl. Возвращает сырой XML или null.
 */
function mgrctl_call(string $func, array $params = []): ?string
{
    if (!is_executable(MGRCTL_BIN)) {
        return null;
    }
    $cmd = escapeshellarg(MGRCTL_BIN) . ' -m billmgr ' . escapeshellarg($func);
    foreach ($params as $key => $value) {
        $cmd .= ' ' . escapeshellarg($key . '=' . $value);
    }
    $cmd .= ' out=xml 2>/dev/null';
    $out = shell_exec($cmd);
    return is_string($out) ? $out : null;
}

/**
 * Вызов mgrctl с разбором XML-ответа.
 */
function mgrctl_xml(string $func, array $params = []): ?SimpleXMLElement
{
    $out = mgrctl_call($func, $params);
    if ($out === null || trim($out) === '') {
        return null;
    }
    libxml_use_internal_errors(true);
    $xml = simplexml_load_string($out);
    return ($xml === false) ? null : $xml;
}

/**
 * Файл состояния для идемпотентности: обработанные пары id:status.
 */
function state_file(): string
{
    foreach (['/usr/local/mgr5/var', sys_get_temp_dir()] as $dir) {
        if (is_dir($dir) && is_writable($dir)) {
            return $dir . '/tabpaynotify.state';
        }
    }
    return sys_get_temp_dir() . '/tabpaynotify.state';
}

/**
 * Уже обрабатывали такую пару (id, status)?
 */
function already_processed(string $key): bool
{
    $file = state_file();
    if (!is_file($file)) {
        return false;
    }
    $content = @file_get_contents($file);
    if (!is_string($content) || trim($content) === '') {
        return false;
    }
    $lines = preg_split('/\R+/', trim($content));
    return is_array($lines) && in_array($key, $lines, true);
}

/**
 * Отметить пару (id, status) обработанной.
 */
function mark_processed(string $key): void
{
    @file_put_contents(state_file(), $key . "\n", FILE_APPEND | LOCK_EX);
}

/**
 * Журнал приёмника. Секреты, ключи и тела запросов сюда не пишутся никогда.
 */
function nlog(string $line): void
{
    $entry = date('Y-m-d H:i:s') . ' [tabpaynotify] ' . $line . "\n";
    @file_put_contents(NOTIFY_LOG, $entry, FILE_APPEND | LOCK_EX);
}
