"""Payment gateway adapters behind the one payment_gateway Connector type.

Each provider knows its own protocol — endpoints, auth/signing, amount
format, how the browser is handed over, how its callback is verified and how
to read a payment's status — and maps it onto one small contract
(app/crud/payments.py drives it):

    create_checkout(config, txn, callback_url) -> Checkout
    parse_callback(config, txn, params)        -> CallbackInfo
    fetch_status(config, txn)                  -> (status, provider_status, raw)
    test_credentials(config)                   -> (ok, detail)

`status` is always normalised to "paid" | "pending" | "failed" so Pages,
Tasks and Workflows never need to know gateway-specific values.
PROVIDERS also describes each provider's settings for the Connectors screen,
so adding a gateway is one adapter plus one metadata entry — no UI work.

Amounts are minor units (paise/cents) everywhere in FlowFabric; an adapter
converts when its gateway wants major units.
"""

import base64
import hashlib
import hmac
import json
import secrets
import string
from dataclasses import dataclass, field
from typing import Any, Protocol

import httpx
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

TIMEOUT = 20.0
PAID, PENDING, FAILED = "paid", "pending", "failed"


class PaymentGatewayError(Exception):
    pass


@dataclass
class Checkout:
    """How to hand the payer's browser to the gateway: a plain redirect, or
    an auto-submitted form POST (served by /payments/launch/{id})."""

    kind: str  # "redirect" | "form_post"
    url: str
    fields: dict[str, str] = field(default_factory=dict)
    gateway_reference: str | None = None
    raw: dict = field(default_factory=dict)


@dataclass
class CallbackInfo:
    signature_valid: bool | None  # None = this gateway's callback isn't signed
    provider_status: str | None = None
    raw: dict = field(default_factory=dict)


class Txn(Protocol):
    reference: str
    label: str | None
    amount: int
    currency: str
    gateway_reference: str | None


def _major(amount_minor: int) -> str:
    return f"{amount_minor // 100}.{amount_minor % 100:02d}"


def _dig(data: Any, path: str) -> Any:
    """"body.resultInfo.resultStatus" style lookup; None when absent."""
    for part in [p for p in (path or "").split(".") if p]:
        if isinstance(data, dict):
            data = data.get(part)
        elif isinstance(data, list) and part.isdigit() and int(part) < len(data):
            data = data[int(part)]
        else:
            return None
    return data


def _json(resp: httpx.Response) -> dict:
    try:
        body = resp.json()
        return body if isinstance(body, dict) else {"data": body}
    except ValueError:
        return {"text": resp.text[:500]}


# ---- Paytm (All-in-One / Initiate Transaction API) ---------------------------------------------

_PAYTM_IV = b"@@@@&&&&####$$$$"


def _paytm_cipher(key: str) -> Cipher:
    return Cipher(algorithms.AES(key.encode()), modes.CBC(_PAYTM_IV))


def _paytm_hash(params: str, salt: str) -> str:
    return hashlib.sha256(f"{params}|{salt}".encode()).hexdigest() + salt


def paytm_signature(params: str, key: str) -> str:
    """Paytm's checksum (their PaytmChecksum library, ported): AES-CBC of
    sha256(params|salt)+salt under the merchant key, base64-encoded."""
    salt = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(4))
    padder = padding.PKCS7(128).padder()
    data = padder.update(_paytm_hash(params, salt).encode()) + padder.finalize()
    enc = _paytm_cipher(key).encryptor()
    return base64.b64encode(enc.update(data) + enc.finalize()).decode()


def paytm_verify(params: dict | str, key: str, checksum: str) -> bool:
    if isinstance(params, dict):
        params = "|".join("" if params[k] is None or str(params[k]).lower() == "null" else str(params[k]) for k in sorted(params))
    try:
        dec = _paytm_cipher(key).decryptor()
        raw = dec.update(base64.b64decode(checksum)) + dec.finalize()
        unpadder = padding.PKCS7(128).unpadder()
        text = (unpadder.update(raw) + unpadder.finalize()).decode()
    except Exception:  # noqa: BLE001 - a malformed checksum is simply invalid
        return False
    return hmac.compare_digest(text, _paytm_hash(params, text[-4:]))


