#!/usr/bin/env python3
"""Task 13 — Registration rules + per-member team fee deduction.

Covers:
  A. Match deadline model math (midnight cutoff, open-until-start exception,
     past matches never registrable)
  B. HTTP: past match blocked (GET banner + POST guard)
  C. HTTP: open match solo join → wallet debit + visible entry Transaction
  D. HTTP: team join → EVERY selected member pays separately from own wallet
     (+ paid flag fix + per-member entry transactions + notifications)
  E. Team join blocked when one member cannot afford it (no partial charge)
  F. Admin match create/edit persists the deadline option; join page shows
     the right deadline notice
  G. Public surfaces: game page / tournament detail / tournaments list show
     closed state

Run:  python scripts/test_task13_registration.py
"""
import sys, os, json as _json
from datetime import datetime, timedelta

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

PASS, FAIL = 0, 0
def check(name, cond, extra=""):
    global PASS, FAIL
    if cond:
        PASS += 1
        print(f"  ✓ {name}")
    else:
        FAIL += 1
        print(f"  ✗ FAIL: {name} {extra}")

from app import create_app
from app.extensions import db
from app.models.game import Game, GameMode
from app.models.user import User
from app.models.match import Match, MatchParticipant
from app.models.team import Team, TeamMember
from app.models.wallet import Wallet, Transaction
from app.models.notification import Notification

app = create_app()
app.config["WTF_CSRF_ENABLED"] = False
ctx = app.app_context()
ctx.push()

STAMP = "t13"

# ─── sandbox entities ───────────────────────────────────────────────
game = Game(slug=f"__{STAMP}_game", name=f"__{STAMP}_game")
db.session.add(game)
db.session.flush()
mode = GameMode(game_id=game.id, slug=f"__{STAMP}_m", name=f"__{STAMP}_m", squad_size=2)
db.session.add(mode)
db.session.flush()

users = [User.query.filter_by(username=u).first() for u in ("Champ", "zynox", "ProLeague")]
check("3 users exist", all(users))
u_admin = User.query.filter_by(email="admin@vapolyx.gg").first()
check("admin exists", bool(u_admin))

created_matches = []
created_teams = []

def mk_match(title, starts_at, fee=0, team_mode=False, open_until_start=False, squad=None):
    m = Match(game_id=game.id, mode_id=mode.id, region="global", title=title,
              starts_at=starts_at, entry_fee=fee, prize_pool=0, capacity=50,
              team_mode=team_mode, status="upcoming", reg_open_until_start=open_until_start)
    db.session.add(m)
    db.session.flush()
    created_matches.append(m.id)
    return m

def entry_balance(uid):
    w = Wallet.query.filter_by(user_id=uid, kind="deposit").first()
    return float(w.balance) if w else 0.0

def set_balance(uid, amount):
    w = Wallet.query.filter_by(user_id=uid, kind="deposit").first()
    if not w:
        w = Wallet(user_id=uid, kind="deposit", balance=0)
        db.session.add(w)
        db.session.flush()
    w.balance = float(amount)
    return w

# snapshot for restore
bal_snapshot = {u.id: entry_balance(u.id) for u in users}
region_snapshot = {u.id: u.region for u in users}
for u in users:
    u.region = "global"
    set_balance(u.id, 100)
db.session.commit()

now = datetime.utcnow()
far_future = now.replace(microsecond=0) + timedelta(days=5)

print("═" * 60)
print("A. Deadline model math (fake clock)")
print("═" * 60)
import app.models.match as mm_module
RealDT = mm_module.datetime
class FakeDT(RealDT):
    _fake_now = None
    @classmethod
    def utcnow(cls):
        return cls._fake_now

def model_case(fake_now, starts_at, open_until_start, status="upcoming"):
    mm_module.datetime = FakeDT
    FakeDT._fake_now = fake_now
    try:
        m = Match(starts_at=starts_at, status=status,
                  reg_open_until_start=open_until_start)
        return m.registration_open, m.registration_deadline
    finally:
        mm_module.datetime = RealDT

