#!/usr/bin/env python3
"""Однофайловый клиент песочницы i-Free Money — Subscription Service (подписки).

Single-file sandbox client for the Subscription Service XML API (subscriptions).

Скопируйте файл в свой проект целиком. Только стандартная библиотека, Python 3.10+.
Внутри всё, на чём обычно ломается интеграция:
  * подпись HMAC-SHA256 подписок + тест-векторы (прогоняются ДО первого сетевого
    вызова) — формула СВОЯ, не та, что у разовых платежей Callisto;
  * CreateSubscription -> ApproveSubscription -> GetSubscriptionStatus;
  * проверка подписи входящего коллбека SubscriptionStatusNotification.

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

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

from __future__ import annotations

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

BASE_URL = "https://subs-sandbox.digitalspb.com"

# Публичные демо-доступы песочницы — общие для всех, регистрация не нужна
PROJECT_ID = "2001"
SECRET_KEY = "subs_secret_key"


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


def sign_subs(secret_key: str, subscription_project_id: str, now: str) -> str:
    """HMAC-SHA256 подписок: ключ — SecretKey, сообщение — SubscriptionProjectID + Now.

    Один проход, без логина и без промежуточного inner — это ДРУГАЯ формула,
    не та, что у разовых платежей Callisto. Порядок конкатенации фиксирован:
    сначала SubscriptionProjectID, затем Now. Остальные поля в подпись не входят.
    """
    msg = (subscription_project_id + now).encode("utf-8")
    return hmac.new(secret_key.encode("utf-8"), msg, hashlib.sha256).hexdigest()


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


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

    Поля Now и Signature лежат в теле нотификации рядом с SubscriptionProjectID.
    ВАЖНО: подпись покрывает только SubscriptionProjectID + Now, остальное тело
    НЕ подписано — статус подтверждайте отдельным get_subscription_status().
    """
    root = ET.fromstring(body_xml)
    project_id = root.findtext(".//SubscriptionProjectID") or ""
    now = root.findtext(".//Now") or ""
    provided = (root.findtext(".//Signature") or "").lower()
    expected = sign_subs(secret_key, project_id, now)
    return hmac.compare_digest(expected, provided)


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


