from dotenv import load_dotenv
from pathlib import Path
ROOT_DIR = Path(__file__).parent
load_dotenv(ROOT_DIR / '.env')

import os
import asyncio
import uuid
import logging
import secrets as pysecrets
from datetime import datetime, timezone, timedelta
from typing import Optional, List, Annotated, Any

import bcrypt
import jwt
import requests
import stripe
from bson import ObjectId
from fastapi import FastAPI, APIRouter, HTTPException, Request, Response, Depends, UploadFile, File, Form
from starlette.middleware.cors import CORSMiddleware
from motor.motor_asyncio import AsyncIOMotorClient
from pydantic import BaseModel, Field, BeforeValidator, EmailStr, ConfigDict

from setup_stripe import setup as setup_stripe_catalog
from tts import synthesize
from vps_store import DiskStore

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

client = AsyncIOMotorClient(os.environ['MONGO_URL'])
db = client[os.environ['DB_NAME']]

stripe.api_key = os.environ.get("STRIPE_SECRET_KEY") or "sk_test_emergent"
STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "")
GAME_PRICE = 150.0
LOOKUP_KEY = "adjuster_city_lifetime"
JWT_ALGORITHM = "HS256"

app = FastAPI()
api = APIRouter(prefix="/api")

# ---------- Mongo helpers ----------
PyObjectId = Annotated[str, BeforeValidator(lambda v: str(v) if isinstance(v, ObjectId) else v)]


class BaseDocument(BaseModel):
    model_config = ConfigDict(populate_by_name=True, extra="ignore")
    id: Optional[PyObjectId] = Field(default=None, alias="_id")

    @classmethod
    def from_mongo(cls, doc):
        return cls(**doc) if doc else None

    def to_mongo(self):
        d = self.model_dump(by_alias=True, exclude_none=True)
        d.pop("_id", None)
        return d


class User(BaseDocument):
    email: str
    name: str = ""
    role: str = "user"
    has_access: bool = False
    access_source: Optional[str] = None
    created_at: Optional[str] = None


class Coupon(BaseDocument):
    code: str
    percent_off: float = 0
    max_uses: int = 0
    uses: int = 0
    active: bool = True
    note: str = ""
    created_at: Optional[str] = None


class Track(BaseDocument):
    title: str
    artist: str = ""
    storage_path: str
    content_type: str
    created_at: Optional[str] = None


def oid(v: str) -> ObjectId:
    if not ObjectId.is_valid(v):
        raise HTTPException(404, "Not found")
    return ObjectId(v)


def now_iso():
    return datetime.now(timezone.utc).isoformat()


# ---------- Object storage ----------
STORAGE_URL = ((os.environ.get("INTEGRATION_PROXY_URL") or "").strip() or "https://integrations.emergentagent.com").rstrip("/") + "/objstore/api/v1/storage"
APP_NAME = "adjuster-city"
_storage_key = None


_disk = DiskStore(os.environ["LOCAL_STORAGE_DIR"]) if os.environ.get("LOCAL_STORAGE_DIR") else None


def init_storage(force=False):
    global _storage_key
    if _storage_key and not force:
        return _storage_key
    r = requests.post(f"{STORAGE_URL}/init", json={"emergent_key": os.environ.get("EMERGENT_LLM_KEY")}, timeout=30)
    r.raise_for_status()
    _storage_key = r.json()["storage_key"]
    return _storage_key


def put_object(path, data, content_type):
    if _disk:
        return _disk.put(path, data, content_type)
    r = requests.put(f"{STORAGE_URL}/objects/{path}", headers={"X-Storage-Key": init_storage(), "Content-Type": content_type}, data=data, timeout=180)
    if r.status_code == 404:
        r = requests.put(f"{STORAGE_URL}/objects/{path}", headers={"X-Storage-Key": init_storage(True), "Content-Type": content_type}, data=data, timeout=180)
    r.raise_for_status()
    return r.json()


def get_object(path):
    if _disk:
        return _disk.get(path)
    r = requests.get(f"{STORAGE_URL}/objects/{path}", headers={"X-Storage-Key": init_storage()}, timeout=120)
    r.raise_for_status()
    return r.content, r.headers.get("Content-Type", "application/octet-stream")


