#!/usr/bin/env php
<?php
/**
 * pmtabpay - модуль платёжной системы TabPay для BILLmanager 6 (ядро mgr5).
 *
 * Внешний CLI-обработчик по официальному протоколу платёжных модулей BILLmanager:
 * панель вызывает скрипт с аргументом --command, ответы модуль печатает на stdout
 * в виде XML (для команды pay - HTML-страница перенаправления покупателя).
 *
 * Поддерживаемые команды:
 *   config   - XML с параметрами модуля и формой настроек (apikey, signingsecret)
 *   features - XML со списком возможностей (оплата с redirect, без автоклиринга)
 *   validate - проверка заполненности настроек при сохранении формы
 *   pay      - создание платежа в TabPay и перенаправление покупателя на payUrl
 *
 * Требования: PHP 7.4+, расширения curl и simplexml. Внешних библиотек нет.
 * API-ключ и секрет подписи никогда не пишутся в журнал и не выводятся.
 */

// База боевого API TabPay
const TABPAY_API_BASE = 'https://tabpay.org/api';

// Имя модуля (совпадает с именем файла в каталоге addon панели)
const TABPAY_MODULE = 'pmtabpay';

// Путь CGI-приёмника вебхука относительно корня панели
const TABPAY_NOTIFY_PATH = '/mancgi/tabpaynotify';

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

main($argv);

/**
 * Точка входа: разбор аргументов и выбор команды протокола.
 */
function main(array $argv): void
{
    $args = parse_args($argv);
    $command = trim((string) ($args['command'] ?? ''));

    switch ($command) {
        case 'config':
            cmd_config();
            break;
        case 'features':
            cmd_features();
            break;
        case 'validate':
            cmd_validate();
            break;
        case 'pay':
            cmd_pay($args);
            break;
        default:
            plog('неизвестная команда: ' . $command);
            fail_xml('command', 'Неизвестная команда модуля: ' . $command);
    }
    exit(0);
}

/**
 * Разбор аргументов командной строки вида --key value и --key=value.
 */
function parse_args(array $argv): array
{
    $args = [];
    $count = count($argv);
    for ($i = 1; $i < $count; $i++) {
        $arg = $argv[$i];
        if (strpos($arg, '--') !== 0) {
            continue;
        }
        $key = substr($arg, 2);
        $value = 'on';
        if (strpos($key, '=') !== false) {
            [$key, $value] = explode('=', $key, 2);
        } elseif (isset($argv[$i + 1]) && strpos($argv[$i + 1], '--') !== 0) {
            $value = $argv[$i + 1];
            $i++;
        }
        $args[$key] = $value;
    }
    return $args;
}

/**
 * Команда config: служебные параметры модуля и форма настроек метода оплаты.
 */
function cmd_config(): void
{
    // TODO(replace): сверить на тестовой панели формат ответа команды config.
    // По документации модуль сообщает служебные параметры (в том числе
    // payment_script - путь CGI-приёмника уведомлений) и описание формы настроек.
    // В части сборок панели форма настроек подключается отдельным addon-XML
    // (/usr/local/mgr5/etc/xml/billmgr_mod_pmtabpay.xml) - если поля apikey и
    // signingsecret не появятся в форме метода оплаты, перенести блоки metadata
    // и lang в такой файл.
    $notifyPath = TABPAY_NOTIFY_PATH;
    $xml = <<<XMLDOC
<?xml version="1.0" encoding="UTF-8"?>
<doc>
  <params>
    <payment_script>{$notifyPath}</payment_script>
  </params>
  <metadata name="paymethod.edit" type="form">
    <form>
      <page name="payment">
        <field name="apikey">
          <input type="text" name="apikey" required="yes"/>
        </field>
        <field name="signingsecret">
          <input type="password" name="signingsecret" required="yes"/>
        </field>
      </page>
    </form>
  </metadata>
  <lang name="ru">
    <messages name="paymethod.edit">
      <msg name="apikey">API-ключ TabPay</msg>
      <msg name="hint_apikey">Кабинет TabPay: настройки магазина, раздел API</msg>
      <msg name="signingsecret">Секрет подписи TabPay</msg>
      <msg name="hint_signingsecret">Кабинет TabPay: настройки магазина, раздел Webhook. Это отдельный секрет, не API-ключ</msg>
    </messages>
  </lang>
</doc>
XMLDOC;
    echo $xml . "\n";
}

