"""Online payment through Paystack (cards and Ghana Mobile Money), plus a pretend provider for the demo.

The browser is never trusted about money: an order is marked paid only when Paystack itself says the
transaction succeeded, in GHS, for exactly the order total (checked when the customer returns AND by the
signed webhook).
"""
from typing import Optional
import hashlib
import hmac
import json
import secrets
import urllib.error
import urllib.request
from datetime import timedelta
from decimal import Decimal

from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session

from . import config
from .db import IS_POSTGRES
from .models import Order, OrderEvent, Payment, now
from .services import change_status, locked_products, move_stock

API = "https://api.paystack.co"


def enabled() -> bool:
    return bool(config.PAYMENT_PROVIDER)


def _call(method: str, path: str, body: Optional[dict] = None) -> dict:
    req = urllib.request.Request(API + path, method=method,
                                 data=json.dumps(body).encode() if body is not None else None,
                                 headers={"Authorization": f"Bearer {config.PAYSTACK_SECRET_KEY}",
                                          "Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(req, timeout=20) as r:
            return json.loads(r.read())
    except urllib.error.HTTPError as e:
        try:
            msg = json.loads(e.read()).get("message", "")
        except Exception:
            msg = ""
        raise HTTPException(502, f"The payment service refused the request. {msg}".strip())
    except (urllib.error.URLError, TimeoutError, ValueError):
        raise HTTPException(502, "We could not reach the payment service. Please try again in a moment.")


def paystack_initialize(*, email: str, amount_pesewas: int, reference: str, callback_url: str, order_number: str) -> str:
    r = _call("POST", "/transaction/initialize", {
        "email": email, "amount": amount_pesewas, "currency": "GHS", "reference": reference,
        "callback_url": callback_url, "channels": ["card", "mobile_money"],
        "metadata": {"order": order_number}})
    return r["data"]["authorization_url"]


def paystack_verify(reference: str) -> dict:
    return _call("GET", f"/transaction/verify/{reference}")["data"]


def pesewas(amount: Decimal) -> int:
    return int((amount * 100).to_integral_value())


def start(db: Session, order: Order, base_url: str) -> str:
    """Make a fresh payment attempt for an unpaid online order and return where to send the customer."""
    if order.payment_method != "online":
        raise HTTPException(400, "This order is not an online-payment order.")
    if order.status == "cancelled":
        raise HTTPException(400, "This order was cancelled. Please place a new order.")
    if order.payment_status == "paid":
        raise HTTPException(400, "This order is already paid.")
    ref = f"{order.number}-{secrets.token_hex(3).upper()}"
    pay = Payment(order_id=order.id, reference=ref, amount=order.total, provider=config.PAYMENT_PROVIDER, status="initiated")
    db.add(pay)
    back = f"{config.PUBLIC_URL or base_url.rstrip('/')}/order/{order.number}"
    if config.PAYMENT_PROVIDER == "paystack":
        email = order.email or f"{order.number.lower()}@orders.invalid"
        url = paystack_initialize(email=email, amount_pesewas=pesewas(order.total), reference=ref,
                                  callback_url=back, order_number=order.number)
    else:  # demo: a pretend payment page inside our own shop
        url = f"/pay/demo?ref={ref}&order={order.number}"
    db.commit()
    return url


def _paid_ok(order: Order, data: dict) -> bool:
    return (data.get("status") == "success" and data.get("currency") == "GHS"
            and int(data.get("amount", -1)) == pesewas(order.total))


def settle(db: Session, order: Order, pay: Payment, data: dict, source: str):
    """Apply a verified payment result to the order. Safe to call many times."""
    if pay.status == "success":
        return
    if _paid_ok(order, data):
        pay.status = "success"
        order.payment_status = "paid"
        order.payment_ref = pay.reference
        note = f"Online payment received ({source})"
        if order.status == "cancelled":  # money arrived after the hold expired
            if _reopen(db, order):
                note += ". The order had expired and was reopened."
            else:
                order.payment_status = "refund_due"
                note += ". THE ORDER HAD EXPIRED AND SOME ITEMS ARE GONE: REFUND THIS CUSTOMER."
        db.add(OrderEvent(order_id=order.id, status=order.status, note=note, by="Payment service"))
    # Anything else (failed, abandoned, ongoing) leaves the attempt open: Paystack lets the customer retry the
    # same payment page, and the order simply stays unpaid until a success is confirmed.


def _reopen(db: Session, order: Order) -> bool:
    """Take the stock back for a paid-late order. False (and nothing changed) if any item has run out."""
    products = locked_products(db, [i.product_id for i in order.items if i.product_id])
    if any(i.product_id not in products or products[i.product_id].stock < i.quantity or not products[i.product_id].active
           for i in order.items):
        return False
    for i in order.items:
        move_stock(db, products[i.product_id], -i.quantity, "sale", ref=order.number, by="Late payment")
    order.status = "pending"
    return True


def verify_order(db: Session, order: Order) -> Order:
    """Called when the customer comes back from paying. Asks the provider; never trusts the browser."""
    if order.payment_status == "paid":
        return order
    pays = db.scalars(select(Payment).where(Payment.order_id == order.id, Payment.status == "initiated")
                      .order_by(Payment.id.desc())).all()
    problem = None
    for pay in pays:
        if pay.provider == "paystack":
            try:
                data = paystack_verify(pay.reference)
            except HTTPException as e:  # one unreachable/unknown attempt must not hide a paid one
                problem = e
                continue
        else:
            data = ({"status": "success", "currency": "GHS", "amount": pesewas(order.total)} if pay.demo_result == "success"
                    else {"status": "failed"} if pay.demo_result == "failed" else {"status": "pending"})
        settle(db, order, pay, data, "return from payment page")
        if order.payment_status == "paid":
            break
    db.commit()
    if problem and order.payment_status != "paid" and len(pays) == 1:
        raise problem
    return order


def webhook(db: Session, raw: bytes, signature: str):
    if config.PAYMENT_PROVIDER != "paystack":
        raise HTTPException(404, "Not found.")
    good = hmac.new(config.PAYSTACK_SECRET_KEY.encode(), raw, hashlib.sha512).hexdigest()
    if not signature or not hmac.compare_digest(good, signature):
        raise HTTPException(401, "Bad signature.")
    event = json.loads(raw)
    if event.get("event") != "charge.success":
        return
    data = event.get("data", {})
    pay = db.scalar(select(Payment).where(Payment.reference == data.get("reference", "")).with_for_update())
    if not pay:
        return
    order = db.scalar(select(Order).where(Order.id == pay.order_id).with_for_update())
    settle(db, order, pay, data, "payment confirmation")
    db.commit()


def expire_unpaid(db: Session):
    """Online orders nobody paid within the hold time give their stock back."""
    cutoff = now() - timedelta(minutes=config.UNPAID_HOLD_MINUTES)
    stale = db.scalars(select(Order).where(Order.payment_method == "online", Order.payment_status == "unpaid",
                                           Order.status == "pending", Order.created_at < cutoff)
                       .with_for_update(skip_locked=IS_POSTGRES)).all()
    for o in stale:
        change_status(db, o, "cancelled", "System", f"Not paid within {config.UNPAID_HOLD_MINUTES} minutes")
    if stale:
        db.commit()