# ---------- Auth ----------
def hash_password(p):
    return bcrypt.hashpw(p.encode(), bcrypt.gensalt()).decode()


def verify_password(p, h):
    return bcrypt.checkpw(p.encode(), h.encode())


def create_access_token(uid, email):
    return jwt.encode({"sub": uid, "email": email, "type": "access", "exp": datetime.now(timezone.utc) + timedelta(hours=12)}, os.environ["JWT_SECRET"], algorithm=JWT_ALGORITHM)


def create_refresh_token(uid):
    return jwt.encode({"sub": uid, "type": "refresh", "exp": datetime.now(timezone.utc) + timedelta(days=7)}, os.environ["JWT_SECRET"], algorithm=JWT_ALGORITHM)


def set_auth_cookies(response: Response, uid, email):
    access = create_access_token(uid, email)
    response.set_cookie("access_token", access, httponly=True, secure=True, samesite="none", max_age=43200, path="/")
    response.set_cookie("refresh_token", create_refresh_token(uid), httponly=True, secure=True, samesite="none", max_age=604800, path="/")
    return access


async def get_current_user(request: Request) -> User:
    token = request.cookies.get("access_token")
    auth = request.headers.get("Authorization", "")
    if auth.startswith("Bearer "):
        token = auth[7:]
    if not token:
        raise HTTPException(401, "Not authenticated")
    try:
        payload = jwt.decode(token, os.environ["JWT_SECRET"], algorithms=[JWT_ALGORITHM])
    except jwt.ExpiredSignatureError:
        raise HTTPException(401, "Token expired")
    except jwt.InvalidTokenError:
        raise HTTPException(401, "Invalid token")
    if payload.get("type") != "access":
        raise HTTPException(401, "Invalid token type")
    doc = await db.users.find_one({"_id": ObjectId(payload["sub"])})
    if not doc:
        raise HTTPException(401, "User not found")
    return User.from_mongo(doc)


async def require_access(user: User = Depends(get_current_user)) -> User:
    if not (user.has_access or user.role == "admin"):
        raise HTTPException(403, "Game access required")
    return user


async def require_admin(user: User = Depends(get_current_user)) -> User:
    if user.role != "admin":
        raise HTTPException(403, "Admin only")
    return user


class RegisterIn(BaseModel):
    email: EmailStr
    password: str = Field(min_length=6)
    name: str = ""


class LoginIn(BaseModel):
    email: EmailStr
    password: str


@api.post("/auth/register")
async def register(body: RegisterIn, response: Response):
    email = body.email.lower()
    if await db.users.find_one({"email": email}):
        raise HTTPException(400, "An account with this email already exists. Please log in.")
    doc = User(email=email, name=body.name, created_at=now_iso()).to_mongo()
    doc["password_hash"] = hash_password(body.password)
    res = await db.users.insert_one(doc)
    user = User.from_mongo(await db.users.find_one({"_id": res.inserted_id}))
    token = set_auth_cookies(response, user.id, email)
    return {"user": user.model_dump(), "token": token}


@api.post("/auth/login")
async def login(body: LoginIn, request: Request, response: Response):
    email = body.email.lower()
    ident = email
    attempt = await db.login_attempts.find_one({"identifier": ident})
    if attempt and attempt.get("count", 0) >= 5 and attempt.get("locked_until", "") > now_iso():
        raise HTTPException(429, "Too many attempts. Try again in 15 minutes.")
    doc = await db.users.find_one({"email": email})
    if not doc or not verify_password(body.password, doc["password_hash"]):
        count = (attempt or {}).get("count", 0) + 1
        await db.login_attempts.update_one({"identifier": ident}, {"$set": {"count": count, "locked_until": (datetime.now(timezone.utc) + timedelta(minutes=15)).isoformat()}}, upsert=True)
        raise HTTPException(401, "Invalid email or password")
    await db.login_attempts.delete_one({"identifier": ident})
    user = User.from_mongo(doc)
    token = set_auth_cookies(response, user.id, email)
    return {"user": user.model_dump(), "token": token}