base = datetime(2026, 9, 21, 15, 0, 0)
# 1) match in 5 days @18:00 → deadline that day 00:00 → OPEN
op, dl = model_case(base, base.replace(microsecond=0) + timedelta(days=5, hours=3), False)
check("future match open", op is True)
check("deadline = midnight of match day", (dl.hour, dl.minute) == (0, 0), dl)
# 2) same rule: match tomorrow 00:30 → deadline tomorrow 00:00 → OPEN until then
op, dl = model_case(base, base.replace(microsecond=0) + timedelta(days=1, hours=9, minutes=30), False)
check("tomorrow-morning match open tonight", op is True, dl)
# 3) deadline passed (match today 20:00, now 15:00) → CLOSED although not started
op, dl = model_case(base, base.replace(hour=20, minute=0, microsecond=0), False)
check("same-day match closed by midnight rule", op is False, dl)
check("deadline is 00:00 of today", dl == base.replace(hour=0, minute=0), dl)
# 4) admin exception → same match OPEN until start, deadline == starts_at
op, dl = model_case(base, base.replace(hour=20, minute=0, microsecond=0), True)
check("admin exception keeps it open", op is True)
check("exception deadline == start time", dl == base.replace(hour=20, minute=0), dl)
# 5) past match NEVER registrable (even with exception)
op, _ = model_case(base, base - timedelta(hours=2), True)
check("past match closed even with exception", op is False)
# 6) finished/cancelled/live never registrable
op, _ = model_case(base, base + timedelta(days=5), False, status="finished")
check("finished closed", op is False)
op, _ = model_case(base, base + timedelta(days=5), False, status="cancelled")
check("cancelled closed", op is False)
op, _ = model_case(base, base + timedelta(days=5), False, status="live")
check("live closed", op is False)

print("═" * 60)
print("B. Past match blocked (GET banner + POST guard)")
print("═" * 60)
m_past = mk_match(f"{STAMP} past match", now - timedelta(hours=2), fee=10)
client = app.test_client()
with client.session_transaction() as s:
    s["user_id"] = users[0].id

resp = client.get(f"/match/{m_past.id}/join")
html = resp.get_data(as_text=True)
check("past match join page 200", resp.status_code == 200)
check("closed banner shown", "Registration Closed" in html)
check("started-reason text shown", "already started" in html)
check("join section hidden", 'id="joinSection"' not in html)

resp = client.post(f"/match/{m_past.id}/join", data={"join_type": "solo"}, follow_redirects=True)
html = resp.get_data(as_text=True)
check("past match POST blocked", "Registration for this match is closed" in html)
check("no participant created", MatchParticipant.query.filter_by(match_id=m_past.id).count() == 0)
check("no wallet change", entry_balance(users[0].id) == 100.0)

print("═" * 60)
print("C. Open match solo join → debit + entry Transaction")
print("═" * 60)
m_open = mk_match(f"{STAMP} open match", far_future + timedelta(hours=3), fee=12)
tx_before = Transaction.query.filter_by(user_id=users[0].id, kind="entry").count()

resp = client.get(f"/match/{m_open.id}/join")
html = resp.get_data(as_text=True)
check("open match join page 200", resp.status_code == 200)
check("midnight deadline notice shown", "12 midnight" in html, html[:200])
check("join section visible", 'id="joinSection"' in html)

resp = client.post(f"/match/{m_open.id}/join", data={"join_type": "solo"}, follow_redirects=True)
check("solo join ok", resp.status_code == 200)
p = MatchParticipant.query.filter_by(match_id=m_open.id, user_id=users[0].id).first()
check("participant created", bool(p))
check("participant paid=True", bool(p and p.paid))
check("wallet debited 12", entry_balance(users[0].id) == 88.0, entry_balance(users[0].id))
tx = Transaction.query.filter_by(user_id=users[0].id, kind="entry").order_by(Transaction.id.desc()).first()
check("entry transaction created", bool(tx) and tx.amount == -12, tx and (tx.amount, tx.kind))
check("tx ref ENTRY- prefixed", bool(tx and tx.ref.startswith("ENTRY-")), tx and tx.ref)
check("tx reason mentions match", bool(tx and m_open.title in (tx.reason or "")))