class Paytm:
    HOSTS = {"sandbox": "https://securestage.paytmpayments.com", "production": "https://secure.paytmpayments.com"}

    @staticmethod
    def _settings(config: dict) -> tuple[str, str, str, str]:
        mid = (config.get("merchant_id") or "").strip()
        key = config.get("merchant_key") or config.get("api_key") or ""
        env = config.get("environment") or "sandbox"
        website = (config.get("website_name") or ("WEBSTAGING" if env == "sandbox" else "DEFAULT")).strip()
        if not mid or not key:
            raise PaymentGatewayError("Paytm needs the Merchant ID and Merchant Key")
        if len(key) not in (16, 24, 32):
            raise PaymentGatewayError("Paytm Merchant Key should be 16 characters — copy it exactly from the Paytm dashboard")
        return mid, key, (config.get("api_base_url") or Paytm.HOSTS.get(env, Paytm.HOSTS["sandbox"])).rstrip("/"), website

    @staticmethod
    async def _signed_post(url: str, body: dict, key: str) -> dict:
        body_str = json.dumps(body, separators=(",", ":"))
        payload = '{"body":' + body_str + ',"head":{"signature":"' + paytm_signature(body_str, key) + '"}}'
        async with httpx.AsyncClient(timeout=TIMEOUT) as client:
            resp = await client.post(url, content=payload, headers={"Content-Type": "application/json"})
        return _json(resp)

    async def create_checkout(self, config: dict, txn: Txn, callback_url: str) -> Checkout:
        mid, key, host, website = self._settings(config)
        body = {
            "requestType": "Payment",
            "mid": mid,
            "websiteName": website,
            "orderId": txn.reference,
            "callbackUrl": callback_url,
            "txnAmount": {"value": _major(txn.amount), "currency": txn.currency or "INR"},
            "userInfo": {"custId": f"CUST_{txn.reference[-12:]}"},
        }
        data = await self._signed_post(f"{host}/theia/api/v1/initiateTransaction?mid={mid}&orderId={txn.reference}", body, key)
        info = _dig(data, "body.resultInfo") or {}
        token = _dig(data, "body.txnToken")
        if info.get("resultStatus") != "S" or not token:
            raise PaymentGatewayError(f"Paytm refused the transaction: {info.get('resultMsg') or data}")
        return Checkout(
            kind="form_post",
            url=f"{host}/theia/api/v1/showPaymentPage?mid={mid}&orderId={txn.reference}",
            fields={"mid": mid, "orderId": txn.reference, "txnToken": token},
            gateway_reference=txn.reference,
            raw={"resultInfo": info},
        )

    def parse_callback(self, config: dict, txn: Txn, params: dict) -> CallbackInfo:
        _, key, _, _ = self._settings(config)
        checksum = params.get("CHECKSUMHASH")
        rest = {k: v for k, v in params.items() if k != "CHECKSUMHASH"}
        return CallbackInfo(signature_valid=bool(checksum) and paytm_verify(rest, key, checksum), provider_status=params.get("STATUS"), raw=rest)

    async def fetch_status(self, config: dict, txn: Txn) -> tuple[str, str | None, dict]:
        mid, key, host, _ = self._settings(config)
        data = await self._signed_post(f"{host}/v3/order/status", {"mid": mid, "orderId": txn.reference}, key)
        status = _dig(data, "body.resultInfo.resultStatus")
        mapped = {"TXN_SUCCESS": PAID, "PENDING": PENDING, "TXN_FAILURE": FAILED}.get(status or "", PENDING if status is None else FAILED)
        return mapped, status, data

    async def test_credentials(self, config: dict) -> tuple[bool, str]:
        mid, key, host, _ = self._settings(config)
        data = await self._signed_post(f"{host}/v3/order/status", {"mid": mid, "orderId": f"FFTEST{secrets.token_hex(6)}"}, key)
        msg = str(_dig(data, "body.resultInfo.resultMsg") or data)
        low = msg.lower()
        if "checksum" in low or ("mid" in low and "invalid" in low):
            return False, f"Paytm rejected the credentials: {msg}"
        return True, f"Paytm accepted the Merchant ID and Key ({msg})"


