#!/usr/bin/env python3
"""Task 14 — Registration window FLIPPED (opens at midnight) + rules-only gate.

User correction: "ثبت‌نام رأس ساعت ۱۲ شبِ قبل از روز مسابقه باید باز بشه نه بسته"
→ registration OPENS at 12 midnight (00:00) before/at the start of the match day
   (default), admin-selected matches are exempt (open right away), past matches
   are never registrable.  When registering, ONLY the rules are shown first and
   the admin writes those rules in the create/edit panel.

Covers:
  A. Model math (fake clock): not-yet-open → opens 00:00 of match day → open
     until start; admin exception open immediately; past/finished never
  B. HTTP: not-yet-open match blocked (GET amber banner + POST guard)
  B2. HTTP: past match blocked (GET banner + POST guard)
  C. Rules gate: POST join without rules_accepted rejected; with it → solo join
     debit + entry Transaction (exception match)
  D. Same-day default-rule match open (guarded near-midnight skip)
  E. Team join → every member pays separately (rules gate included)
  F. Admin create/edit persists rules text + registration-window option
  G. Public surfaces: sticky register bar, rules modal, rules card, badges, fa

Run:  python scripts/test_task14_registration.py
"""
import sys, os
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 = "t14"

# ─── 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, rules=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, rules=rules)
    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. Registration-window model math (fake clock) — FLIPPED")
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_opens_at,
                m.registration_not_yet_open)
    finally:
        mm_module.datetime = RealDT

base = datetime(2026, 9, 21, 15, 0, 0)
# 1) match in 5 days @18:00 → opens at 00:00 of that day → NOT open yet
op, opens, notyet = model_case(base, base.replace(microsecond=0) + timedelta(days=5, hours=3), False)
check("far-future default match NOT open yet", op is False)
check("not_yet_open flag True", notyet is True)
check("opens_at = 00:00 of match day", opens is not None and (opens.hour, opens.minute) == (0, 0), opens)
check("opens_at is match day", opens is not None and opens.date() == (base + timedelta(days=5)).date(), opens)
# 2) fake now = match day 08:00, match @18:00 → OPEN (after midnight gate)
match_day = base + timedelta(days=5)
op, opens, notyet = model_case(match_day.replace(hour=8), match_day.replace(hour=18, microsecond=0), False)
check("open after midnight on match day", op is True)
check("not_yet_open False once past midnight", notyet is False)
check("opens_at == 00:00 same day", opens == match_day.replace(hour=0, minute=0, second=0, microsecond=0), opens)
# 3) same-day tonight match (now 15:00, match 20:00) → OPEN
op, opens, notyet = model_case(base, base.replace(hour=20, minute=0, microsecond=0), False)
check("same-day match open (midnight already passed)", op is True)
check("opens_at = 00:00 today", opens == base.replace(hour=0, minute=0), opens)
# 4) match tomorrow 00:30 → opens tomorrow 00:00 → NOT open yet
tmr = base + timedelta(days=1)
op, opens, notyet = model_case(base, tmr.replace(hour=0, minute=30, microsecond=0), False)
check("tomorrow 00:30 match not open yet tonight", op is False and notyet is True)
check("opens_at = tomorrow 00:00", opens == tmr.replace(hour=0, minute=0, second=0, microsecond=0), opens)
# 5) admin exception → open immediately (opens_at None)
op, opens, notyet = model_case(base, tmr.replace(hour=0, minute=30, microsecond=0), True)
check("admin exception open right away", op is True)
check("exception has no opening gate", opens is None)
check("not_yet_open False with exception", notyet is False)
# 6) match starting exactly at midnight → no useful gate → open until start
op, opens, notyet = model_case(base, tmr.replace(hour=0, minute=0, microsecond=0), False)
check("midnight-start match not gated", op is True and opens is None)
# 7) past match NEVER registrable (even with exception)
op, opens, notyet = model_case(base, base - timedelta(hours=2), True)
check("past match closed even with exception", op is False)
check("past match not flagged not-yet-open", notyet is False)
# 8) finished/cancelled/live never registrable
for st in ("finished", "cancelled", "live"):
    op, _, _ = model_case(base, base + timedelta(days=5), False, status=st)
    check(f"{st} closed", op is False)

