#!/usr/bin/env python3
"""Однофайловый клиент песочницы i-Free Money — Callisto Merchant XML API v2.6.

Single-file sandbox client for the Callisto Merchant XML API v2.6 (one-off payments).

Скопируйте файл в свой проект целиком. Только стандартная библиотека, Python 3.10+.
Внутри всё, на чём обычно ломается интеграция:
  * подпись HMAC-SHA256 v3 + тест-векторы (прогоняются ДО первого сетевого вызова);
  * CreateInvoice -> AuthorizeInvoice (PIN 1234) -> GetInvoice;
  * проверка подписи входящего коллбека SendPaymentTransaction.

Запуск:
    python callisto_client.py         # прогнать тест-векторы подписи (без сети)
    python callisto_client.py demo    # живой сценарий против песочницы

Только sandbox-креды: демо-доступы ниже публичны, боевые секреты в этот файл
класть нельзя. Документация: https://pay-sandbox.digitalspb.com/help
Тест-векторы с разбором:   https://pay-sandbox.digitalspb.com/sigtest.json
"""

from __future__ import annotations

import hashlib
import hmac
import sys
import time
import urllib.request
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from xml.sax.saxutils import escape

BASE_URL = "https://pay-sandbox.digitalspb.com"
ENDPOINT = BASE_URL + "/API/XML/InvoiceManagementService.ashx"

# Публичные демо-доступы песочницы — общие для всех, регистрация не нужна
LOGIN = "demo_login"
SECRET = "demo_password"
PROJECT_ID = "1001"


# ── Подпись ──────────────────────────────────────────────────────────────────


def sign_v3(secret: str, login: str, timestamp: str) -> str:
    """HMAC-SHA256 v3: два прохода через промежуточный inner.

    inner     = hex(HMAC_SHA256(secret, timestamp))
    signature = hex(HMAC_SHA256(secret, login + inner))

    inner подставляется во второй проход как hex-строка из 64 символов,
    НЕ как сырые байты. Тело запроса в подпись не входит.
    """
    key = secret.encode("utf-8")
    inner = hmac.new(key, timestamp.encode("utf-8"), hashlib.sha256).hexdigest()
    return hmac.new(key, (login + inner).encode("utf-8"), hashlib.sha256).hexdigest()


def now_utc() -> str:
    """NowUtc XML-контура: yyyyMMddHHmmss UTC. Дрейф больше ±20 минут — ResultCode=101."""
    return datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")


def verify_callback(body_xml: str | bytes, secret: str = SECRET) -> bool:
    """Проверяет подпись входящего XML-коллбека (SendPaymentTransaction).

    ВАЖНО: подпись покрывает только Login + NowUtc, тело коллбека НЕ подписано.
    Не считайте её контрольной суммой содержимого — перед выдачей товара
    подтвердите состояние инвойса отдельным запросом get_invoice().
    """
    auth = ET.fromstring(body_xml).find("./Header/Authentication")
    if auth is None:
        return False
    expected = sign_v3(secret, auth.findtext("Login") or "", auth.findtext("NowUtc") or "")
    return hmac.compare_digest(expected, (auth.findtext("Signature") or "").lower())


# ── Вызовы API ───────────────────────────────────────────────────────────────


def call(operation: str, inner_xml: str) -> ET.Element:
    """POST одного XML-запроса; operation — имя без суффикса Request."""
    now = now_utc()
    sig = sign_v3(SECRET, LOGIN, now)  # подпись пересчитывается на каждый запрос
    body = (
        '<?xml version="1.0" encoding="utf-8"?>\n'
        "<Request>\n"
        "  <Header><Authentication>\n"
        f"    <Login>{escape(LOGIN)}</Login>\n"
        f"    <NowUtc>{now}</NowUtc>\n"
        f"    <Signature>{sig}</Signature>\n"
        "  </Authentication></Header>\n"
        f"  <{operation}Request>\n{inner_xml}\n  </{operation}Request>\n"
        "</Request>"
    )
    req = urllib.request.Request(
        ENDPOINT,
        data=body.encode("utf-8"),
        headers={"Content-Type": "text/xml; charset=utf-8"},
    )
    with urllib.request.urlopen(req, timeout=30) as resp:
        return ET.fromstring(resp.read())


def check_billing(msisdn: str) -> ET.Element:
    """CheckCustomerBillingSupport — можно ли биллинговать абонента."""
    return call(
        "CheckCustomerBillingSupport",
        f"    <ProjectID>{PROJECT_ID}</ProjectID>\n"
        f"    <MobilePhoneCustomer><MSISDN>{escape(msisdn)}</MSISDN></MobilePhoneCustomer>",
    )


def create_invoice(
    msisdn: str,
    amount: str,
    merchant_order_id: str,
    expected_result: str = "Success",
    callback_delay: int = 0,
) -> ET.Element:
    """CreateInvoice. amount — строка с двумя знаками ("99.00"): float теряет копейки.

    Блок <TestInfo> — песочные ручки, в боевой среде его нет.
    """
    return call(
        "CreateInvoice",
        f"    <ProjectID>{PROJECT_ID}</ProjectID>\n"
        f"    <MobilePhoneCustomer><MSISDN>{escape(msisdn)}</MSISDN></MobilePhoneCustomer>\n"
        "    <InvoiceInfo>\n"
        f"      <MerchantOrderID>{escape(merchant_order_id)}</MerchantOrderID>\n"
        f'      <CustomerPrice amount="{escape(amount)}" currency="RUB" includingVAT="true"/>\n'
        "    </InvoiceInfo>\n"
        "    <TestInfo>\n"
        f"      <ExpectedBillingResult>{escape(expected_result)}</ExpectedBillingResult>\n"
        f"      <CallbackDelaySeconds>{int(callback_delay)}</CallbackDelaySeconds>\n"
        "    </TestInfo>",
    )