def call(method: str, inner_xml: str = "") -> ET.Element:
    """POST одного запроса; method — имя без суффикса Request (CreateSubscription...).

    Подпись передаётся в теле (поля Now и Signature), а не в заголовках.
    """
    now = now_utc()
    sig = sign_subs(SECRET_KEY, PROJECT_ID, now)  # пересчитывается на каждый запрос
    body = (
        f"<{method}Request>\n"
        f"  <SubscriptionProjectID>{PROJECT_ID}</SubscriptionProjectID>\n"
        f"{inner_xml}"
        f"  <Now>{now}</Now>\n"
        f"  <Signature>{sig}</Signature>\n"
        f"</{method}Request>"
    )
    req = urllib.request.Request(
        f"{BASE_URL}/Http/Services/{method}.aspx",
        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 create_subscription(msisdn: str, billing_period_seconds: int = 0) -> ET.Element:
    """CreateSubscription с подтверждением по SMS. Сохраните SubscriptionID из ответа:
    именно он идентифицирует подписку во всех остальных вызовах.

    billing_period_seconds включает регулярные списания и сжимает период до секунд —
    без него списаний в песочнице не будет вовсе."""
    test_info = (
        f"  <TestInfo><BillingPeriodSeconds>{int(billing_period_seconds)}"
        "</BillingPeriodSeconds></TestInfo>\n"
        if billing_period_seconds else ""
    )
    return call(
        "CreateSubscription",
        f"  <Subscriber_MSISDN>{escape(msisdn)}</Subscriber_MSISDN>\n{test_info}",
    )


def create_card_subscription(customer_id: str) -> ET.Element:
    """CreateSubscription с оплатой картой или через СБП.

    В ответе AuthorizeUrl — страница оплаты: перенаправьте на неё абонента.
    Для оплаты по QR-коду СБП добавьте к адресу `?useSbpQr=True`.
    ApproveSubscription в этом сценарии не вызывается: подписка активируется
    по факту оплаты, партнёру приходит уведомление EventType=APPROVED."""
    return call(
        "CreateSubscription",
        "  <CustomerType>Card</CustomerType>\n"
        f"  <CustomerId>{escape(customer_id)}</CustomerId>\n",
    )


def create_wap_subscription(client_ip: str) -> ET.Element:
    """CreateSubscription без номера — WAP-активация.

    Номер абонента определяет шлюз оператора. В ответе AuthorizeUrl — страница
    подтверждения; к ней можно дописать `?ReturnUrl=result.php`, чтобы вернуть
    абонента на свой адрес с параметрами subscriptionid, msisdn и action."""
    return call("CreateSubscription", f"  <ClientIP>{escape(client_ip)}</ClientIP>\n")


def approve_subscription(subscription_id: str, pin: str = "1234") -> ET.Element:
    """ApproveSubscription — подтверждение PIN (в песочнице верный код 1234,
    даётся 3 попытки)."""
    return call(
        "ApproveSubscription",
        f"  <SubscriptionID>{escape(subscription_id)}</SubscriptionID>\n"
        f"  <PinCode>{escape(pin)}</PinCode>\n",
    )


def get_subscription_status(subscription_id: str) -> ET.Element:
    """GetSubscriptionStatus — текущий статус подписки."""
    return call(
        "GetSubscriptionStatus",
        f"  <SubscriptionID>{escape(subscription_id)}</SubscriptionID>\n",
    )


def cancel_subscription(subscription_id: str) -> ET.Element:
    """CancelSubscription — отмена подписки."""
    return call(
        "CancelSubscription",
        f"  <SubscriptionID>{escape(subscription_id)}</SubscriptionID>\n",
    )


def resend_pin_code(subscription_id: str) -> ET.Element:
    """ResendPinCode — повторная отправка PIN (в песочнице SMS не уходит)."""
    return call(
        "ResendPinCode",
        f"  <SubscriptionID>{escape(subscription_id)}</SubscriptionID>\n",
    )


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

VECTORS = [
    # (subscription_project_id, secret_key, now, ожидаемая подпись)
    (
        "2001",
        "subs_secret_key",
        "20260210175637",
        "7b61d6943253d43f1318d9bf251d599f8e6d1a3608123b0178732d4828d436a6",
    ),
]

NEGATIVE_VECTORS = [
    # Конкатенация в обратном порядке: Now + SubscriptionProjectID
    (
        "2001",
        "subs_secret_key",
        "20260210175637",
        "b49249dc3accfa0e4a55a0b576ee0aac08394d87407e7283ddcb7df3b937b2dc",
    ),
    # MD5-формула из редакций спеки до 2026.05.01 — больше не принимается;
    # если ваша подпись 32 символа вместо 64, вы работаете по устаревшему документу
    (
        "2001",
        "subs_secret_key",
        "20260210175637",
        "8d03ad158808ed83bb5ba5381ab3ae1c",
    ),
]


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


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


def demo() -> None:
    """CreateSubscription -> ApproveSubscription -> GetSubscriptionStatus."""
    # Свой номер на прогон: подписка уникальна для пары проект + номер, и повторный
    # CreateSubscription на живую подписку отвечает SubscriptionAlreadyCreated.
    msisdn = "7960" + datetime.now(timezone.utc).strftime("%H%M%S") + "0"
    resp = create_subscription(msisdn)
    status = resp.findtext("StatusName")
    subscription_id = resp.findtext("SubscriptionID") or ""
    print(f"создана подписка {subscription_id}, StatusName={status}")

    resp = approve_subscription(subscription_id)
    print("подтверждена:", resp.findtext("StatusName"))

    resp = get_subscription_status(subscription_id)
    print("статус:", resp.findtext("SubscriptionStatus"), resp.findtext("PaidUntil"))


def demo_sbp() -> None:
    """Подписка с оплатой через СБП: CreateSubscription -> AuthorizeUrl."""
    customer_id = "cust-" + datetime.now(timezone.utc).strftime("%H%M%S")
    resp = create_card_subscription(customer_id)
    subscription_id = resp.findtext("SubscriptionID") or ""
    authorize_url = (resp.findtext("AuthorizeUrl") or "").strip()
    print(f"создана подписка {subscription_id}, статус {resp.findtext('SubscriptionStatus')}")
    print("страница оплаты картой:", authorize_url)
    print("оплата по QR СБП:      ", authorize_url + "?useSbpQr=True")
    print("после оплаты статус станет Active, партнёру уйдёт EventType=APPROVED")


if __name__ == "__main__":
    selftest()
    mode = sys.argv[1] if len(sys.argv) > 1 else ""
    if mode == "demo":
        demo()
    elif mode == "sbp":
        demo_sbp()