print("═" * 60)
print("D. Team join → each member pays separately")
print("═" * 60)
m_team = mk_match(f"{STAMP} team match", far_future + timedelta(hours=4), fee=12, team_mode=True)
team = Team(game_id=game.id, name=f"{STAMP} team", owner_id=users[0].id)
db.session.add(team)
db.session.flush()
db.session.add(TeamMember(team_id=team.id, user_id=users[0].id, role="owner"))
db.session.add(TeamMember(team_id=team.id, user_id=users[1].id, role="member"))
db.session.commit()
created_teams.append(team.id)

b0, b1 = entry_balance(users[0].id), entry_balance(users[1].id)
n1_before = Notification.query.filter_by(user_id=users[1].id).count()
resp = client.post(f"/match/{m_team.id}/join", data={
    "join_type": "team", "team_id": str(team.id),
    "member_ids": [str(users[0].id), str(users[1].id)],
}, follow_redirects=True)
check("team join ok", resp.status_code == 200, resp.status_code)
p0 = MatchParticipant.query.filter_by(match_id=m_team.id, user_id=users[0].id).first()
p1 = MatchParticipant.query.filter_by(match_id=m_team.id, user_id=users[1].id).first()
check("both participants created", bool(p0) and bool(p1))
check("both paid=True (fee debited)", bool(p0 and p0.paid and p1 and p1.paid))
check("owner debited separately", entry_balance(users[0].id) == b0 - 12, entry_balance(users[0].id))
check("member debited separately", entry_balance(users[1].id) == b1 - 12, entry_balance(users[1].id))
txs = Transaction.query.filter(Transaction.kind == "entry",
                               Transaction.user_id.in_([users[0].id, users[1].id]),
                               Transaction.reason == f"Entry - {m_team.title}").all()
check("2 entry transactions (one per member)", len(txs) == 2, len(txs))
check("each tx amount -12", all(t.amount == -12 for t in txs))
check("member notified", Notification.query.filter_by(user_id=users[1].id).count() == n1_before + 1)

print("═" * 60)
print("E. Team join blocked when a member cannot afford")
print("═" * 60)
set_balance(users[1].id, 5)   # member has 5 < fee 12
db.session.commit()
m_team2 = mk_match(f"{STAMP} team match 2", far_future + timedelta(hours=5), fee=12, team_mode=True)
b0 = entry_balance(users[0].id)
resp = client.post(f"/match/{m_team2.id}/join", data={
    "join_type": "team", "team_id": str(team.id),
    "member_ids": [str(users[0].id), str(users[1].id)],
}, follow_redirects=True)
html = resp.get_data(as_text=True)
check("blocked with insufficient message", "insufficient" in html.lower())
check("owner NOT debited (atomic)", entry_balance(users[0].id) == b0)
check("no participant created", MatchParticipant.query.filter_by(match_id=m_team2.id).count() == 0)
set_balance(users[1].id, 100)
db.session.commit()

print("═" * 60)
print("F. Admin create/edit persists deadline option")
print("═" * 60)
admin_c = app.test_client()
with admin_c.session_transaction() as s:
    s["user_id"] = u_admin.id