# ---- Razorpay (Payment Links API) ----------------------------------------------------------------

class Razorpay:
    BASE = "https://api.razorpay.com"

    @staticmethod
    def _auth(config: dict) -> tuple[str, str]:
        key_id, secret = (config.get("key_id") or "").strip(), config.get("key_secret") or ""
        if not key_id or not secret:
            raise PaymentGatewayError("Razorpay needs the Key ID and Key Secret")
        return key_id, secret

    async def create_checkout(self, config: dict, txn: Txn, callback_url: str) -> Checkout:
        auth = self._auth(config)
        payload = {
            "amount": txn.amount,
            "currency": txn.currency or "INR",
            "reference_id": txn.reference,
            "description": (txn.label or txn.reference)[:2048],
            "callback_url": callback_url,
            "callback_method": "get",
        }
        async with httpx.AsyncClient(timeout=TIMEOUT) as client:
            resp = await client.post(f"{self.BASE}/v1/payment_links", json=payload, auth=auth)
        data = _json(resp)
        if resp.status_code >= 400 or not data.get("short_url"):
            raise PaymentGatewayError(f"Razorpay refused the payment link: {_dig(data, 'error.description') or data}")
        return Checkout(kind="redirect", url=data["short_url"], gateway_reference=data.get("id"), raw={"id": data.get("id"), "status": data.get("status")})

    def parse_callback(self, config: dict, txn: Txn, params: dict) -> CallbackInfo:
        _, secret = self._auth(config)
        signed = "|".join(params.get(k, "") for k in ("razorpay_payment_link_id", "razorpay_payment_link_reference_id", "razorpay_payment_link_status", "razorpay_payment_id"))
        expected = hmac.new(secret.encode(), signed.encode(), hashlib.sha256).hexdigest()
        sig = params.get("razorpay_signature") or ""
        return CallbackInfo(signature_valid=bool(sig) and hmac.compare_digest(expected, sig), provider_status=params.get("razorpay_payment_link_status"), raw=params)

    async def fetch_status(self, config: dict, txn: Txn) -> tuple[str, str | None, dict]:
        if not txn.gateway_reference:
            return FAILED, None, {"error": "no payment link id recorded"}
        async with httpx.AsyncClient(timeout=TIMEOUT) as client:
            resp = await client.get(f"{self.BASE}/v1/payment_links/{txn.gateway_reference}", auth=self._auth(config))
        data = _json(resp)
        if resp.status_code >= 400:
            raise PaymentGatewayError(f"Razorpay status check failed: {_dig(data, 'error.description') or data}")
        status = data.get("status")
        return {"paid": PAID, "expired": FAILED, "cancelled": FAILED}.get(status or "", PENDING), status, {"id": data.get("id"), "status": status, "amount_paid": data.get("amount_paid")}

    async def test_credentials(self, config: dict) -> tuple[bool, str]:
        async with httpx.AsyncClient(timeout=TIMEOUT) as client:
            resp = await client.get(f"{self.BASE}/v1/payment_links", params={"count": 1}, auth=self._auth(config))
        if resp.status_code == 401:
            return False, "Razorpay rejected the Key ID / Key Secret"
        mode = "test mode" if (config.get("key_id") or "").startswith("rzp_test_") else "live mode"
        return resp.status_code < 400, f"Razorpay accepted the keys ({mode})" if resp.status_code < 400 else f"HTTP {resp.status_code}: {_json(resp)}"


# ---- Stripe (Checkout Sessions) --------------------------------------------------------------------