/**
 * Команда features: возможности модуля.
 */
function cmd_features(): void
{
    // TODO(replace): сверить на тестовой панели формат ответа и имена возможностей.
    // Заявляем: redirect (оплата с перенаправлением покупателя на платёжную
    // страницу), pmvalidate (панель зовёт validate при сохранении настроек),
    // notneedprofile (не требуем заполненного плательщика).
    // Автоклиринг сознательно не заявляем: платёж отмечается оплаченным только
    // по вебхуку TabPay через CGI-приёмник tabpaynotify.
    $xml = <<<XMLDOC
<?xml version="1.0" encoding="UTF-8"?>
<doc>
  <feature name="redirect">on</feature>
  <feature name="pmvalidate">on</feature>
  <feature name="notneedprofile">on</feature>
</doc>
XMLDOC;
    echo $xml . "\n";
}

/**
 * Команда validate: проверка заполненности ключей при сохранении формы настроек.
 */
function cmd_validate(): void
{
    // Панель передаёт значения формы настроек на stdin в виде XML.
    // TODO(replace): сверить на тестовой панели имена полей и формат входного XML.
    $xml = read_stdin_xml();

    $apikey = xml_value($xml, ['//apikey', '//doc/apikey', '//paymethod/apikey']);
    $secret = xml_value($xml, ['//signingsecret', '//doc/signingsecret', '//paymethod/signingsecret']);

    if ($apikey === '') {
        fail_xml('apikey', 'Укажите API-ключ TabPay (кабинет TabPay: настройки магазина, раздел API)');
    }
    if ($secret === '') {
        fail_xml('signingsecret', 'Укажите секрет подписи TabPay (кабинет TabPay: настройки магазина, раздел Webhook)');
    }

    // Успех: пустой doc и код возврата 0.
    // TODO(replace): сверить на тестовой панели, не ожидает ли панель эхо входного XML.
    echo "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<doc/>\n";
}

/**
 * Команда pay: создание платежа в TabPay и вывод перенаправления на payUrl.
 */