def authorize_invoice(invoice_id: str, pin: str = "1234") -> ET.Element:
    """AuthorizeInvoice — подтверждение PIN. В песочнице PIN фиксированный: 1234."""
    return call(
        "AuthorizeInvoice",
        f"    <ProjectID>{PROJECT_ID}</ProjectID>\n"
        f"    <InvoiceInfo><InvoiceID>{escape(invoice_id)}</InvoiceID></InvoiceInfo>\n"
        f"    <AuthorizeInfo><PinCode>{escape(pin)}</PinCode></AuthorizeInfo>",
    )


def get_invoice(invoice_id: str) -> ET.Element:
    """GetInvoice. InvoiceID — строго внутри InvoiceInfo (каноническая форма спеки):
    плоский <InvoiceID> под корнем операции песочница не свяжет с инвойсом."""
    return call(
        "GetInvoice",
        f"    <ProjectID>{PROJECT_ID}</ProjectID>\n"
        f"    <InvoiceInfo><InvoiceID>{escape(invoice_id)}</InvoiceID></InvoiceInfo>",
    )


def result_code(resp: ET.Element) -> str:
    """ResultCode ответа: "0" — успех, иначе — код ошибки (справочник — в документации)."""
    return resp.findtext("ResultCode") or ""


# ── Тест-векторы (дословно из {BASE_URL}/sigtest.json) ───────────────────────

VECTORS = [
    # (login, secret, timestamp, ожидаемая подпись)
    (
        "demo_login",
        "demo_password",
        "2026-02-10T17:56:37Z",  # формат метки JSON-контура — формула та же
        "0c902262f3cfc2282b05a2ccf38f59cce766617badf15c458eb32547f0a0841e",
    ),
    (
        "demo_login",
        "demo_password",
        "20260210175637",  # формат метки XML-контура (NowUtc)
        "2f412b1a3b08a2ae3a5a427753bff4fd2d719e911d1eaa057d2cfa9e598dc163",
    ),
]

NEGATIVE_VECTORS = [
    # Один проход HMAC по login+timestamp — самая частая неверная реализация
    (
        "demo_login",
        "demo_password",
        "2026-02-10T17:56:37Z",
        "c5261f39339d743804df72d2141653e8f0dc718cd5033f4e04899fed45be067b",
    ),
    # inner передан во второй проход сырыми байтами вместо hex-строки
    (
        "demo_login",
        "demo_password",
        "2026-02-10T17:56:37Z",
        "a615c666319b0c5d42a300354accd7f2bae4de418eb5853d438cf370db1e00c7",
    ),
]


def selftest() -> None:
    """Прогон тест-векторов подписи. Вызывайте ДО первого сетевого запроса:
    несовпадение двух строк локально отлаживается, 401 от сервера — нет."""
    for login, secret, ts, expected in VECTORS:
        got = sign_v3(secret, login, ts)
        if got != expected:
            raise SystemExit(
                f"вектор не сошёлся (timestamp={ts}):\n"
                f"  ожидали  {expected}\n  получили {got}\n"
                f"разбор частых причин: {BASE_URL}/sigtest.json"
            )
    for login, secret, ts, wrong in NEGATIVE_VECTORS:
        if sign_v3(secret, login, ts) == wrong:
            raise SystemExit("подпись совпала с заведомо НЕВЕРНОЙ реализацией — сверьте формулу")
    print(f"signature self-test: OK ({len(VECTORS)} векторов, {len(NEGATIVE_VECTORS)} негативных)")


# ── Живой сценарий ───────────────────────────────────────────────────────────


def demo() -> None:
    """CreateInvoice -> AuthorizeInvoice -> GetInvoice до Processed/PAID."""
    # Песочница дедуплицирует инвойсы по клиенту — каждому прогону свой абонент
    msisdn = "79" + str(time.time_ns())[-9:]
    order_id = f"CLIENT-DEMO-{int(time.time())}"

    resp = create_invoice(msisdn, "99.00", order_id)
    if result_code(resp) != "0":
        raise SystemExit("CreateInvoice отклонён:\n" + ET.tostring(resp, encoding="unicode"))
    invoice_id = resp.findtext(".//InvoiceID") or ""
    print(f"создан инвойс {invoice_id} (заказ {order_id}, абонент {msisdn})")

    resp = authorize_invoice(invoice_id)
    print("подтверждён:", resp.findtext(".//InvoiceStatus"))

    for _ in range(10):  # песочница имитирует тарификацию ~6 секунд
        resp = get_invoice(invoice_id)
        status = resp.findtext(".//InvoiceStatus")
        print(f"GetInvoice: {status} / {resp.findtext('.//PaymentStatus')}")
        if status == "Processed":
            return
        time.sleep(2)
    raise SystemExit("инвойс не дошёл до Processed — смотрите ответы выше")


if __name__ == "__main__":
    selftest()
    if len(sys.argv) > 1 and sys.argv[1] == "demo":
        demo()
