"""Payments API checks: retrieve, update, capture, refund, close, list, idempotency, parallel captures.

Configuration comes from environment variables (see README.md in this folder):
  TABBY_SIM_BASE   simulator base URL            (default http://localhost:4030/tabby-sim)
  TABBY_SIM_KEY    Authorization header value    (default "Bearer sk_test_000000000", the simulator's test key)
"""
import os
import sys

import json, threading, uuid, collections
import urllib.request, urllib.error, urllib.parse

BASE = os.environ.get("TABBY_SIM_BASE", "http://localhost:4030/tabby-sim")
KEY = os.environ.get("TABBY_SIM_KEY", "Bearer sk_test_000000000")


class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, *a, **k):
        return None


opener = urllib.request.build_opener(NoRedirect)


def call(method, path, body=None, form=None, auth=True, key=KEY):
    data, headers = None, {}
    if body is not None:
        data, headers = json.dumps(body).encode(), {"content-type": "application/json"}
    if form is not None:
        data = urllib.parse.urlencode(form).encode()
    if auth:
        headers["Authorization"] = key
    req = urllib.request.Request(BASE + path, data=data, method=method, headers=headers)
    try:
        r = opener.open(req, timeout=20)
    except urllib.error.HTTPError as e:
        r = e
    raw = r.read().decode()
    try:
        parsed = json.loads(raw)
    except ValueError:
        parsed = raw
    return r.status if hasattr(r, "status") else r.code, parsed


def new_payment(approve=True, email="otp.success@tabby.ai"):
    body = {"merchant_code": "ae", "payment": {"amount": "100.00", "currency": "AED",
            "buyer": {"phone": "500000001", "email": email},
            "order": {"reference_id": "T-step4", "items": [{"title": "Phone", "quantity": 1, "unit_price": "100.00"}]}},
            "merchant_urls": {"success": "https://example.com/ok", "cancel": "https://example.com/cancel", "failure": "https://example.com/fail"}}
    _, d = call("POST", "/api/v2/checkout", body=body)
    sid, pid = d["id"], d["payment"]["id"]
    if approve:
        call("POST", f"/hpp/{sid}", form={"action": "approve", "otp": "8888"}, auth=False)
    return pid


def err(resp, status, error_type, text):
    st, d = resp
    return st == status and isinstance(d, dict) and d.get("status") == "error" and d.get("errorType") == error_type and d.get("error") == text


results = []


def check(name, ok, detail=""):
    results.append(ok)
    print(("PASS  " if ok else "FAIL  ") + name + (f"   [{detail}]" if (detail and not ok) else ""))


P = lambda pid, suffix="": f"/api/v2/payments/{pid}{suffix}"

# ---- auth and ids
check("no key -> 401 'missing Bearer scheme' (retrieve)", err(call("GET", P(str(uuid.uuid4())), auth=False), 401, "not_authorized", "missing Bearer scheme"))
check("wrong key prefix -> 401 (capture)", err(call("POST", P(str(uuid.uuid4()), "/captures"), body={}, key="Bearer abc"), 401, "not_authorized", "missing Bearer scheme"))
check("no key on list -> 401", call("GET", "/api/v2/payments", auth=False)[0] == 401)
check("non-uuid id -> 400 'invalid payment id'", err(call("GET", P("nope")), 400, "bad_data", "invalid payment id"))
check("unknown uuid -> 404 'no such payment' (retrieve)", err(call("GET", P(str(uuid.uuid4()))), 404, "not_found", "no such payment"))
for action, method, body in [("captures", "POST", {"amount": "1", "reference_id": "x"}), ("refunds", "POST", {"amount": "1", "reference_id": "x"}), ("close", "POST", None)]:
    check(f"unknown uuid -> 404 ({action})", err(call(method, P(str(uuid.uuid4()), "/" + action), body=body), 404, "not_found", "no such payment"))
check("unknown uuid -> 404 (update)", err(call("PUT", P(str(uuid.uuid4())), body={"order": {"reference_id": "x"}}), 404, "not_found", "no such payment"))