function cmd_pay(array $args): void
{
    $xml = read_stdin_xml();

    // Идентификатор платежа в биллинге: аргумент --elid либо поле входного XML
    $elid = trim((string) ($args['elid'] ?? ''));
    if ($elid === '') {
        $elid = xml_value($xml, ['//elid', '//payment/@id', '//payment/id']);
    }
    if ($elid === '') {
        fail_xml('elid', 'Не передан идентификатор платежа (elid)');
    }

    // TODO(replace): сверить на тестовой панели имена полей входного XML команды pay:
    // сумма к оплате, валюта, email плательщика, настройки метода оплаты, адрес панели.
    $amountStr = xml_value($xml, ['//payment/paymethodamount', '//paymethodamount', '//payment/amount', '//amount']);
    $apikey = xml_value($xml, ['//paymethod/apikey', '//apikey']);
    $email = xml_value($xml, ['//payment/useremail', '//useremail', '//customer/email', '//user/email', '//email']);
    $currency = xml_value($xml, ['//payment/currency', '//payment/currency_iso', '//currency']);
    $panelUrl = xml_value($xml, ['//payment/managerurl', '//managerurl', '//doc/managerurl', '//site_url']);

    if ($apikey === '') {
        fail_xml('apikey', 'В настройках метода оплаты не найден API-ключ TabPay');
    }

    // TabPay принимает только рубли
    if ($currency !== '' && !in_array(strtoupper($currency), ['RUB', '643', '810'], true)) {
        fail_xml('currency', 'TabPay принимает оплату только в рублях (RUB)');
    }

    // Деньги считаем в копейках: целое число, без float в сумме
    if ($amountStr === '') {
        fail_xml('amount', 'Во входных данных команды pay не найдена сумма платежа');
    }
    $amount = (float) str_replace([',', ' '], ['.', ''], $amountStr);
    $kopecks = (int) round($amount * 100);
    if ($kopecks < 100) {
        fail_xml('amount', 'Сумма платежа меньше минимальной для TabPay (1 рубль)');
    }

    // Адреса возврата покупателя в панель после оплаты.
    // TODO(replace): сверить на тестовой панели точные адреса возврата
    // (func=payment.success и func=payment.fail).
    $successUrl = '';
    $failUrl = '';
    if ($panelUrl !== '') {
        $base = rtrim($panelUrl, '/');
        if (strpos($base, '://') === false) {
            $base = 'https://' . $base;
        }
        // Контракт TabPay требует https для successUrl, поэтому http-адрес панели пропускаем
        if (strpos($base, 'https://') === 0) {
            if (substr($base, -8) !== '/billmgr') {
                $base .= '/billmgr';
            }
            $successUrl = $base . '?func=payment.success&elid=' . rawurlencode($elid);
            $failUrl = $base . '?func=payment.fail&elid=' . rawurlencode($elid);
        }
    }

    $payload = [
        'amountKopecks' => $kopecks,
        'description' => 'Платёж #' . $elid,
        // metadata вернётся в вебхуке - кладём источник и elid платежа
        'metadata' => ['source' => 'billmanager', 'elid' => (string) $elid],
    ];
    if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $payload['email'] = $email;
    }
    if ($successUrl !== '') {
        $payload['successUrl'] = $successUrl;
    }
    if ($failUrl !== '') {
        $payload['failUrl'] = $failUrl;
    }

    $payUrl = create_or_reuse_payment($apikey, 'bill-' . $elid, $payload, $elid);

    // Перенаправление покупателя на платёжную страницу TabPay.
    // TODO(replace): сверить на тестовой панели ожидаемый формат вывода команды pay
    // при возможности redirect: здесь печатается готовая HTML-страница, которую
    // панель отдаёт браузеру покупателя; в части версий протокола вместо HTML
    // ожидается XML-ответ вида <ok type="url">адрес</ok>.
    $safeUrl = htmlspecialchars($payUrl, ENT_QUOTES, 'UTF-8');
    $jsUrl = json_encode($payUrl, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | JSON_UNESCAPED_SLASHES);
    $html = <<<HTMLDOC
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="0; url={$safeUrl}">
<title>Перенаправление на оплату TabPay</title>
</head>
<body>
<p>Перенаправляем на страницу оплаты TabPay...</p>
<p><a href="{$safeUrl}">Перейти к оплате вручную</a></p>
<script>window.location.replace({$jsUrl});</script>
</body>
</html>
HTMLDOC;
    echo $html . "\n";
}

/**
 * Создание платежа в TabPay с обработкой 409 по контракту:
 * повтор orderId -> читаем существующий платёж; живой (CREATED/PENDING) или
 * SUCCESS - используем его; финальный неуспех (FAILED/EXPIRED/CANCELED) -
 * создаём новый платёж с ретрай-суффиксом (-r2, -r3, ...).
 * Возвращает payUrl либо завершает работу с ошибкой.
 */
function create_or_reuse_payment(string $apikey, string $orderBase, array $payload, string $elid): string
{
    $maxAttempts = 5; // базовый orderId + суффиксы -r2..-r5

    for ($n = 1; $n <= $maxAttempts; $n++) {
        $orderId = ($n === 1) ? $orderBase : $orderBase . '-r' . $n;
        $payload['orderId'] = $orderId;

        [$code, $resp] = tabpay_request('POST', '/v1/payments', $apikey, $payload);

        if ($code === 201 && is_array($resp) && !empty($resp['payUrl'])) {
            plog('pay elid=' . $elid . ' создан платёж orderId=' . $orderId . ' id=' . (string) ($resp['id'] ?? '?'));
            return (string) $resp['payUrl'];
        }

        if ($code === 409) {
            // orderId уже занят: читаем существующий платёж по orderId
            [$getCode, $existingResp] = tabpay_request('GET', '/v1/payments?orderId=' . rawurlencode($orderId), $apikey, null);
            $existing = payment_from_response($existingResp);
            $status = strtoupper((string) ($existing['status'] ?? ''));

            if (in_array($status, ['CREATED', 'PENDING', 'SUCCESS'], true) && !empty($existing['payUrl'])) {
                plog('pay elid=' . $elid . ' переиспользован платёж orderId=' . $orderId . ' status=' . $status);
                return (string) $existing['payUrl'];
            }
            if (in_array($status, ['FAILED', 'EXPIRED', 'CANCELED'], true)) {
                // финальный неуспех: пробуем следующий orderId с ретрай-суффиксом
                continue;
            }
            plog('pay elid=' . $elid . ' 409 без пригодного платежа: http=' . $getCode . ' status=' . $status);
            if ($status === '') {
                fail_xml('payment', 'TabPay: не удалось получить существующий платёж по orderId (HTTP ' . $getCode . ')');
            }
            // Неожиданный статус (например REFUNDED): контракт не разрешает
            // ни переиспользовать такой платёж, ни создавать новый с суффиксом
            fail_xml('payment', 'TabPay: существующий платёж по этому заказу в статусе ' . $status . ', оплата невозможна');
        }

        plog('pay elid=' . $elid . ' ошибка создания платежа: http=' . $code);
        fail_xml('payment', 'TabPay: ошибка создания платежа (HTTP ' . $code . ')');
    }

    plog('pay elid=' . $elid . ' исчерпаны попытки создания платежа');
    fail_xml('payment', 'TabPay: исчерпаны попытки создания платежа');
    return ''; // недостижимо, fail_xml завершает процесс
}