@api.post("/auth/logout")
async def logout(response: Response):
    response.delete_cookie("access_token", path="/", secure=True, samesite="none")
    response.delete_cookie("refresh_token", path="/", secure=True, samesite="none")
    return {"ok": True}


@api.get("/auth/me")
async def me(user: User = Depends(get_current_user)):
    return user.model_dump()


@api.post("/auth/refresh")
async def refresh(request: Request, response: Response):
    token = request.cookies.get("refresh_token")
    if not token:
        raise HTTPException(401, "No refresh token")
    try:
        payload = jwt.decode(token, os.environ["JWT_SECRET"], algorithms=[JWT_ALGORITHM])
    except jwt.InvalidTokenError:
        raise HTTPException(401, "Invalid refresh token")
    doc = await db.users.find_one({"_id": ObjectId(payload["sub"])})
    if not doc:
        raise HTTPException(401, "User not found")
    return {"token": set_auth_cookies(response, str(doc["_id"]), doc["email"])}


# ---------- Coupons & payments ----------
async def find_valid_coupon(code: Optional[str]) -> Optional[Coupon]:
    if not code:
        return None
    c = Coupon.from_mongo(await db.coupons.find_one({"code": code.strip().upper()}))
    if not c or not c.active or (c.max_uses and c.uses >= c.max_uses):
        raise HTTPException(400, "This coupon is invalid or has expired")
    return c


def price_after(c: Optional[Coupon]) -> float:
    return round(max(0.0, GAME_PRICE * (1 - (c.percent_off if c else 0) / 100)), 2)


@api.get("/pricing")
async def pricing(coupon: Optional[str] = None):
    c = await find_valid_coupon(coupon)
    return {"base": GAME_PRICE, "final": price_after(c), "coupon": c.code if c else None, "percent_off": c.percent_off if c else 0}


class CheckoutIn(BaseModel):
    origin_url: str
    coupon_code: Optional[str] = None


async def grant_access(user_id: str, source: str):
    await db.users.update_one({"_id": ObjectId(user_id)}, {"$set": {"has_access": True, "access_source": source}})


@api.post("/payments/checkout")
async def checkout(body: CheckoutIn, user: User = Depends(get_current_user)):
    if user.has_access:
        return {"already_has_access": True}
    c = await find_valid_coupon(body.coupon_code)
    final = price_after(c)
    if final <= 0:
        await grant_access(user.id, f"coupon:{c.code}")
        await db.coupons.update_one({"code": c.code}, {"$inc": {"uses": 1}})
        return {"free": True}
    prices = stripe.Price.list(lookup_keys=[LOOKUP_KEY], active=True, limit=1).data
    if not prices:
        raise HTTPException(500, "Price not configured")
    price = prices[0]
    line = {"price": price.id, "quantity": 1} if not c else {"price_data": {"currency": "usd", "product": price.product, "unit_amount": int(round(final * 100))}, "quantity": 1}
    kwargs = dict(line_items=[line], mode="payment", customer_email=user.email,
                  success_url=f"{body.origin_url}/payment/success?session_id={{CHECKOUT_SESSION_ID}}",
                  cancel_url=f"{body.origin_url}/payment/cancel",
                  metadata={"user_id": user.id, "coupon": c.code if c else ""})
    try:
        session = stripe.checkout.Session.create(**kwargs, managed_payments={"enabled": True})
    except stripe.error.InvalidRequestError as e:
        msg = (e.user_message or str(e)).lower()
        if "managed" in msg or "ineligible" in msg:
            session = stripe.checkout.Session.create(**kwargs, automatic_tax={"enabled": True}, billing_address_collection="required")
        else:
            raise HTTPException(400, e.user_message or "Checkout failed")
    await db.payment_transactions.insert_one({
        "session_id": session.id, "user_id": user.id, "email": user.email, "coupon": c.code if c else None,
        "amount": float(final), "currency": "usd", "status": "initiated", "payment_status": "pending",
        "created_at": now_iso(), "updated_at": now_iso()})
    return {"checkout_url": session.url, "session_id": session.id}