# ---- retrieve a CREATED payment
pid = new_payment(approve=False)
st, d = call("GET", P(pid))
keys = "id created_at expires_at status is_test amount currency description buyer shipping_address order captures refunds buyer_history order_history meta attachment".split()
check("retrieve CREATED: 200 and exactly the documented keys", st == 200 and sorted(d) == sorted(keys), str(sorted(set(d) ^ set(keys))) if isinstance(d, dict) else str(d))
check("retrieve: status CREATED, amount '100', captures/refunds []", d["status"] == "CREATED" and d["amount"] == "100" and d["captures"] == [] and d["refunds"] == [])
check("capture on CREATED -> 400 'could not capture not authorized payments'", err(call("POST", P(pid, "/captures"), body={"amount": "10", "reference_id": "x"}), 400, "bad_data", "could not capture not authorized payments"))
check("refund on CREATED -> 400 'can not refund payment that has no captures and not closed'", err(call("POST", P(pid, "/refunds"), body={"amount": "10", "reference_id": "x"}), 400, "bad_data", "can not refund payment that has no captures and not closed"))
check("close on CREATED -> 400", err(call("POST", P(pid, "/close")), 400, "bad_data", "could not close not authorized payments"))
check("update on CREATED -> 400", err(call("PUT", P(pid), body={"order": {"reference_id": "x"}}), 400, "bad_data", "could not update not authorized payments"))

# ---- authorized payment: update, partial and full capture, idempotency
pid = new_payment()
st, d = call("GET", P(pid))
check("after HPP approve the payment is AUTHORIZED", st == 200 and d["status"] == "AUTHORIZED")
st, d = call("PUT", P(pid), body={"order": {"reference_id": "ORDER-77", "tax_amount": "9"}, "amount": "1", "status": "CLOSED"})
check("update sets order.reference_id, ignores everything else", st == 200 and d["order"]["reference_id"] == "ORDER-77" and d["order"]["tax_amount"] == "0" and d["amount"] == "100" and d["status"] == "AUTHORIZED", str(d)[:200])
st, d = call("PUT", P(pid), body={})
check("update with an empty body changes nothing (200)", st == 200 and d["order"]["reference_id"] == "ORDER-77")

check("capture without reference_id -> 400", err(call("POST", P(pid, "/captures"), body={"amount": "10"}), 400, "bad_data", "reference_id is required"))
check("capture without amount -> 400", err(call("POST", P(pid, "/captures"), body={"reference_id": "x"}), 400, "bad_data", "amount is required"))
check("capture amount 'abc' -> 400", err(call("POST", P(pid, "/captures"), body={"amount": "abc", "reference_id": "x"}), 400, "bad_data", "amount is invalid"))
check("capture amount 0 -> 400", err(call("POST", P(pid, "/captures"), body={"amount": "0", "reference_id": "x"}), 400, "bad_data", "amount must be greater than 0"))
check("capture more than the payment -> 400", err(call("POST", P(pid, "/captures"), body={"amount": "100.01", "reference_id": "x"}), 400, "bad_data", "capture amount exceeds the remaining amount"))