/**
 * Ответ на GET по orderId может быть объектом платежа или списком - нормализуем.
 */
function payment_from_response($resp): array
{
    if (!is_array($resp)) {
        return [];
    }
    if (isset($resp['status'])) {
        return $resp;
    }
    if (isset($resp[0]) && is_array($resp[0])) {
        return $resp[0];
    }
    if (isset($resp['items'][0]) && is_array($resp['items'][0])) {
        return $resp['items'][0];
    }
    return [];
}

/**
 * HTTP-запрос к API TabPay. Возвращает [код ответа, разобранный JSON или null].
 */
function tabpay_request(string $method, string $path, string $apikey, ?array $body): array
{
    $ch = curl_init(TABPAY_API_BASE . $path);
    if ($ch === false) {
        return [0, null];
    }
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_HTTPHEADER => [
            'X-Api-Key: ' . $apikey,
            'Content-Type: application/json',
        ],
        CURLOPT_CONNECTTIMEOUT => 10,
        CURLOPT_TIMEOUT => 30,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_SSL_VERIFYHOST => 2,
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE));
    }
    $raw = curl_exec($ch);
    $status = ($raw === false) ? 0 : (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    $json = is_string($raw) ? json_decode($raw, true) : null;
    return [$status, is_array($json) ? $json : null];
}

/**
 * Чтение XML из stdin (панель передаёт параметры команды по протоколу).
 */
function read_stdin_xml(): ?SimpleXMLElement
{
    $raw = '';
    $fh = @fopen('php://stdin', 'rb');
    if ($fh) {
        while (!feof($fh)) {
            $chunk = fread($fh, 8192);
            if ($chunk === false || $chunk === '') {
                break;
            }
            $raw .= $chunk;
        }
        fclose($fh);
    }
    $raw = trim($raw);
    if ($raw === '') {
        return null;
    }
    libxml_use_internal_errors(true);
    $xml = simplexml_load_string($raw);
    return ($xml === false) ? null : $xml;
}

/**
 * Первое непустое значение по списку XPath-выражений.
 */
function xml_value(?SimpleXMLElement $xml, array $paths): string
{
    if ($xml === null) {
        return '';
    }
    foreach ($paths as $path) {
        $nodes = @$xml->xpath($path);
        if (!is_array($nodes)) {
            continue;
        }
        foreach ($nodes as $node) {
            $value = trim((string) $node);
            if ($value !== '') {
                return $value;
            }
        }
    }
    return '';
}

/**
 * Вывод XML ошибки протокола и завершение с ненулевым кодом.
 */
function fail_xml(string $object, string $message): void
{
    // TODO(replace): сверить на тестовой панели формат XML ошибки протокола.
    $obj = htmlspecialchars($object, ENT_QUOTES, 'UTF-8');
    $msg = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
    echo "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
    echo "<doc><error type=\"value\" object=\"{$obj}\"><msg>{$msg}</msg></error></doc>\n";
    exit(1);
}

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