class Stripe:
    BASE = "https://api.stripe.com"

    @staticmethod
    def _headers(config: dict) -> dict:
        key = (config.get("secret_key") or "").strip()
        if not key:
            raise PaymentGatewayError("Stripe needs the Secret key")
        return {"Authorization": f"Bearer {key}"}

    async def create_checkout(self, config: dict, txn: Txn, callback_url: str) -> Checkout:
        form = {
            "mode": "payment",
            "line_items[0][quantity]": "1",
            "line_items[0][price_data][currency]": (txn.currency or "INR").lower(),
            "line_items[0][price_data][unit_amount]": str(txn.amount),
            "line_items[0][price_data][product_data][name]": txn.label or txn.reference,
            "client_reference_id": txn.reference,
            "metadata[reference]": txn.reference,
            "success_url": f"{callback_url}?session_id={{CHECKOUT_SESSION_ID}}",
            "cancel_url": f"{callback_url}?cancelled=1",
        }
        async with httpx.AsyncClient(timeout=TIMEOUT) as client:
            resp = await client.post(f"{self.BASE}/v1/checkout/sessions", data=form, headers=self._headers(config))
        data = _json(resp)
        if resp.status_code >= 400 or not data.get("url"):
            raise PaymentGatewayError(f"Stripe refused the checkout session: {_dig(data, 'error.message') or data}")
        return Checkout(kind="redirect", url=data["url"], gateway_reference=data.get("id"), raw={"id": data.get("id")})

    def parse_callback(self, config: dict, txn: Txn, params: dict) -> CallbackInfo:
        # Stripe's redirect isn't signed — the status check below is what counts.
        return CallbackInfo(signature_valid=None, provider_status="cancelled" if params.get("cancelled") else None, raw=params)

    async def fetch_status(self, config: dict, txn: Txn) -> tuple[str, str | None, dict]:
        if not txn.gateway_reference:
            return FAILED, None, {"error": "no checkout session id recorded"}
        async with httpx.AsyncClient(timeout=TIMEOUT) as client:
            resp = await client.get(f"{self.BASE}/v1/checkout/sessions/{txn.gateway_reference}", headers=self._headers(config))
        data = _json(resp)
        if resp.status_code >= 400:
            raise PaymentGatewayError(f"Stripe status check failed: {_dig(data, 'error.message') or data}")
        pay, session = data.get("payment_status"), data.get("status")
        mapped = PAID if pay == "paid" else FAILED if session == "expired" else PENDING
        return mapped, f"{session}/{pay}", {"id": data.get("id"), "status": session, "payment_status": pay}

    async def test_credentials(self, config: dict) -> tuple[bool, str]:
        async with httpx.AsyncClient(timeout=TIMEOUT) as client:
            resp = await client.get(f"{self.BASE}/v1/balance", headers=self._headers(config))
        if resp.status_code == 401:
            return False, "Stripe rejected the Secret key"
        mode = "test mode" if (config.get("secret_key") or "").startswith("sk_test_") else "live mode"
        return resp.status_code < 400, f"Stripe accepted the key ({mode})" if resp.status_code < 400 else f"HTTP {resp.status_code}"


# ---- Custom REST gateway (any other provider) -----------------------------------------------------

def _render(template: str, values: dict) -> str:
    out = template
    for k, v in values.items():
        out = out.replace("{{" + k + "}}", "" if v is None else str(v))
    return out