st, d = call("POST", P(pid, "/captures"), body={"amount": "60.00", "reference_id": "cap-1", "tax_amount": "5.00"})
c = d["captures"][0] if st == 200 and d.get("captures") else {}
check("partial capture 60 -> 200, still AUTHORIZED, 1 capture", st == 200 and d["status"] == "AUTHORIZED" and len(d["captures"]) == 1)
check("capture entry shape and normalised amounts", c.get("amount") == "60" and c.get("tax_amount") == "5" and c.get("shipping_amount") == "0" and c.get("reference_id") == "cap-1" and c.get("items") is None and sorted(c) == sorted("id created_at amount tax_amount shipping_amount discount_amount items reference_id".split()), str(c))
st, d = call("POST", P(pid, "/captures"), body={"amount": "60.00", "reference_id": "cap-1"})
check("replay (same reference_id) -> 200, still 1 capture", st == 200 and len(d["captures"]) == 1)
st, d = call("POST", P(pid, "/captures"), body={"amount": "999", "reference_id": "cap-1"})
check("replay with another amount -> 200 original, nothing new", st == 200 and len(d["captures"]) == 1 and d["captures"][0]["amount"] == "60")
check("capture 50 when 40 remain -> 400", err(call("POST", P(pid, "/captures"), body={"amount": "50", "reference_id": "cap-2"}), 400, "bad_data", "capture amount exceeds the remaining amount"))
check("refund while only partly captured (AUTHORIZED) -> 400", err(call("POST", P(pid, "/refunds"), body={"amount": "10", "reference_id": "r-0"}), 400, "bad_data", "can not refund payment that has no captures and not closed"))
st, d = call("POST", P(pid, "/captures"), body={"amount": 40, "reference_id": "cap-2", "items": [{"title": "Phone", "quantity": 1, "unit_price": "40.00", "category": "E"}]})
check("capturing the remaining 40 closes the payment", st == 200 and d["status"] == "CLOSED" and len(d["captures"]) == 2)
check("capture items are normalised", d["captures"][1]["items"] and d["captures"][1]["items"][0]["unit_price"] == "40" and d["captures"][1]["items"][0]["brand"] == "")
st, d = call("POST", P(pid, "/captures"), body={"amount": "40", "reference_id": "cap-2"})
check("retry of the capture that closed it -> 200 CLOSED, 2 captures", st == 200 and d["status"] == "CLOSED" and len(d["captures"]) == 2)
check("new capture on CLOSED -> 400", err(call("POST", P(pid, "/captures"), body={"amount": "1", "reference_id": "cap-3"}), 400, "bad_data", "could not capture not authorized payments"))
check("close on CLOSED -> 400 'already closed'", err(call("POST", P(pid, "/close")), 400, "bad_data", "already closed"))
st, d = call("PUT", P(pid), body={"order": {"reference_id": "ORDER-78"}})
check("update on CLOSED works", st == 200 and d["order"]["reference_id"] == "ORDER-78" and d["status"] == "CLOSED")

# ---- refunds
st, d = call("POST", P(pid, "/refunds"), body={"amount": "30.00", "reference_id": "ref-1", "reason": "damaged"})
r = d["refunds"][0] if st == 200 and d.get("refunds") else {}
check("partial refund 30 -> 200, status stays CLOSED, 1 refund", st == 200 and d["status"] == "CLOSED" and len(d["refunds"]) == 1)
check("refund entry shape", r.get("amount") == "30" and r.get("reason") == "damaged" and r.get("items") is None and r.get("reference_id") == "ref-1" and sorted(r) == sorted("id created_at amount reason items reference_id".split()), str(r))
st, d = call("POST", P(pid, "/refunds"), body={"amount": "30", "reference_id": "ref-1"})
check("refund replay -> 200, still 1 refund", st == 200 and len(d["refunds"]) == 1)
check("refund 80 when 70 left -> 400", err(call("POST", P(pid, "/refunds"), body={"amount": "80", "reference_id": "ref-2"}), 400, "bad_data", "refund amount exceeds the captured amount"))
check("refund reason of the wrong type -> 400", err(call("POST", P(pid, "/refunds"), body={"amount": "1", "reference_id": "ref-2", "reason": 5}), 400, "bad_data", "reason is invalid"))
st, d = call("POST", P(pid, "/refunds"), body={"amount": "70", "reference_id": "ref-2"})
check("refunding the rest (total = captured 100) -> 200, 2 refunds", st == 200 and len(d["refunds"]) == 2)
check("one more cent -> 400", err(call("POST", P(pid, "/refunds"), body={"amount": "0.01", "reference_id": "ref-3"}), 400, "bad_data", "refund amount exceeds the captured amount"))
st, d = call("GET", P(pid))
check("retrieve shows the final state", st == 200 and d["status"] == "CLOSED" and len(d["captures"]) == 2 and len(d["refunds"]) == 2 and d["order"]["reference_id"] == "ORDER-78")