async def mark_paid(session_id: str, pi=None):
    res = await db.payment_transactions.find_one_and_update(
        {"session_id": session_id, "payment_status": {"$ne": "paid"}},
        {"$set": {"status": "completed", "payment_status": "paid", "stripe_payment_intent_id": pi, "updated_at": now_iso()}})
    if res:
        await grant_access(res["user_id"], "stripe")
        if res.get("coupon"):
            await db.coupons.update_one({"code": res["coupon"]}, {"$inc": {"uses": 1}})


@api.get("/payments/status/{session_id}")
async def payment_status(session_id: str):
    rec = await db.payment_transactions.find_one({"session_id": session_id})
    if not rec:
        raise HTTPException(404, "Transaction not found")
    if rec.get("payment_status") != "paid":
        try:
            s = stripe.checkout.Session.retrieve(session_id)
            if s.payment_status == "paid" or s.status == "complete":
                await mark_paid(session_id, s.payment_intent)
                rec = await db.payment_transactions.find_one({"session_id": session_id})
        except stripe.error.StripeError:
            pass
    return {"session_id": rec["session_id"], "status": rec["status"], "payment_status": rec["payment_status"]}


@api.post("/stripe/webhook")
async def stripe_webhook(request: Request):
    payload = await request.body()
    try:
        event = stripe.Webhook.construct_event(payload, request.headers.get("stripe-signature", ""), STRIPE_WEBHOOK_SECRET)
    except (stripe.error.SignatureVerificationError, ValueError):
        raise HTTPException(400, "Invalid signature")
    obj, t = event["data"]["object"], event["type"]
    if t in ("checkout.session.completed", "checkout.session.async_payment_succeeded") and obj.get("payment_status") == "paid":
        await mark_paid(obj["id"], obj.get("payment_intent"))
    elif t == "checkout.session.async_payment_failed":
        await db.payment_transactions.update_one({"session_id": obj["id"]}, {"$set": {"status": "failed", "payment_status": "failed", "updated_at": now_iso()}})
    elif t == "checkout.session.expired":
        await db.payment_transactions.update_one({"session_id": obj["id"], "payment_status": {"$ne": "paid"}}, {"$set": {"status": "expired", "payment_status": "expired", "updated_at": now_iso()}})
    elif t == "charge.refunded":
        await db.payment_transactions.update_one({"stripe_payment_intent_id": obj.get("payment_intent")}, {"$set": {"status": "refunded", "payment_status": "refunded", "updated_at": now_iso()}})
    return {"status": "ok"}


# ---------- Progress ----------
class ProgressIn(BaseModel):
    character: Optional[str] = None
    xp: Optional[int] = None
    cash: Optional[int] = None
    completed_missions: Optional[List[str]] = None
    vocab_mastered: Optional[List[str]] = None
    exam_scores: Optional[List[dict]] = None
    chapters_read: Optional[List[str]] = None
    calc_solved: Optional[List[str]] = None
    missed_questions: Optional[List[str]] = None


@api.get("/progress")
async def get_progress(user: User = Depends(require_access)):
    doc = await db.progress.find_one({"user_id": user.id}, {"_id": 0}) or {}
    return {"xp": 0, "cash": 500, "completed_missions": [], "vocab_mastered": [], "exam_scores": [], "chapters_read": [], "calc_solved": [], "missed_questions": [], "character": None, **doc}


@api.put("/progress")
async def put_progress(body: ProgressIn, user: User = Depends(require_access)):
    data = {k: v for k, v in body.model_dump().items() if v is not None}
    prev = await db.progress.find_one({"user_id": user.id}) or {}
    if "xp" in data and data["xp"] - prev.get("xp", 0) > 1500:
        raise HTTPException(400, "Invalid progress update")
    if "exam_scores" in data:
        data["exam_scores"] = [s for s in data["exam_scores"] if isinstance(s.get("pct"), (int, float)) and 0 <= s["pct"] <= 100][-20:]
    data["updated_at"] = now_iso()
    await db.progress.update_one({"user_id": user.id}, {"$set": data}, upsert=True)
    return await get_progress(user)