print("═" * 60)
print("B. Not-yet-open match blocked (GET banner + POST guard)")
print("═" * 60)
m_future = mk_match(f"{STAMP} future match", far_future + timedelta(hours=3), fee=10)  # default rule
client = app.test_client()
with client.session_transaction() as s:
    s["user_id"] = users[0].id

resp = client.get(f"/match/{m_future.id}/join")
html = resp.get_data(as_text=True)
check("future match join page 200", resp.status_code == 200)
check("not-open-yet banner shown", "Registration Not Open Yet" in html)
check("opens-at midnight line shown", "00:00" in html)
check("join section hidden", 'id="joinSection"' not in html)

resp = client.post(f"/match/{m_future.id}/join", data={"join_type": "solo", "rules_accepted": "1"}, follow_redirects=True)
html = resp.get_data(as_text=True)
check("not-yet-open POST blocked", "not open yet" in html)
check("no participant created", MatchParticipant.query.filter_by(match_id=m_future.id).count() == 0)

print("═" * 60)
print("B2. Past match blocked (GET banner + POST guard)")
print("═" * 60)
m_past = mk_match(f"{STAMP} past match", now - timedelta(hours=2), fee=10)
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", "rules_accepted": "1"}, follow_redirects=True)
html = resp.get_data(as_text=True)
check("past match POST blocked", "already started" in html)
check("no participant created (past)", MatchParticipant.query.filter_by(match_id=m_past.id).count() == 0)

print("═" * 60)
print("C. Rules gate + open exception match solo join → debit + tx")
print("═" * 60)
RULES_TEXT = "1. No smurf accounts.\n2. Room code 10 min before start.\n3. Disconnect > 5 min = forfeit."
m_open = mk_match(f"{STAMP} open match", far_future + timedelta(hours=3), fee=12,
                  open_until_start=True, rules=RULES_TEXT)

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("join section visible", 'id="joinSection"' in html)
check("rules card shows admin rules", "No smurf accounts" in html)
check("data-join-form gate present", "data-join-form" in html)
check("rules modal present", 'id="rulesModal"' in html)
check("open-until-start notice shown", "right up to match start" in html)

# gate: POST without rules_accepted must be rejected
tx_before = Transaction.query.filter_by(user_id=users[0].id, kind="entry").count()
resp = client.post(f"/match/{m_open.id}/join", data={"join_type": "solo"}, follow_redirects=True)
html = resp.get_data(as_text=True)
check("POST without rules_accepted rejected", "accept the match rules" in html)
check("no participant without rules gate", MatchParticipant.query.filter_by(match_id=m_open.id).count() == 0)
check("no entry tx without rules gate",
      Transaction.query.filter_by(user_id=users[0].id, kind="entry").count() == tx_before)

resp = client.post(f"/match/{m_open.id}/join", data={"join_type": "solo", "rules_accepted": "1"}, 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. Same-day default-rule match is open (guarded)")
print("═" * 60)
now = datetime.utcnow()
if now.hour >= 1 and now.hour <= 22:
    m_today = mk_match(f"{STAMP} today open", now.replace(second=0, microsecond=0) + timedelta(hours=2), fee=0)
    check("same-day match opens today 00:00",
          m_today.registration_opens_at == now.replace(hour=0, minute=0, second=0, microsecond=0),
          m_today.registration_opens_at)
    check("same-day match registration open now", m_today.registration_open)
    check("not flagged not-yet-open", not m_today.registration_not_yet_open)
else:
    print("  · skip same-day check (near midnight UTC)")

print("═" * 60)
print("E. Team join → each member pays separately (with rules gate)")
print("═" * 60)
m_team = mk_match(f"{STAMP} team match", far_future + timedelta(hours=4), fee=12, team_mode=True,
                  open_until_start=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)],
    "rules_accepted": "1",
}, 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)