# ---- close without capturing
pid2 = new_payment()
st, d = call("POST", P(pid2, "/close"))
check("close an AUTHORIZED payment -> 200 CLOSED", st == 200 and d["status"] == "CLOSED" and d["captures"] == [])
check("refund after closing with no captures -> 400", err(call("POST", P(pid2, "/refunds"), body={"amount": "1", "reference_id": "r"}), 400, "bad_data", "can not refund payment that has no captures and not closed"))
check("capture after close -> 400", err(call("POST", P(pid2, "/captures"), body={"amount": "1", "reference_id": "c"}), 400, "bad_data", "could not capture not authorized payments"))

# ---- rejected / cancelled payments cannot be captured or closed
rejected = new_payment(approve=True, email="otp.rejected@tabby.ai")
check("payment rejected by the HPP is REJECTED", call("GET", P(rejected))[1]["status"] == "REJECTED")
check("capture on REJECTED -> 400", err(call("POST", P(rejected, "/captures"), body={"amount": "1", "reference_id": "c"}), 400, "bad_data", "could not capture not authorized payments"))
check("update on REJECTED -> 400", err(call("PUT", P(rejected), body={"order": {"reference_id": "x"}}), 400, "bad_data", "could not update not authorized payments"))

# ---- list
st, d = call("GET", "/api/v2/payments")
check("list: 200, payments + pagination{limit,offset,total_count}", st == 200 and isinstance(d["payments"], list) and sorted(d["pagination"]) == ["limit", "offset", "total_count"] and d["pagination"]["limit"] == 20 and d["pagination"]["offset"] == 0)
check("list default: only AUTHORIZED and CLOSED, newest first", all(p["status"] in ("AUTHORIZED", "CLOSED") for p in d["payments"]) and [p["created_at"] for p in d["payments"]] == sorted((p["created_at"] for p in d["payments"]), reverse=True))
check("list: each element is a full payment object", all(sorted(p) == sorted(keys) for p in d["payments"]))
st, d = call("GET", "/api/v2/payments?status=closed&limit=50")
check("limit above 20 -> 400 'failed to decode request'", err((st, d), 400, "bad_data", "failed to decode request"))
check("limit=0 -> 400", call("GET", "/api/v2/payments?limit=0")[0] == 400)
check("offset=-1 -> 400", call("GET", "/api/v2/payments?offset=-1")[0] == 400)
for name in ("new", "captured", "refunded", "cancelled"):
    check("status=%s -> 200" % name, call("GET", "/api/v2/payments?status=" + name)[0] == 200)
check("status=partially_refunded -> 400", call("GET", "/api/v2/payments?status=partially_refunded")[0] == 400)
check("status=bogus -> 400", call("GET", "/api/v2/payments?status=bogus")[0] == 400)
check("created_at__gte=garbage -> 400", call("GET", "/api/v2/payments?created_at__gte=yesterday")[0] == 400)
st, d = call("GET", "/api/v2/payments?status=closed&limit=1")
check("status=closed&limit=1 -> 1 closed payment, total_count >= 1", st == 200 and len(d["payments"]) == 1 and d["payments"][0]["status"] == "CLOSED" and d["pagination"]["total_count"] >= 1 and d["pagination"]["limit"] == 1)
total = d["pagination"]["total_count"]
st, d2 = call("GET", f"/api/v2/payments?status=closed&limit=1&offset={total}")
check("offset past the end -> empty page, same total", st == 200 and d2["payments"] == [] and d2["pagination"]["total_count"] == total and d2["pagination"]["offset"] == total)
st, d = call("GET", "/api/v2/payments?status=rejected&limit=5")
check("status=rejected lists REJECTED payments", st == 200 and d["payments"] and all(p["status"] == "REJECTED" for p in d["payments"]))
st, d = call("GET", "/api/v2/payments?status=new&limit=5")
check("status=new = AUTHORIZED", st == 200 and all(p["status"] == "AUTHORIZED" for p in d["payments"]))
st, d = call("GET", "/api/v2/payments?created_at__gte=2099-01-01")
check("created_at__gte in the future -> empty, total_count 0", st == 200 and d["payments"] == [] and d["pagination"]["total_count"] == 0)
st, d = call("GET", "/api/v2/payments?created_at__lte=2000-01-01")
check("created_at__lte in the past -> empty", st == 200 and d["payments"] == [] and d["pagination"]["total_count"] == 0)
st, d = call("GET", "/api/v2/payments?created_at__gte=2000-01-01T00:00:00Z&created_at__lte=2099-01-01&status=closed&limit=20")
check("a wide date range (date and date-time forms) returns closed payments", st == 200 and len(d["payments"]) >= 1)
st, d = call("GET", f"/api/v2/payments?status=closed&limit=20")
check("the payment closed above is in the closed list", any(p["id"] == pid for p in d["payments"]))