class CustomRest:
    """A configurable REST gateway: POST a JSON body (a template, or a
    sensible default) to base URL + checkout path with the chosen auth, read
    the checkout link and gateway reference from the response by path, and
    check status with GET base URL + status path. Also serves connectors
    saved before providers existed (they had no "provider" key)."""

    @staticmethod
    def _base(config: dict) -> str:
        base = (config.get("api_base_url") or "").rstrip("/")
        if not base:
            raise PaymentGatewayError("set the gateway's API base URL")
        return base

    @staticmethod
    def _auth(config: dict) -> tuple[dict, tuple[str, str] | None]:
        kind = config.get("auth_type") or ("bearer" if config.get("api_key") else "none")
        key = config.get("api_key") or ""
        if kind == "bearer":
            return {"Authorization": f"Bearer {key}"}, None
        if kind == "basic":
            return {}, (key, config.get("api_secret") or "")
        if kind == "header":
            return {(config.get("auth_header") or "X-API-Key"): key}, None
        return {}, None

    def _values(self, config: dict, txn: Txn, callback_url: str) -> dict:
        minor = (config.get("amount_unit") or "minor") == "minor"
        return {
            "amount": txn.amount if minor else _major(txn.amount),
            "amount_minor": txn.amount,
            "amount_major": _major(txn.amount),
            "currency": txn.currency or "INR",
            "reference": txn.reference,
            "label": txn.label or "",
            "merchant_id": config.get("merchant_id") or "",
            "callback_url": callback_url,
            "return_url": callback_url,
        }

    async def create_checkout(self, config: dict, txn: Txn, callback_url: str) -> Checkout:
        values = self._values(config, txn, callback_url)
        headers, auth = self._auth(config)
        template = (config.get("body_template") or "").strip()
        if template:
            try:
                body = json.loads(_render(template, values))
            except ValueError as exc:
                raise PaymentGatewayError(f"the request body template isn't valid JSON after filling it in: {exc}") from exc
        else:
            body = {k: values[k] for k in ("amount", "currency", "reference", "merchant_id", "callback_url", "return_url")}
        path = config.get("checkout_path") or "/checkout"
        async with httpx.AsyncClient(timeout=TIMEOUT) as client:
            resp = await client.post(f"{self._base(config)}{path}", json=body, headers=headers, auth=auth)
        data = _json(resp)
        if resp.status_code >= 400:
            raise PaymentGatewayError(f"HTTP {resp.status_code} from {path}: {data}")
        url = _dig(data, config.get("checkout_url_field") or "checkout_url")
        if not url:
            raise PaymentGatewayError(f"the response has no '{config.get('checkout_url_field') or 'checkout_url'}' — set the response field for the checkout link")
        ref = _dig(data, config.get("reference_field") or "payment_reference") or data.get("id")
        return Checkout(kind="redirect", url=str(url), gateway_reference=str(ref) if ref else None, raw=data)

    def parse_callback(self, config: dict, txn: Txn, params: dict) -> CallbackInfo:
        return CallbackInfo(signature_valid=None, provider_status=params.get(config.get("callback_status_param") or "status"), raw=params)

    async def fetch_status(self, config: dict, txn: Txn) -> tuple[str, str | None, dict]:
        path = (config.get("status_path") or "/payments/{reference}").replace("{reference}", txn.gateway_reference or txn.reference).replace("{order_id}", txn.reference)
        headers, auth = self._auth(config)
        async with httpx.AsyncClient(timeout=TIMEOUT) as client:
            resp = await client.get(f"{self._base(config)}{path}", headers=headers, auth=auth)
        data = _json(resp)
        if resp.status_code >= 400:
            raise PaymentGatewayError(f"HTTP {resp.status_code} from {path}: {data}")
        raw_status = _dig(data, config.get("status_field") or "status")
        value = str(raw_status or "").lower()
        paid = {v.strip().lower() for v in (config.get("paid_values") or "paid,success,captured,completed,succeeded").split(",") if v.strip()}
        failed = {v.strip().lower() for v in (config.get("failed_values") or "failed,failure,cancelled,canceled,expired,declined").split(",") if v.strip()}
        return (PAID if value in paid else FAILED if value in failed else PENDING), None if raw_status is None else str(raw_status), data

    async def test_credentials(self, config: dict) -> tuple[bool, str]:
        headers, auth = self._auth(config)
        async with httpx.AsyncClient(timeout=TIMEOUT) as client:
            resp = await client.get(self._base(config), headers=headers, auth=auth)
        if resp.status_code in (401, 403):
            return False, f"HTTP {resp.status_code} — the gateway rejected the credentials"
        return resp.status_code < 500, f"Reached the gateway (HTTP {resp.status_code})"


ADAPTERS: dict[str, Any] = {"paytm": Paytm(), "razorpay": Razorpay(), "stripe": Stripe(), "custom": CustomRest()}


def provider_of(config: dict) -> str:
    return config.get("provider") if config.get("provider") in ADAPTERS else "custom"


def adapter_for(config: dict):
    return ADAPTERS[provider_of(config)]


# What the Connectors screen shows per provider (GET /payments/providers).
def _f(key: str, label: str, *, secret: bool = False, required: bool = True, placeholder: str = "", help: str = "", options: list | None = None, advanced: bool = False, multiline: bool = False) -> dict:
    return {"key": key, "label": label, "secret": secret, "required": required, "placeholder": placeholder, "help": help, "options": options, "advanced": advanced, "multiline": multiline}