# insufficient member → atomic block (no partial charge)
set_balance(users[1].id, 5)
db.session.commit()
m_team2 = mk_match(f"{STAMP} team match 2", far_future + timedelta(hours=5), fee=12, team_mode=True,
                   open_until_start=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)],
    "rules_accepted": "1",
}, 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 rules + window 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} admin 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",
    "rules": "Rule A from admin panel.\nRule B.",
}, follow_redirects=True)
check("admin create 200", resp.status_code == 200, resp.status_code)
m_exc = Match.query.filter_by(title=f"{STAMP} admin match").order_by(Match.id.desc()).first()
check("admin 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("rules persisted from panel", m_exc.rules == "Rule A from admin panel.\nRule B.", m_exc.rules)
    check("exception match is registration-open", m_exc.registration_open)
    check("exception opens_at None", m_exc.registration_opens_at is None)
    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)
    check("edit form prefills rules textarea", "Rule A from admin panel." in html)
    check("edit form has rules field", 'name="rules"' 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} admin 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",
        "rules": "Rule A updated.",
    }, 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("opens_at switched to midnight rule", m_exc2.registration_opens_at == midnight,
          m_exc2.registration_opens_at)
    check("far-future default match now NOT open yet", m_exc2.registration_not_yet_open)
    check("rules updated via edit", m_exc2.rules == "Rule A updated.", m_exc2.rules)

print("═" * 60)
print("G. Public surfaces: sticky bar, modal, badges, fa")
print("═" * 60)
# tournament detail of open exception match → sticky bar + join button + modal
resp = client.get(f"/tournament/{m_open.id}")
html = resp.get_data(as_text=True)
check("tournament detail 200", resp.status_code == 200)
check("sticky register bar present", 'id="tdxStickyBar"' in html)
check("sticky bar has register button", "openRulesModal()" in html)
check("rules-only modal present", 'id="rulesModal"' in html)
check("modal agree checkbox", 'id="rulesAgree"' in html)
check("sidebar rules card shows admin rules", "No smurf accounts" in html)
check("no direct join link (modal instead)", 'href="/match/%d/join"' % m_open.id not in html or "openRulesModal" in html)

# not-yet-open detail → amber state with opens date
resp = client.get(f"/tournament/{m_future.id}")
html = resp.get_data(as_text=True)
check("future match detail shows not-open-yet", "Registration Not Open Yet" in html)
check("future match detail shows opens date 00:00", "00:00" in html)

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 shows not-open-yet badge", "Registration Not Open Yet" in html)

resp = client.get("/tournaments")
html = resp.get_data(as_text=True)
check("tournaments page 200", resp.status_code == 200)
check("tournaments page shows not-open-yet badge", "Registration Not Open Yet" in html)

# fa locale render
u0 = users[0]
u0.lang = "fa"
db.session.commit()
resp = client.get(f"/match/{m_future.id}/join")
html = resp.get_data(as_text=True)
check("fa not-open-yet banner translated", "ثبت‌نام هنوز باز نشده است" in html)
check("fa opens-at-midnight line", "رأس ساعت ۱۲ شب" in html)
resp = client.get(f"/tournament/{m_open.id}")
html = resp.get_data(as_text=True)
check("fa rules modal title", "قوانین مسابقه" in html)
check("fa rules agree", "قوانین را خوانده‌ام و می‌پذیرم" in html)
check("fa sticky bar present (RTL)", 'id="tdxStickyBar"' 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)
check("fa admin rules rendered", "بدون اکانت جعلی" not in html and "No smurf accounts" 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)