# ---- concurrency: the row lock makes concurrent captures take turns
def hammer(pid, jobs):
    out, barrier = [], threading.Barrier(len(jobs))
    def go(job):
        barrier.wait()
        out.append(call("POST", P(pid, "/captures"), body=job)[0])
    ts = [threading.Thread(target=go, args=(j,)) for j in jobs]
    [t.start() for t in ts]; [t.join() for t in ts]
    return collections.Counter(out)

pid = new_payment()
res = hammer(pid, [{"amount": "20", "reference_id": f"par-{i}"} for i in range(10)])
final = call("GET", P(pid))[1]
check("10 parallel captures of 20 on a payment of 100: exactly 5 succeed", res.get(200) == 5 and res.get(400) == 5, str(dict(res)))
check("...and the payment ends CLOSED with exactly 5 captures totalling 100", final["status"] == "CLOSED" and len(final["captures"]) == 5 and sum(float(c["amount"]) for c in final["captures"]) == 100.0, f"{final['status']} {len(final['captures'])}")

pid = new_payment()
res = hammer(pid, [{"amount": "30", "reference_id": "same"} for _ in range(10)])
final = call("GET", P(pid))[1]
check("10 parallel captures with the SAME reference_id: all 200, one capture recorded", res.get(200) == 10 and len(final["captures"]) == 1 and final["status"] == "AUTHORIZED", f"{dict(res)} captures={len(final['captures'])}")


# ---- dashboard status filters: put one payment in each state, then see which lists hold it
def ids(status):
    st, d = call("GET", "/api/v2/payments?limit=20&status=" + status)
    return st, [p["id"] for p in d["payments"]]

def capture(pid, amount, ref):
    return call("POST", P(pid, "/captures"), body={"amount": amount, "reference_id": ref})

def refund(pid, amount, ref):
    return call("POST", P(pid, "/refunds"), body={"amount": amount, "reference_id": ref})

p_new_none = new_payment()
p_new_part = new_payment(); capture(p_new_part, "60", "c1")
p_captured_full = new_payment(); capture(p_captured_full, "100", "c1")
p_captured_part = new_payment(); capture(p_captured_part, "60", "c1"); call("POST", P(p_captured_part, "/close"))
p_cancelled = new_payment(); call("POST", P(p_cancelled, "/close"))
p_part_refunded = new_payment(); capture(p_part_refunded, "100", "c1"); refund(p_part_refunded, "40", "r1")
p_refunded = new_payment(); capture(p_refunded, "100", "c1"); refund(p_refunded, "40", "r1"); refund(p_refunded, "60", "r2")
everything = {"p_new_none": p_new_none, "p_new_part": p_new_part, "p_captured_full": p_captured_full, "p_captured_part": p_captured_part,
              "p_cancelled": p_cancelled, "p_part_refunded": p_part_refunded, "p_refunded": p_refunded}