PROVIDERS: list[dict] = [
    {
        "id": "paytm",
        "name": "Paytm",
        "tagline": "UPI, cards, net banking and wallets in India",
        "environments": ["sandbox", "production"],
        "docs_url": "https://business.paytm.com/docs/api/initiate-transaction-api",
        "fields": [
            _f("merchant_id", "Merchant ID (MID)", placeholder="e.g. YourMi12345678901234", help="Paytm Dashboard → Developer Settings → API Keys."),
            _f("merchant_key", "Merchant Key", secret=True, help="The 16-character key shown next to the MID. It signs every request and is never sent to Paytm."),
            _f("website_name", "Website name", required=False, placeholder="WEBSTAGING", help="Leave blank for the default: WEBSTAGING in Sandbox, DEFAULT in Production."),
        ],
        "notes": "Payers finish on Paytm's own payment page; FlowFabric verifies the result with Paytm's status API before your page sees it.",
    },
    {
        "id": "razorpay",
        "name": "Razorpay",
        "tagline": "Payment links for UPI, cards and net banking",
        "environments": [],
        "docs_url": "https://razorpay.com/docs/api/payments/payment-links/",
        "fields": [
            _f("key_id", "Key ID", placeholder="rzp_test_…", help="Razorpay Dashboard → Account & Settings → API Keys. rzp_test_ keys use test mode."),
            _f("key_secret", "Key Secret", secret=True, help="Shown once when the key is generated."),
        ],
        "notes": "Test or live mode follows the key you use.",
    },
    {
        "id": "stripe",
        "name": "Stripe",
        "tagline": "Hosted Checkout for cards and wallets worldwide",
        "environments": [],
        "docs_url": "https://docs.stripe.com/api/checkout/sessions",
        "fields": [_f("secret_key", "Secret key", secret=True, placeholder="sk_test_…", help="Stripe Dashboard → Developers → API keys. sk_test_ keys use test mode.")],
        "notes": "Test or live mode follows the key you use.",
    },
    {
        "id": "custom",
        "name": "Other gateway (REST)",
        "tagline": "Any gateway with a create-payment API and a hosted checkout link",
        "environments": [],
        "docs_url": None,
        "fields": [
            _f("api_base_url", "API base URL", placeholder="https://api.yourgateway.com"),
            _f("auth_type", "Authentication", options=[["bearer", "Bearer token"], ["basic", "Basic (key + secret)"], ["header", "API key header"], ["none", "None"]]),
            _f("api_key", "API key / token", secret=True, required=False),
            _f("api_secret", "API secret (Basic auth)", secret=True, required=False),
            _f("auth_header", "Header name (API key header)", required=False, placeholder="X-API-Key"),
            _f("merchant_id", "Merchant ID", required=False, help="Available to the request template as {{merchant_id}}."),
            _f("amount_unit", "Amount format", options=[["minor", "Minor units (paise/cents, e.g. 1050)"], ["major", "Major units (e.g. 10.50)"]]),
            _f("checkout_path", "Create-payment path", required=False, placeholder="/checkout", advanced=True),
            _f(
                "body_template", "Request body (JSON template)", required=False, advanced=True, multiline=True,
                placeholder='{"amount": {{amount}}, "currency": "{{currency}}", "order_id": "{{reference}}", "redirect_url": "{{callback_url}}"}',
                help="Placeholders: {{amount}} {{amount_minor}} {{amount_major}} {{currency}} {{reference}} {{label}} {{merchant_id}} {{callback_url}}. Blank = a default body.",
            ),
            _f("checkout_url_field", "Checkout link field in the response", required=False, placeholder="checkout_url", advanced=True, help='Dot path, e.g. "data.payment_url".'),
            _f("reference_field", "Gateway reference field in the response", required=False, placeholder="payment_reference", advanced=True),
            _f("status_path", "Status-check path", required=False, placeholder="/payments/{reference}", advanced=True, help="{reference} = the gateway's reference, {order_id} = FlowFabric's order id."),
            _f("status_field", "Status field in the status response", required=False, placeholder="status", advanced=True),
            _f("paid_values", "Values meaning paid", required=False, placeholder="paid,success,captured,completed,succeeded", advanced=True),
            _f("failed_values", "Values meaning failed", required=False, placeholder="failed,cancelled,expired,declined", advanced=True),
        ],
        "notes": "The gateway must redirect the payer back to the callback URL FlowFabric sends it.",
    },
]