# ---------- Music tracks ----------
@api.get("/tracks")
async def list_tracks(user: User = Depends(require_access)):
    docs = await db.tracks.find({"is_deleted": {"$ne": True}}).sort("created_at", 1).to_list(500)
    return [Track.from_mongo(d).model_dump() for d in docs]


@api.get("/tracks/{track_id}/stream")
async def stream_track(track_id: str, user: User = Depends(require_access)):
    d = await db.tracks.find_one({"_id": oid(track_id), "is_deleted": {"$ne": True}})
    if not d:
        raise HTTPException(404, "Track not found")
    data, _ = get_object(d["storage_path"])
    return Response(content=data, media_type=d["content_type"], headers={"Cache-Control": "private, max-age=3600"})


# ---------- Site assets (logos) ----------
LOGO_KINDS = {"adjuster_city", "revcreation"}


@api.get("/site/logos")
async def site_logos():
    docs = await db.site_assets.find({"kind": {"$in": list(LOGO_KINDS)}}).to_list(10)
    return {d["kind"]: f"/api/site/logo/{d['kind']}?v={d['version']}" for d in docs}


@api.get("/site/logo/{kind}")
async def site_logo(kind: str):
    d = await db.site_assets.find_one({"kind": kind})
    if not d:
        raise HTTPException(404, "No logo")
    data, _ = get_object(d["storage_path"])
    return Response(content=data, media_type=d["content_type"], headers={"Cache-Control": "public, max-age=86400"})


# ---------- Admin ----------
class AdminUserIn(BaseModel):
    email: EmailStr
    password: str = Field(min_length=6)
    name: str = ""
    has_access: bool = True
    role: str = "user"


class AdminUserPatch(BaseModel):
    has_access: Optional[bool] = None
    password: Optional[str] = None


class CouponIn(BaseModel):
    code: str = Field(min_length=3, max_length=32)
    percent_off: float = Field(ge=1, le=100)
    max_uses: int = Field(0, ge=0)
    note: str = ""


@api.get("/admin/stats")
async def admin_stats(_: User = Depends(require_admin)):
    paid = await db.payment_transactions.find({"payment_status": "paid"}).to_list(10000)
    return {"users": await db.users.count_documents({}), "players_with_access": await db.users.count_documents({"has_access": True}),
            "paid_orders": len(paid), "revenue": round(sum(p.get("amount", 0) for p in paid), 2),
            "coupons": await db.coupons.count_documents({}), "tracks": await db.tracks.count_documents({"is_deleted": {"$ne": True}})}


@api.get("/admin/users")
async def admin_users(_: User = Depends(require_admin)):
    docs = await db.users.find({}).sort("created_at", -1).to_list(2000)
    return [User.from_mongo(d).model_dump() for d in docs]


@api.post("/admin/users")
async def admin_create_user(body: AdminUserIn, _: User = Depends(require_admin)):
    email = body.email.lower()
    if await db.users.find_one({"email": email}):
        raise HTTPException(400, "User already exists")
    doc = User(email=email, name=body.name, role="admin" if body.role == "admin" else "user", has_access=body.has_access,
               access_source="admin" if body.has_access else None, created_at=now_iso()).to_mongo()
    doc["password_hash"] = hash_password(body.password)
    res = await db.users.insert_one(doc)
    return User.from_mongo(await db.users.find_one({"_id": res.inserted_id})).model_dump()


@api.patch("/admin/users/{uid}")
async def admin_patch_user(uid: str, body: AdminUserPatch, _: User = Depends(require_admin)):
    upd = {}
    if body.has_access is not None:
        upd.update({"has_access": body.has_access, "access_source": "admin" if body.has_access else None})
    if body.password:
        upd["password_hash"] = hash_password(body.password)
    await db.users.update_one({"_id": oid(uid)}, {"$set": upd})
    return User.from_mongo(await db.users.find_one({"_id": oid(uid)})).model_dump()


@api.delete("/admin/users/{uid}")
async def admin_delete_user(uid: str, admin: User = Depends(require_admin)):
    if uid == admin.id:
        raise HTTPException(400, "You can't delete yourself")
    await db.users.delete_one({"_id": oid(uid)})
    return {"ok": True}