expected = {
    "new": {"p_new_none", "p_new_part"},
    "captured": {"p_captured_full", "p_captured_part", "p_part_refunded"},
    "cancelled": {"p_cancelled"},
    "refunded": {"p_refunded"},
}
for status, members in expected.items():
    st, listed = ids(status)
    got = {name for name, pid_ in everything.items() if pid_ in listed}
    check("status=%s lists exactly %s of my 7 payments" % (status, sorted(members)), st == 200 and got == members, "got " + str(sorted(got)))
for status, want in (("captured", "CLOSED"), ("cancelled", "CLOSED"), ("refunded", "CLOSED"), ("new", "AUTHORIZED")):
    st, d = call("GET", "/api/v2/payments?limit=20&status=" + status)
    check("status=%s returns only %s payments" % (status, want), st == 200 and all(p["status"] == want for p in d["payments"]))
st, d = call("GET", "/api/v2/payments?limit=20&status=cancelled")
check("status=cancelled: every payment has no captures", all(p["captures"] == [] for p in d["payments"]))
st, d = call("GET", "/api/v2/payments?limit=20&status=captured")
check("status=captured: every payment has captures", all(len(p["captures"]) > 0 for p in d["payments"]))
st, d = call("GET", "/api/v2/payments?limit=1&status=captured")
tc = d["pagination"]["total_count"]
st, d2 = call("GET", "/api/v2/payments?limit=1&offset=1&status=captured")
check("dashboard filter paging: total_count same on page 2, different payment", d2["pagination"]["total_count"] == tc and d["payments"][0]["id"] != d2["payments"][0]["id"])

# ---- checkout-rejected payment stays CREATED; HPP Reject gives REJECTED
body = {"merchant_code": "ae", "payment": {"amount": "100.00", "currency": "AED", "buyer": {"phone": "500000002", "email": "otp.success@tabby.ai"},
        "order": {"reference_id": "T", "items": [{"title": "x", "quantity": 1, "unit_price": "100.00"}]}},
        "merchant_urls": {"success": "https://example.com/ok", "cancel": "https://example.com/cancel", "failure": "https://example.com/fail"}}
_, d = call("POST", "/api/v2/checkout", body=body)
check("rejected checkout: session rejected, payment CREATED", d["status"] == "rejected" and d["payment"]["status"] == "CREATED")
check("capture on that CREATED payment -> 400", err(capture(d["payment"]["id"], "100", "c"), 400, "bad_data", "could not capture not authorized payments"))
_, d = call("POST", "/api/v2/checkout", body={**body, "payment": {**body["payment"], "buyer": {"phone": "500000001", "email": "otp.success@tabby.ai"}}})
sid, rej = d["id"], d["payment"]["id"]
st, _ = call("POST", f"/hpp/{sid}", form={"action": "reject"}, auth=False)
check("HPP Reject -> 302", st == 302)
check("...payment is REJECTED and is in status=rejected", call("GET", P(rej))[1]["status"] == "REJECTED" and rej in ids("rejected")[1])
_, d = call("POST", "/api/v2/checkout", body={**body, "payment": {**body["payment"], "buyer": {"phone": "500000001", "email": "otp.success@tabby.ai"}}})
call("POST", f"/hpp/{d['id']}", form={"action": "cancel"}, auth=False)
exp = call("GET", P(d["payment"]["id"]))[1]
check("HPP Cancel -> EXPIRED, capture -> 400", exp["status"] == "EXPIRED" and err(capture(d["payment"]["id"], "100", "c"), 400, "bad_data", "could not capture not authorized payments"))

# ---- your test 8: 10 parallel full-amount captures with different reference_ids
pid = new_payment()
res = hammer(pid, [{"amount": "100.00", "reference_id": f"full-{i}"} for i in range(10)])
final = call("GET", P(pid))[1]
check("10 parallel FULL captures, different reference_ids: exactly 1 x 200 and 9 x 400", res.get(200) == 1 and res.get(400) == 9, str(dict(res)))
check("...payment CLOSED with exactly 1 capture of 100", final["status"] == "CLOSED" and len(final["captures"]) == 1 and final["captures"][0]["amount"] == "100")

print(f"\n{sum(results)}/{len(results)} passed")