start_iso = (far_future + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M")
resp = admin_c.post("/admin/matches/create", data={
    "save": "1", "title": f"{STAMP} exception match", "game_id": str(game.id),
    "mode_id": str(mode.id), "region": "global", "starts_at": start_iso,
    "entry_fee": "0", "prize_pool": "0", "capacity": "50",
    "reg_open_until_start": "1",
}, follow_redirects=True)
check("admin create 200", resp.status_code == 200, resp.status_code)
m_exc = Match.query.filter_by(title=f"{STAMP} exception match").order_by(Match.id.desc()).first()
check("exception match created", bool(m_exc))
if m_exc:
    created_matches.append(m_exc.id)
    check("reg_open_until_start persisted True", bool(m_exc.reg_open_until_start))
    check("exception match is registration-open",
          m_exc.registration_open, m_exc.registration_deadline)
    resp = admin_c.get(f"/admin/matches/{m_exc.id}/edit")
    html = resp.get_data(as_text=True)
    check("edit form shows option selected", 'value="1" selected' in html)
    with client.session_transaction() as s:
        s["user_id"] = users[2].id
    resp = client.get(f"/match/{m_exc.id}/join")
    html = resp.get_data(as_text=True)
    check("join page shows open-until-start notice", "right up to match start" in html)
    # flip back to default via edit
    resp = admin_c.post(f"/admin/matches/{m_exc.id}/edit", data={
        "_method": "PUT", "title": f"{STAMP} exception match", "mode_id": str(mode.id),
        "starts_at": start_iso, "entry_fee": "0", "prize_pool": "0", "capacity": "50",
        "status": "upcoming", "region": "global", "reg_open_until_start": "0",
    }, follow_redirects=True)
    check("admin edit 200", resp.status_code == 200, resp.status_code)
    db.session.expire_all()
    m_exc2 = db.session.get(Match, m_exc.id)
    check("reg_open_until_start flipped to False", not bool(m_exc2.reg_open_until_start))
    midnight = m_exc2.starts_at.replace(hour=0, minute=0, second=0, microsecond=0)
    check("deadline switched from start-time to midnight rule",
          m_exc2.registration_deadline == midnight, m_exc2.registration_deadline)
    check("far-future match still open under midnight rule",
          m_exc2.registration_open and midnight > datetime.utcnow())
    with client.session_transaction() as s:
        s["user_id"] = users[0].id

print("═" * 60)
print("G. Public surfaces show closed state")
print("═" * 60)
resp = client.get(f"/game/__{STAMP}_game")
html = resp.get_data(as_text=True)
check("game page 200", resp.status_code == 200)
check("game page shows closed badge for past match", "Registration Closed" in html)
check("game page hides join for closed match", html.count("join_now") == 0 or "Registration Closed" in html)

resp = client.get(f"/tournament/{m_past.id}")
html = resp.get_data(as_text=True)
check("tournament detail shows closed card", "Registration Closed" in html)

resp = client.get("/tournaments")
html = resp.get_data(as_text=True)
check("tournaments page 200", resp.status_code == 200)
# Same-day match: start later today -> listed as upcoming WITH closed badge
now = datetime.utcnow()
if (now + timedelta(minutes=45)).date() == now.date():
    m_today = mk_match(f"{STAMP} today closed", now.replace(second=0, microsecond=0) + timedelta(minutes=45))
    resp = client.get("/tournaments")
    html = resp.get_data(as_text=True)
    check("same-day match listed as upcoming", f"{STAMP} today closed" in html)
    seg = html.split(f"{STAMP} today closed", 1)[1][:1200]
    check("same-day upcoming card shows closed badge", "Registration Closed" in seg)
else:
    print("  · skip same-day badge check (near midnight UTC)")

# fa locale render
u0 = users[0]
u0.lang = "fa"
db.session.commit()
resp = client.get(f"/match/{m_past.id}/join")
html = resp.get_data(as_text=True)
check("fa closed banner translated", "ثبت‌نام بسته است" in html)
resp = client.get(f"/match/{m_open.id}/join")
html = resp.get_data(as_text=True)
check("fa each-pays hint present", "از کیف پول خودش" in html)
u0.lang = "en"
db.session.commit()

# ═══ cleanup ═══════════════════════════════════════════════════════
print("═" * 60)
print("Cleanup")
print("═" * 60)
uids = [u.id for u in users]
for mid in created_matches:
    m = db.session.get(Match, mid)
    if m:
        db.session.delete(m)
for tid in created_teams:
    t = db.session.get(Team, tid)
    if t:
        db.session.delete(t)
Transaction.query.filter(Transaction.user_id.in_(uids), Transaction.kind == "entry",
                         Transaction.ref.like(f"ENTRY-%")).delete(synchronize_session=False)
Notification.query.filter(Notification.user_id.in_(uids)).delete(synchronize_session=False)
for u in users:
    u.region = region_snapshot.get(u.id)
    set_balance(u.id, bal_snapshot[u.id])
db.session.delete(game)
db.session.commit()
print(f"\n════════ RESULT: {PASS} passed / {FAIL} failed ══════")
sys.exit(1 if FAIL else 0)