@api.get("/admin/coupons")
async def admin_coupons(_: User = Depends(require_admin)):
    docs = await db.coupons.find({}).sort("created_at", -1).to_list(1000)
    return [Coupon.from_mongo(d).model_dump() for d in docs]


@api.post("/admin/coupons")
async def admin_create_coupon(body: CouponIn, _: User = Depends(require_admin)):
    code = body.code.strip().upper()
    if await db.coupons.find_one({"code": code}):
        raise HTTPException(400, "Coupon code already exists")
    res = await db.coupons.insert_one(Coupon(code=code, percent_off=body.percent_off, max_uses=body.max_uses, note=body.note, created_at=now_iso()).to_mongo())
    return Coupon.from_mongo(await db.coupons.find_one({"_id": res.inserted_id})).model_dump()


@api.patch("/admin/coupons/{cid}")
async def admin_toggle_coupon(cid: str, _: User = Depends(require_admin)):
    d = await db.coupons.find_one({"_id": oid(cid)})
    if not d:
        raise HTTPException(404, "Not found")
    await db.coupons.update_one({"_id": d["_id"]}, {"$set": {"active": not d.get("active", True)}})
    return Coupon.from_mongo(await db.coupons.find_one({"_id": d["_id"]})).model_dump()


@api.delete("/admin/coupons/{cid}")
async def admin_delete_coupon(cid: str, _: User = Depends(require_admin)):
    await db.coupons.delete_one({"_id": oid(cid)})
    return {"ok": True}


@api.get("/admin/payments")
async def admin_payments(_: User = Depends(require_admin)):
    return await db.payment_transactions.find({}, {"_id": 0}).sort("created_at", -1).to_list(2000)


AUDIO_TYPES = {"mp3": "audio/mpeg", "wav": "audio/wav", "m4a": "audio/mp4", "ogg": "audio/ogg", "aac": "audio/aac"}
IMAGE_TYPES = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "webp": "image/webp", "svg": "image/svg+xml"}


@api.post("/admin/tracks")
async def admin_upload_track(file: UploadFile = File(...), title: str = Form(...), artist: str = Form(""), admin: User = Depends(require_admin)):
    ext = (file.filename or "").rsplit(".", 1)[-1].lower()
    if ext not in AUDIO_TYPES:
        raise HTTPException(400, "Upload an mp3, wav, m4a, ogg or aac file")
    data = await file.read()
    if len(data) > 25 * 1024 * 1024:
        raise HTTPException(400, "Max 25MB per track")
    result = put_object(f"{APP_NAME}/tracks/{uuid.uuid4()}.{ext}", data, AUDIO_TYPES[ext])
    res = await db.tracks.insert_one({**Track(title=title, artist=artist, storage_path=result["path"], content_type=AUDIO_TYPES[ext], created_at=now_iso()).to_mongo(), "is_deleted": False})
    return Track.from_mongo(await db.tracks.find_one({"_id": res.inserted_id})).model_dump()


@api.delete("/admin/tracks/{tid}")
async def admin_delete_track(tid: str, _: User = Depends(require_admin)):
    await db.tracks.update_one({"_id": oid(tid)}, {"$set": {"is_deleted": True}})
    return {"ok": True}


@api.post("/admin/logos/{kind}")
async def admin_upload_logo(kind: str, file: UploadFile = File(...), _: User = Depends(require_admin)):
    if kind not in LOGO_KINDS:
        raise HTTPException(400, "Unknown logo")
    ext = (file.filename or "").rsplit(".", 1)[-1].lower()
    if ext not in IMAGE_TYPES:
        raise HTTPException(400, "Upload a png, jpg, webp or svg")
    result = put_object(f"{APP_NAME}/logos/{kind}-{uuid.uuid4()}.{ext}", await file.read(), IMAGE_TYPES[ext])
    await db.site_assets.update_one({"kind": kind}, {"$set": {"storage_path": result["path"], "content_type": IMAGE_TYPES[ext], "version": uuid.uuid4().hex[:8]}}, upsert=True)
    return await site_logos()


@api.delete("/admin/logos/{kind}")
async def admin_reset_logo(kind: str, _: User = Depends(require_admin)):
    await db.site_assets.delete_one({"kind": kind})
    return await site_logos()


VOICES = {"alloy", "ash", "coral", "echo", "fable", "nova", "onyx", "sage", "shimmer"}


class VoiceIn(BaseModel):
    text: str = Field(min_length=1, max_length=700)
    voice: str = "onyx"


@api.post("/voice")
async def voice_line(body: VoiceIn, _: User = Depends(require_access)):
    import hashlib
    voice = body.voice if body.voice in VOICES else "onyx"
    key = hashlib.sha256(f"{voice}|{body.text}".encode()).hexdigest()
    hit = await db.voice_cache.find_one({"key": key}); data = None
    if hit:
        try:
            data, _ct = await asyncio.to_thread(get_object, hit["storage_path"])
        except Exception:
            data = None
    if data is None:
        day = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        usage = await db.voice_usage.find_one_and_update({"user_id": _.id, "day": day}, {"$inc": {"n": 1}}, upsert=True, return_document=True)
        if usage and usage.get("n", 0) > 300:
            raise HTTPException(429, "Daily voice limit reached")
        try:
            data = await synthesize(body.text, voice)
        except Exception as e:
            logger.error(f"TTS failed: {e}")
            raise HTTPException(503, "Voice unavailable right now")
        res = await asyncio.to_thread(put_object, f"{APP_NAME}/voice/{key}.mp3", data, "audio/mpeg")
        await db.voice_cache.update_one({"key": key}, {"$set": {"key": key, "storage_path": res["path"], "created_at": now_iso()}}, upsert=True)
    return Response(content=data, media_type="audio/mpeg", headers={"Cache-Control": "private, max-age=86400"})


@api.get("/leaderboard")
async def leaderboard(user: User = Depends(require_access)):
    users = {str(u["_id"]): u for u in await db.users.find({"$or": [{"has_access": True}, {"role": "admin"}]}).to_list(5000)}
    rows = []
    progs = {p["user_id"]: p for p in await db.progress.find({"user_id": {"$in": list(users)}}).to_list(5000)}
    for uid, u in users.items():
        p = progs.get(uid, {"user_id": uid})
        scores = [s.get("pct", 0) for s in p.get("exam_scores", []) if s.get("label") == "full-exam"]
        rows.append({"name": u.get("name") or u["email"].split("@")[0], "character": p.get("character"), "xp": p.get("xp", 0),
                     "best_exam": max(scores) if scores else None, "cases": len(p.get("completed_missions", [])),
                     "vocab": len(p.get("vocab_mastered", [])), "is_you": p["user_id"] == user.id})
    return rows


@api.get("/")
async def root():
    return {"message": "Adjuster City API"}


app.include_router(api)
app.add_middleware(CORSMiddleware, allow_credentials=True, allow_origin_regex=".*", allow_methods=["*"], allow_headers=["*"])


async def seed_user(email, password, name, role, has_access):
    if email and password and not await db.users.find_one({"email": email.lower()}):
        doc = User(email=email.lower(), name=name, role=role, has_access=has_access, access_source="seed", created_at=now_iso()).to_mongo()
        doc["password_hash"] = hash_password(password)
        await db.users.insert_one(doc)


@app.on_event("startup")
async def startup():
    await db.users.create_index("email", unique=True)
    await db.coupons.create_index("code", unique=True)
    await db.login_attempts.create_index("identifier")
    await db.payment_transactions.create_index("session_id")
    await db.progress.create_index("user_id", unique=True)
    await db.voice_cache.create_index("key", unique=True)
    await seed_user(os.environ.get("ADMIN_EMAIL"), os.environ.get("ADMIN_PASSWORD"), "Tiffany", "admin", True)
    await seed_user(os.environ.get("KNIJA_EMAIL"), os.environ.get("KNIJA_PASSWORD"), "Knija", "user", True)
    try:
        setup_stripe_catalog()
    except Exception as e:
        logger.error(f"Stripe catalog setup failed: {e}")
    try:
        init_storage()
    except Exception as e:
        logger.error(f"Storage init failed: {e}")


@app.on_event("shutdown")
async def shutdown():
    client.close()
