"""
Smile.One Web App - Scraper UI + Auto Order + User Wallet
"""

import hashlib
import json
import threading
from decimal import Decimal, ROUND_HALF_UP
import time
import uuid
from pathlib import Path
import os

from flask import Flask, render_template, jsonify, request, session, redirect, url_for
from werkzeug.utils import secure_filename
from scraper import SmileOneScraper
from config import EXTRA_HOME_MERCHANTS, REGIONS
from config import DEFAULT_MARKUP_PCT
from config import ORDER_NODE_SUBPROCESS_TIMEOUT_SEC, ORDER_THREAD_JOIN_TIMEOUT_SEC
from config import (
    ENABLE_HSTS,
    FORCE_HTTPS,
    PUBLIC_SITE_URL,
    RATELIMIT_DEFAULT_API_PER_MINUTE,
    RATELIMIT_ME_PER_MINUTE,
    RATELIMIT_ORDER_PER_MINUTE,
    RATELIMIT_ORDER_STATUS_PER_MINUTE,
    RATELIMIT_PRODUCT_DETAILS_PER_MINUTE,
)
from currency import (
    parse_price_to_brl_cents,
    parse_php_amount_from_text,
    php_amount_to_mmk_str,
    brl_cents_to_mmk_str,
    invalidate_brl_fx_cache,
)
from package_display import (
    dedupe_packages_by_brl_cents,
    dedupe_packages_by_php_cents,
    effective_page_title,
    is_spurious_package_label,
    is_trash_page_title,
    mlbb_drop_plain_duplicate_at_same_price,
    package_name_to_english,
    sanitize_merchant_display_name,
    strip_shared_page_title_prefix,
)
from scraper_playwright import run_game_packages_cached
from manual_account_fields import get_manual_fields_for_url
import models

try:
    from dotenv import load_dotenv
    load_dotenv(Path(__file__).parent / ".env")
except Exception:
    pass

app = Flask(__name__)
app.secret_key = os.getenv("FLASK_SECRET_KEY", "dev-only-change-me")
app.config["MAX_CONTENT_LENGTH"] = int(
    os.getenv("MAX_UPLOAD_MB", "512").strip() or "512"
) * 1024 * 1024
# Ensure SQLite migrations run (e.g. api_key_hash on users) even if models was imported earlier
models.init_db()

from social_boosting_routes import social_boosting_bp

app.register_blueprint(social_boosting_bp)

DATA_FILE = Path(__file__).parent / "smile_one_data.json"
AUTH_FILE = Path(__file__).parent / "smile_auth.json"
UPLOAD_DIR = Path(__file__).parent / "static" / "uploads"
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)


def _admin_token() -> str:
    return (os.getenv("ADMIN_TOKEN") or "").strip()


def is_admin() -> bool:
    return bool(session.get("is_admin") is True)


def require_admin() -> bool:
    if is_admin():
        return True
    return False


def current_user() -> dict | None:
    uid = session.get("user_id")
    if uid:
        return models.get_user(uid)
    return None


def _extract_api_key_from_request() -> str | None:
    """Bearer token, X-API-Key header, or ?api_key= for programmatic access."""
    auth = (request.headers.get("Authorization") or "").strip()
    if auth.lower().startswith("bearer "):
        return auth[7:].strip() or None
    x = request.headers.get("X-API-Key") or request.headers.get("X-Api-Key")
    if x:
        return x.strip()
    return (request.args.get("api_key") or "").strip() or None


def current_user_session_or_api_key() -> dict | None:
    """Logged-in browser session OR valid API key (same user endpoints)."""
    u = current_user()
    if u:
        return u
    key = _extract_api_key_from_request()
    if key:
        return models.get_user_by_api_key(key)
    return None


def _audit_key_hint() -> str:
    k = _extract_api_key_from_request()
    if k:
        return hashlib.sha256(k.encode("utf-8")).hexdigest()[:12]
    u = current_user()
    if u:
        return f"s:{u['id']}"
    return ""


def _rate_limit_key() -> str:
    u = current_user_session_or_api_key()
    if u:
        return f"uid:{u['id']}"
    try:
        from flask_limiter.util import get_remote_address

        return get_remote_address()
    except Exception:
        return request.remote_addr or "0.0.0.0"


try:
    from flask_limiter import Limiter

    _limiter = Limiter(
        app=app,
        key_func=_rate_limit_key,
        storage_uri="memory://",
        default_limits=[],
    )
except ImportError:
    _limiter = None


def _apply_rate_limit(spec: str):
    def deco(f):
        if _limiter is not None:
            return _limiter.limit(spec)(f)
        return f

    return deco


_order_by_user: dict[int, dict] = {}


def _format_order_progress_line(raw: str) -> str:
    """Single stable status for running orders (avoids step-by-step text in the UI)."""
    return "Placing order…"


def _json_from_memory_order_state(st: dict) -> dict:
    oid = st.get("order_id")
    status = st.get("status", "idle")
    if status == "running":
        return {
            "status": "running",
            "message": st.get("message", "Placing order…"),
            "order_id": oid,
            "step": st.get("step", "running"),
        }
    if status == "done":
        ok = st.get("ok", False)
        msg = st.get("message", "")
        return {
            "status": "done",
            "ok": ok,
            "message": msg if ok else None,
            "error": None if ok else msg,
            "step": st.get("step", ""),
            "order_id": oid,
        }
    return {"status": "idle", "order_id": oid}


def _order_status_payload_from_db(row: dict) -> dict:
    oid = row["id"]
    s = (row.get("status") or "").strip().lower()
    if s == "processing":
        return {
            "status": "running",
            "message": "Order in progress...",
            "order_id": oid,
        }
    if s == "completed":
        msg = row.get("smile_message") or ""
        return {
            "status": "done",
            "ok": True,
            "message": msg or None,
            "error": None,
            "step": row.get("smile_step") or "",
            "order_id": oid,
        }
    msg = row.get("smile_message") or "Order failed."
    return {
        "status": "done",
        "ok": False,
        "message": None,
        "error": msg,
        "step": row.get("smile_step") or "",
        "order_id": oid,
    }


def _is_probably_local_host() -> bool:
    h = (request.host or "").split(":")[0].lower()
    return h in ("127.0.0.1", "localhost", "::1")


@app.before_request
def _maybe_force_https():
    if not FORCE_HTTPS or _is_probably_local_host():
        return None
    if getattr(request, "is_secure", False):
        return None
    if (request.headers.get("X-Forwarded-Proto") or "").lower() == "https":
        return None
    return redirect(request.url.replace("http://", "https://", 1), code=301)


@app.after_request
def _security_headers_and_api_audit(response):
    response.headers.setdefault("X-Content-Type-Options", "nosniff")
    response.headers.setdefault("X-Frame-Options", "SAMEORIGIN")
    response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
    if ENABLE_HSTS:
        response.headers.setdefault(
            "Strict-Transport-Security",
            "max-age=31536000; includeSubDomains",
        )
    p = request.path or ""
    if p.startswith("/api/"):
        try:
            u = current_user_session_or_api_key()
            uid = int(u["id"]) if u else None
            models.append_api_audit(
                uid,
                _audit_key_hint(),
                request.method,
                p[:512],
                request.remote_addr,
                response.status_code,
            )
        except Exception:
            pass
    return response


def require_login():
    return current_user() is not None


def _ts_fmt(ts) -> str:
    if not ts:
        return ""
    try:
        return time.strftime("%Y-%m-%d %H:%M", time.localtime(float(ts)))
    except Exception:
        return ""


CHAT_IMAGE_MAX_BYTES = 15 * 1024 * 1024


def _ga_upload_max_bytes() -> int:
    """Per-file max for game account images — same cap as the overall request (MAX_UPLOAD_MB / MAX_CONTENT_LENGTH)."""
    return int(app.config.get("MAX_CONTENT_LENGTH", 512 * 1024 * 1024))
_ALLOWED_CHAT_IMG_EXT = frozenset({".jpg", ".jpeg", ".png", ".gif", ".webp"})


def _save_uploaded_image(
    file_storage,
    *,
    subdir: str,
    max_bytes: int,
) -> str | None:
    """Save image under static/uploads/{subdir}/. Returns filename only."""
    if not file_storage or not getattr(file_storage, "filename", None):
        return None
    orig = secure_filename(file_storage.filename)
    if not orig:
        return None
    ext = Path(orig).suffix.lower()
    if ext not in _ALLOWED_CHAT_IMG_EXT:
        return None
    data = file_storage.read()
    if len(data) > max_bytes or len(data) < 8:
        return None
    dest = UPLOAD_DIR / subdir
    dest.mkdir(parents=True, exist_ok=True)
    name = f"{uuid.uuid4().hex}{ext}"
    (dest / name).write_bytes(data)
    return name


def _save_chat_upload_image(file_storage) -> str | None:
    """Save uploaded chat image under static/uploads/chat/."""
    return _save_uploaded_image(file_storage, subdir="chat", max_bytes=CHAT_IMAGE_MAX_BYTES)


def _save_ga_info_upload_image(file_storage) -> str | None:
    """Screenshot / game info image for account listings → static/uploads/ga_info/."""
    return _save_uploaded_image(file_storage, subdir="ga_info", max_bytes=_ga_upload_max_bytes())


def _save_ga_cat_upload_image(file_storage) -> str | None:
    """Category card image for Game Accounts → static/uploads/ga_cat/."""
    return _save_uploaded_image(file_storage, subdir="ga_cat", max_bytes=_ga_upload_max_bytes())


def _chat_message_to_json(m: dict) -> dict:
    d = dict(m)
    d["created_at_fmt"] = _ts_fmt(d.get("created_at"))
    d["is_admin"] = bool(d.get("is_admin"))
    img = (d.get("image_file") or "").strip()
    d["image_url"] = f"/static/uploads/chat/{img}" if img else None
    return d


def _fallback_mlbb_account_fields() -> list[dict]:
    """If smile.one DOM scan finds no inputs, still show MLBB-style fields (keys may need to match page name=)."""
    return [
        {
            "key": "game_id",
            "label": "Game ID",
            "type": "text",
            "placeholder": "e.g. 12345678",
            "required": True,
        },
        {
            "key": "server_id",
            "label": "Server ID",
            "type": "text",
            "placeholder": "e.g. 1234",
            "required": True,
        },
    ]


def _is_mlbb_merchant_url(url: str) -> bool:
    """MLBB catalog URLs — same heuristics as package_display (avoid slow Playwright for known forms)."""
    u = (url or "").lower()
    return (
        "mobilelegends" in u
        or "mobile-legends" in u
        or "/mlbb" in u
        or "m-lbb" in u
    )


def get_product_name_from_url(url: str) -> str:
    """Extract product name from URL when name is generic"""
    parts = url.rstrip("/").split("/")
    for p in reversed(parts):
        if p and p not in ("merchant", "game", "pay", "entertainment", "br"):
            return p.replace("-", " ").replace("_", " ").title()
    return "Product"


def _normalize_merchant_url(u: str) -> str:
    u = (u or "").strip().split("?")[0]
    if u.startswith("https://smile.one/") or u.startswith("http://smile.one/"):
        u = "https://www." + u.split("://", 1)[1]
    elif u.startswith("http://"):
        u = "https://" + u[7:]
    return u


def _merge_merchant_lists(*lists) -> list[dict]:
    """Union merchants by URL; keep the longest readable name per URL."""
    by_url: dict[str, dict] = {}
    for lst in lists:
        for m in lst or []:
            raw = (m.get("url") or "").strip()
            if not raw:
                continue
            u = _normalize_merchant_url(raw)
            if "/merchant/" not in u and "/entertainment/" not in u:
                continue
            name = (m.get("name") or "").strip() or get_product_name_from_url(u)
            image = (m.get("image") or "").strip()
            prev = by_url.get(u)
            if prev is None or len(name) > len((prev.get("name") or "")):
                by_url[u] = {"name": name, "url": u, "image": image or (prev or {}).get("image", "")}
            elif image and not (prev or {}).get("image"):
                by_url[u]["image"] = image
    return list(by_url.values())


def _pin_extra_home_first(merchants: list) -> list:
    """Put EXTRA_HOME_MERCHANTS entries at the top (e.g. MLBB PH first), keep all others after."""
    if not merchants or not EXTRA_HOME_MERCHANTS:
        return merchants
    want_order = [_normalize_merchant_url(x["url"]) for x in EXTRA_HOME_MERCHANTS if (x or {}).get("url")]
    head = []
    for u in want_order:
        for m in merchants:
            if _normalize_merchant_url((m or {}).get("url") or "") == u:
                head.append(m)
                break
    head_keys = {_normalize_merchant_url((m or {}).get("url") or "") for m in head}
    rest = [m for m in merchants if _normalize_merchant_url((m or {}).get("url") or "") not in head_keys]
    return head + rest


def _min_merchants_cache_ok() -> int:
    try:
        from config import MIN_MERCHANTS_CACHE_OK

        return int(os.getenv("SMILE_MIN_MERCHANTS_OK", str(MIN_MERCHANTS_CACHE_OK)))
    except Exception:
        return 25


def load_or_scrape_data(force_scrape: bool = False) -> dict:
    """Load JSON cache, or refresh when list is too short / forced (Playwright home scroll)."""
    cached: dict | None = None
    if not force_scrape and DATA_FILE.exists():
        try:
            with open(DATA_FILE, encoding="utf-8") as f:
                cached = json.load(f)
        except Exception:
            cached = None

    merchants_cached = (cached or {}).get("merchants") or []
    need_fresh = (
        force_scrape
        or cached is None
        or len(merchants_cached) < _min_merchants_cache_ok()
    )

    if not need_fresh and cached:
        out_hit = dict(cached)
        out_hit["merchants"] = _pin_extra_home_first(
            _merge_merchant_lists(
                out_hit.get("merchants") or [],
                EXTRA_HOME_MERCHANTS,
            )
        )
        return out_hit

    fresh: list = []
    try:
        from scraper_playwright import run_scrape

        fresh = run_scrape()
    except Exception:
        pass

    popular = (cached or {}).get("popular") if isinstance(cached, dict) else None

    if not fresh:
        scraper = SmileOneScraper(delay=0.3)
        data_fb = scraper.scrape_all(REGIONS.get("br", REGIONS["br"]))
        fresh = data_fb.get("merchants") or []
        if data_fb.get("popular"):
            popular = data_fb["popular"]

    merged = _pin_extra_home_first(
        _merge_merchant_lists(merchants_cached, fresh, EXTRA_HOME_MERCHANTS)
    )

    if not merged:
        merged = merchants_cached or []
        merged = _pin_extra_home_first(_merge_merchant_lists(merged, EXTRA_HOME_MERCHANTS))

    out: dict = {"source": REGIONS.get("br", REGIONS["br"]), "merchants": merged}
    if popular:
        out["popular"] = popular

    with open(DATA_FILE, "w", encoding="utf-8") as f:
        json.dump(out, f, ensure_ascii=False, indent=2)
    return out


# ── User Auth ──

@app.route("/login", methods=["GET", "POST"])
def login_page():
    if current_user():
        return redirect("/")
    if request.method == "POST":
        username = (request.form.get("username") or "").strip()
        password = request.form.get("password") or ""
        user = models.authenticate_user(username, password)
        if user:
            session["user_id"] = user["id"]
            return redirect("/")
        return render_template("login.html", error="Invalid username or password")
    return render_template("login.html", error=None)


@app.route("/register", methods=["GET", "POST"])
def register_page():
    if current_user():
        return redirect("/")
    if request.method == "POST":
        username = (request.form.get("username") or "").strip()
        password = request.form.get("password") or ""
        password2 = request.form.get("password2") or ""
        if len(username) < 3:
            return render_template("register.html", error="Username must be at least 3 characters")
        if len(password) < 4:
            return render_template("register.html", error="Password must be at least 4 characters")
        if password != password2:
            return render_template("register.html", error="Passwords do not match")
        user = models.create_user(username, password)
        if not user:
            return render_template("register.html", error="Username already taken")
        session["user_id"] = user["id"]
        return redirect("/")
    return render_template("register.html", error=None)


@app.route("/logout")
def user_logout():
    session.pop("user_id", None)
    return redirect("/login")


# ── Profile ──

@app.route("/profile")
def profile_page():
    user = current_user()
    if not user:
        return redirect("/login")
    balance = models.get_balance(user["id"])
    return render_template(
        "profile.html", user=user, balance=balance,
        has_api_key=models.user_has_api_key(user["id"]),
        public_site_url=PUBLIC_SITE_URL,
        pw_error=request.args.get("pw_error"),
        pw_success=request.args.get("pw_success"),
        img_error=request.args.get("img_error"),
        img_success=request.args.get("img_success"),
    )


@app.route("/profile/avatar", methods=["POST"])
def profile_upload_avatar():
    user = current_user()
    if not user:
        return redirect("/login")
    avatar = request.files.get("avatar")
    if not avatar or not avatar.filename:
        return redirect("/profile?img_error=No+file+selected")
    ext = Path(avatar.filename).suffix.lower()
    if ext not in (".jpg", ".jpeg", ".png", ".gif", ".webp"):
        return redirect("/profile?img_error=Invalid+image+format.+Use+JPG,+PNG,+GIF+or+WebP")
    profile_dir = UPLOAD_DIR / "profiles"
    profile_dir.mkdir(parents=True, exist_ok=True)
    filename = f"avatar_{user['id']}_{uuid.uuid4().hex[:8]}{ext}"
    avatar.save(str(profile_dir / filename))
    models.update_profile_image(user["id"], filename)
    return redirect("/profile?img_success=Profile+picture+updated!")


@app.route("/profile/password", methods=["POST"])
def profile_change_password():
    user = current_user()
    if not user:
        return redirect("/login")
    current_pw = request.form.get("current_password", "")
    new_pw = request.form.get("new_password", "")
    confirm_pw = request.form.get("confirm_password", "")
    if len(new_pw) < 4:
        return redirect("/profile?pw_error=New+password+must+be+at+least+4+characters")
    if new_pw != confirm_pw:
        return redirect("/profile?pw_error=New+passwords+do+not+match")
    ok = models.change_password(user["id"], current_pw, new_pw)
    if not ok:
        return redirect("/profile?pw_error=Current+password+is+incorrect")
    return redirect("/profile?pw_success=Password+changed+successfully!")


# ── Forgot Password ──

@app.route("/forgot-password", methods=["GET", "POST"])
def forgot_password_page():
    if request.method == "POST":
        username = (request.form.get("username") or "").strip()
        user = models.get_user_by_username(username)
        if not user:
            return render_template("forgot_password.html", error="Username not found", submitted=False)
        existing = models.get_password_resets(status="pending")
        already = any(r["user_id"] == user["id"] for r in existing)
        if not already:
            models.create_password_reset(user["id"])
        return render_template("forgot_password.html", error=None, submitted=True)
    return render_template("forgot_password.html", error=None, submitted=False)


# ── Wallet ──

@app.route("/wallet")
def wallet_page():
    user = current_user()
    if not user:
        return redirect("/login")
    balance = models.get_balance(user["id"])
    raw_deps = models.get_deposits(user_id=user["id"])
    for d in raw_deps:
        d["created_at_fmt"] = _ts_fmt(d["created_at"])
    return render_template(
        "wallet.html",
        user=user,
        username=user["username"],
        balance=balance,
        deposits=raw_deps,
        deposit_error=request.args.get("deposit_error"),
        deposit_success=request.args.get("deposit_success"),
    )


@app.route("/wallet/deposit", methods=["POST"])
def wallet_deposit():
    user = current_user()
    if not user:
        return redirect("/login")
    amount = request.form.get("amount", "")
    payment_method = request.form.get("payment_method", "")
    sender_name = (request.form.get("sender_name") or "").strip()
    receipt = request.files.get("receipt")

    if not amount or not amount.isdigit() or int(amount) < 1:
        return redirect("/wallet?deposit_error=Please+enter+a+valid+amount")
    if not payment_method:
        return redirect("/wallet?deposit_error=Please+select+a+payment+method")
    if not sender_name:
        return redirect("/wallet?deposit_error=Please+enter+sender+name")

    receipt_filename = ""
    if receipt and receipt.filename:
        ext = Path(receipt.filename).suffix.lower()
        if ext not in (".jpg", ".jpeg", ".png", ".gif", ".webp"):
            return redirect("/wallet?deposit_error=Invalid+image+format")
        receipt_filename = f"{uuid.uuid4().hex}{ext}"
        receipt.save(str(UPLOAD_DIR / receipt_filename))

    models.create_deposit(
        user_id=user["id"],
        amount_mmk=int(amount),
        payment_method=payment_method,
        sender_name=sender_name,
        receipt_image=receipt_filename,
    )
    return redirect("/wallet?deposit_success=Deposit+request+submitted!+Waiting+for+admin+approval.")


# ── User Orders ──

@app.route("/orders")
def user_orders_page():
    user = current_user()
    if not user:
        return redirect("/login")
    raw_orders = models.get_unified_user_orders(user["id"], limit=200)
    for o in raw_orders:
        o["created_at_fmt"] = _ts_fmt(o["created_at"])
    return render_template("user_orders.html", orders=raw_orders, user=user)


# ── Main Pages ──

@app.route("/")
def index():
    user = current_user()
    if not user:
        return redirect("/login")
    balance = models.get_balance(user["id"])
    return render_template("index.html", regions=list(REGIONS.keys()), user=user, balance=balance)


@app.route("/admin", methods=["GET", "POST"])
def admin_page():
    token_q = (request.args.get("token") or "").strip()
    if token_q and _admin_token() and token_q == _admin_token():
        session["is_admin"] = True
        return redirect(url_for("admin_page"))

    if request.method == "POST":
        token = (request.form.get("token") or "").strip()
        if _admin_token() and token == _admin_token():
            session["is_admin"] = True
            return redirect(url_for("admin_page"))
        return render_template(
            "admin.html",
            ok=False,
            error="Invalid admin token",
            authed=False,
            admin_alert=None,
            pending_deposits=[],
            all_deposits=[],
            users=[],
            all_orders=[],
            pending_count=0,
            pw_resets=[],
            pw_reset_count=0,
            ga_categories=[],
            ga_accounts_all=[],
            chat_threads=[],
            chat_messages=[],
            chat_cu=None,
            chat_selected_id=None,
            chat_last_id=0,
            fx_shop_rates={"php_to_mmk_rate": None, "brl_to_mmk_fallback_rate": None},
            fx_save_ok=False,
            fx_save_err=False,
        )

    if not is_admin():
        return render_template(
            "admin.html",
            ok=None,
            error=None,
            authed=False,
            admin_alert=None,
            pending_deposits=[],
            all_deposits=[],
            users=[],
            all_orders=[],
            pending_count=0,
            pw_resets=[],
            pw_reset_count=0,
            ga_categories=[],
            ga_accounts_all=[],
            chat_threads=[],
            chat_messages=[],
            chat_cu=None,
            chat_selected_id=None,
            chat_last_id=0,
            fx_shop_rates={"php_to_mmk_rate": None, "brl_to_mmk_fallback_rate": None},
            fx_save_ok=False,
            fx_save_err=False,
        )

    pending = models.get_deposits(status="pending")
    all_deps = models.get_deposits()
    users = models.list_users()
    all_orders = models.get_orders()
    pw_resets = models.get_password_resets(status="pending")
    for d in pending + all_deps:
        d["created_at_fmt"] = _ts_fmt(d["created_at"])
    for u in users:
        u["created_at_fmt"] = _ts_fmt(u["created_at"])
    for o in all_orders:
        o["created_at_fmt"] = _ts_fmt(o["created_at"])
    for pr in pw_resets:
        pr["created_at_fmt"] = _ts_fmt(pr["created_at"])

    ga_categories = models.ga_list_categories()
    ga_accounts_all = models.ga_list_accounts_admin()
    for a in ga_accounts_all:
        a["created_at_fmt"] = _ts_fmt(a["created_at"])
    chat_threads = models.chat_list_threads_for_admin()
    for t in chat_threads:
        t["last_at_fmt"] = _ts_fmt(t["last_at"]) if t.get("last_at") else ""
    cu = request.args.get("cu", type=int)
    chat_messages = []
    chat_cu = None
    if cu:
        chat_messages = models.chat_history_for_admin(cu)
        for m in chat_messages:
            m["created_at_fmt"] = _ts_fmt(m["created_at"])
        chat_cu = models.get_user(cu)

    chat_last_id = 0
    if chat_messages:
        chat_last_id = max(int(m["id"]) for m in chat_messages)
    admin_alert = (
        "Chat image too large (max 15 MB per file)."
        if (request.args.get("e") or "").strip() == "chatimg"
        else None
    )
    fx_save_ok = (request.args.get("fx_saved") or "").strip() == "1"
    fx_save_err = (request.args.get("fx_err") or "").strip() == "1"

    return render_template(
        "admin.html",
        ok=True,
        error=None,
        authed=True,
        admin_alert=admin_alert,
        pending_deposits=pending,
        all_deposits=all_deps,
        users=users,
        all_orders=all_orders,
        pending_count=len(pending),
        pw_resets=pw_resets,
        pw_reset_count=len(pw_resets),
        ga_categories=ga_categories,
        ga_accounts_all=ga_accounts_all,
        chat_threads=chat_threads,
        chat_messages=chat_messages,
        chat_cu=chat_cu,
        chat_selected_id=cu,
        chat_last_id=chat_last_id,
        fx_shop_rates=models.shop_fx_rates_for_admin(),
        fx_save_ok=fx_save_ok,
        fx_save_err=fx_save_err,
    )


@app.route("/admin/settings/fx", methods=["POST"])
def admin_save_fx_settings():
    if not require_admin():
        return redirect("/admin")
    php = (request.form.get("php_to_mmk_rate") or "").strip()
    brl = (request.form.get("brl_to_mmk_fallback_rate") or "").strip()
    try:
        if php == "":
            models.shop_fx_delete("php_to_mmk_rate")
        else:
            models.shop_fx_set_rate("php_to_mmk_rate", float(php))
        if brl == "":
            models.shop_fx_delete("brl_to_mmk_fallback_rate")
        else:
            models.shop_fx_set_rate("brl_to_mmk_fallback_rate", float(brl))
        invalidate_brl_fx_cache()
        return redirect(url_for("admin_page", tab="settings", fx_saved="1"))
    except Exception:
        return redirect(url_for("admin_page", tab="settings", fx_err="1"))


@app.route("/admin/deposit/<int:dep_id>/approve", methods=["POST"])
def admin_approve_deposit(dep_id):
    if not require_admin():
        return redirect("/admin")
    models.approve_deposit(dep_id)
    return redirect("/admin")


@app.route("/admin/deposit/<int:dep_id>/reject", methods=["POST"])
def admin_reject_deposit(dep_id):
    if not require_admin():
        return redirect("/admin")
    models.reject_deposit(dep_id)
    return redirect("/admin")


@app.route("/admin/reset-password/<int:reset_id>", methods=["POST"])
def admin_resolve_password_reset(reset_id):
    if not require_admin():
        return redirect("/admin")
    new_pw = (request.form.get("new_password") or "").strip()
    if len(new_pw) < 4:
        return redirect("/admin")
    models.resolve_password_reset(reset_id, new_pw)
    return redirect("/admin")


@app.route("/admin/dismiss-reset/<int:reset_id>", methods=["POST"])
def admin_dismiss_password_reset(reset_id):
    if not require_admin():
        return redirect("/admin")
    models.dismiss_password_reset(reset_id)
    return redirect("/admin")


@app.route("/admin/user/<int:user_id>/reset-password", methods=["POST"])
def admin_reset_user_password(user_id):
    if not require_admin():
        return redirect("/admin")
    new_pw = (request.form.get("new_password") or "").strip()
    if len(new_pw) < 4:
        return redirect("/admin")
    models.admin_reset_password(user_id, new_pw)
    return redirect("/admin")


@app.route("/admin/user/<int:user_id>/adjust-balance", methods=["POST"])
def admin_adjust_balance(user_id):
    if not require_admin():
        return redirect("/admin")
    amount = request.form.get("amount", "").strip()
    try:
        delta = int(amount)
    except (ValueError, TypeError):
        return redirect("/admin")
    if delta == 0:
        return redirect("/admin")
    models.adjust_balance(user_id, delta)
    return redirect("/admin")


@app.route("/admin/ga/category/add", methods=["POST"])
def admin_ga_category_add():
    if not require_admin():
        return redirect("/admin")
    name = (request.form.get("name") or "").strip()
    if name:
        models.ga_create_category(name)
    return redirect(url_for("admin_page", tab="ga"))


@app.route("/admin/ga/category/<int:cid>/delete", methods=["POST"])
def admin_ga_category_delete(cid: int):
    if not require_admin():
        return redirect("/admin")
    models.ga_delete_category(cid)
    return redirect(url_for("admin_page", tab="ga"))


@app.route("/admin/ga/category/<int:cid>/image", methods=["POST"])
def admin_ga_category_image(cid: int):
    if not require_admin():
        return redirect("/admin")
    if (request.form.get("clear_card_image") or "").strip() == "1":
        models.ga_set_category_card_image(cid, "")
        return redirect(url_for("admin_page", tab="ga"))
    f = request.files.get("card_image")
    if f and f.filename:
        name = _save_ga_cat_upload_image(f)
        if name:
            models.ga_set_category_card_image(cid, name)
    return redirect(url_for("admin_page", tab="ga"))


@app.route("/admin/ga/account/add", methods=["POST"])
def admin_ga_account_add():
    if not require_admin():
        return redirect("/admin")
    try:
        cat_id = int(request.form.get("category_id") or 0)
        price = int(request.form.get("price_mmk") or 0)
    except (ValueError, TypeError):
        return redirect("/admin")
    title = (request.form.get("title") or "").strip()
    cred = (request.form.get("credentials") or "").strip()
    note = (request.form.get("public_note") or "").strip()
    if not (cat_id and title and cred and price > 0):
        return redirect("/admin")
    rid = models.ga_add_account(cat_id, title, price, cred, note)
    if rid:
        saved: list[str] = []
        for f in request.files.getlist("info_images"):
            if f and f.filename:
                name = _save_ga_info_upload_image(f)
                if name:
                    saved.append(name)
        if saved:
            models.ga_info_images_insert(rid, saved)
    return redirect(url_for("admin_page", tab="ga"))


@app.route("/admin/ga/account/<int:aid>/delete", methods=["POST"])
def admin_ga_account_delete(aid: int):
    if not require_admin():
        return redirect("/admin")
    models.ga_delete_account(aid)
    return redirect(url_for("admin_page", tab="ga"))


@app.route("/admin/ga/account/<int:aid>/edit", methods=["GET", "POST"])
def admin_ga_account_edit(aid: int):
    if not require_admin():
        return redirect("/admin")
    acc = models.ga_get_account_admin(aid)
    if not acc:
        return redirect(url_for("admin_page", tab="ga"))
    if acc["status"] != "available":
        return redirect(url_for("admin_page", tab="ga"))
    if request.method == "POST":
        try:
            price = int(request.form.get("price_mmk") or 0)
        except (ValueError, TypeError):
            return redirect(url_for("admin_ga_account_edit", aid=aid))
        title = (request.form.get("title") or "").strip()
        cred = (request.form.get("credentials") or "").strip()
        note = (request.form.get("public_note") or "").strip()
        if title and cred and price > 0:
            models.ga_update_account(
                aid,
                title=title,
                price_mmk=price,
                public_note=note,
                credentials=cred,
            )
        remove_ids: list[int] = []
        for key in request.form:
            if key.startswith("remove_img_"):
                try:
                    remove_ids.append(int(key.replace("remove_img_", "", 1)))
                except ValueError:
                    pass
        if remove_ids:
            models.ga_info_images_delete_rows(aid, remove_ids)
        saved: list[str] = []
        for f in request.files.getlist("info_images"):
            if f and f.filename:
                name = _save_ga_info_upload_image(f)
                if name:
                    saved.append(name)
        if saved:
            models.ga_info_images_append_bounded(aid, saved, max_total=None)
        return redirect(url_for("admin_page", tab="ga"))
    imgs = models.ga_info_images_list_rows(aid)
    for im in imgs:
        im["url"] = f"/static/uploads/ga_info/{im['image_file']}"
    return render_template(
        "admin_ga_account_edit.html",
        account=acc,
        info_images=imgs,
    )


@app.route("/admin/chat/reply", methods=["POST"])
def admin_chat_reply():
    if not require_admin():
        return redirect("/admin")
    try:
        uid = int(request.form.get("user_id") or 0)
    except (ValueError, TypeError):
        return redirect("/admin")
    body = (request.form.get("body") or "").strip()
    image_fn = None
    f = request.files.get("image")
    if f and f.filename:
        image_fn = _save_chat_upload_image(f)
        if image_fn is None:
            return redirect(url_for("admin_page", cu=uid, e="chatimg") if uid else url_for("admin_page", e="chatimg"))
    if uid and (body or image_fn):
        models.chat_append(uid, body, is_admin=True, image_file=image_fn or "")
    return redirect(url_for("admin_page", cu=uid) if uid else url_for("admin_page"))


@app.route("/admin/logout")
def admin_logout():
    session.pop("is_admin", None)
    return redirect(url_for("admin_page"))

@app.route("/product")
def product_page():
    user = current_user()
    if not user:
        return redirect("/login")
    # Browser must poll at least as long as Node Playwright can run (ORDER_NODE_SUBPROCESS_TIMEOUT_SEC) + buffer.
    order_poll_max_ms = int(ORDER_NODE_SUBPROCESS_TIMEOUT_SEC) * 1000 + 90_000
    url = (request.args.get("url") or "").strip()
    if not url or "smile.one" not in url:
        return render_template(
            "product.html",
            product_url="",
            error="Invalid product URL",
            user=user,
            balance=0,
            order_poll_max_ms=order_poll_max_ms,
        )
    balance = models.get_balance(user["id"])
    return render_template(
        "product.html",
        product_url=url,
        error=None,
        user=user,
        balance=balance,
        order_poll_max_ms=order_poll_max_ms,
    )


@app.route("/game-accounts")
def game_accounts_page():
    user = current_user()
    if not user:
        return redirect("/login")
    balance = models.get_balance(user["id"])
    cats = models.ga_list_categories()
    return render_template("game_accounts.html", user=user, balance=balance, categories=cats)


@app.route("/game-accounts/<slug>")
def game_accounts_category_page(slug: str):
    user = current_user()
    if not user:
        return redirect("/login")
    cat = models.ga_get_category_by_slug(slug)
    if not cat:
        return redirect("/game-accounts")
    balance = models.get_balance(user["id"])
    accs = models.ga_list_accounts_public(category_id=cat["id"])
    return render_template(
        "game_accounts_category.html",
        user=user,
        balance=balance,
        category=cat,
        accounts=accs,
    )


@app.route("/chat")
def chat_page():
    user = current_user()
    if not user:
        return redirect("/login")
    balance = models.get_balance(user["id"])
    return render_template("chat.html", user=user, balance=balance)


@app.route("/api/game-accounts/categories")
@_apply_rate_limit(f"{RATELIMIT_DEFAULT_API_PER_MINUTE} per minute")
def api_game_accounts_categories():
    return jsonify({"ok": True, "categories": models.ga_list_categories()})


@app.route("/api/game-accounts/list")
@_apply_rate_limit(f"{RATELIMIT_DEFAULT_API_PER_MINUTE} per minute")
def api_game_accounts_list():
    cid = request.args.get("category_id", type=int)
    slug = (request.args.get("slug") or "").strip()
    if slug:
        c = models.ga_get_category_by_slug(slug)
        if not c:
            return jsonify({"ok": True, "accounts": []})
        cid = c["id"]
    accs = models.ga_list_accounts_public(category_id=cid)
    for a in accs:
        a.pop("credentials", None)
    return jsonify({"ok": True, "accounts": accs})


@app.route("/api/game-accounts/purchase", methods=["POST"])
@_apply_rate_limit(f"{RATELIMIT_ORDER_PER_MINUTE} per minute")
def api_game_accounts_purchase():
    user = current_user()
    if not user:
        return jsonify({"ok": False, "error": "login"}), 401
    data = request.get_json() or {}
    try:
        aid = int(data.get("account_id"))
    except (TypeError, ValueError):
        return jsonify({"ok": False, "error": "bad_id"}), 400
    r = models.ga_purchase_account(user["id"], aid)
    if not r.get("ok"):
        err = r.get("error", "failed")
        code = 402 if err == "insufficient_balance" else 400
        return jsonify(r), code
    return jsonify(r)


@app.route("/api/chat/messages")
@_apply_rate_limit(f"{RATELIMIT_ME_PER_MINUTE} per minute")
def api_chat_messages():
    user = current_user()
    if not user:
        return jsonify({"ok": False, "error": "login"}), 401
    after = request.args.get("after_id", type=int, default=0) or 0
    msgs = models.chat_list_for_user(user["id"], after_id=after)
    return jsonify({"ok": True, "messages": [_chat_message_to_json(m) for m in msgs]})


@app.route("/api/chat/send", methods=["POST"])
@_apply_rate_limit(f"{RATELIMIT_ORDER_PER_MINUTE} per minute")
def api_chat_send():
    user = current_user()
    if not user:
        return jsonify({"ok": False, "error": "login"}), 401
    image_fn = None
    body = ""
    ct = (request.content_type or "").lower()
    if "multipart/form-data" in ct:
        body = (request.form.get("body") or "").strip()
        f = request.files.get("image")
        if f and f.filename:
            image_fn = _save_chat_upload_image(f)
            if image_fn is None:
                return jsonify({"ok": False, "error": "bad_image"}), 400
    else:
        data = request.get_json(silent=True) or {}
        body = (data.get("body") or "").strip()
    if not body and not image_fn:
        return jsonify({"ok": False, "error": "empty"}), 400
    mid = models.chat_append(user["id"], body, is_admin=False, image_file=image_fn or "")
    if not mid:
        return jsonify({"ok": False, "error": "invalid"}), 400
    return jsonify({"ok": True, "id": mid})


@app.route("/api/admin/chat/messages")
@_apply_rate_limit(f"{RATELIMIT_DEFAULT_API_PER_MINUTE} per minute")
def api_admin_chat_messages():
    if not require_admin():
        return jsonify({"ok": False, "error": "admin_required"}), 403
    uid = request.args.get("user_id", type=int, default=0) or 0
    if not uid:
        return jsonify({"ok": False, "error": "bad_user"}), 400
    after = request.args.get("after_id", type=int, default=0) or 0
    msgs = models.chat_list_for_user(uid, after_id=after)
    return jsonify({"ok": True, "messages": [_chat_message_to_json(m) for m in msgs]})


@app.route("/api/admin/chat/reply", methods=["POST"])
@_apply_rate_limit(f"{RATELIMIT_DEFAULT_API_PER_MINUTE} per minute")
def api_admin_chat_reply():
    if not require_admin():
        return jsonify({"ok": False, "error": "admin_required"}), 403
    try:
        uid = int(request.form.get("user_id") or 0)
    except (ValueError, TypeError):
        uid = 0
    body = (request.form.get("body") or "").strip()
    image_fn = None
    f = request.files.get("image")
    if f and f.filename:
        image_fn = _save_chat_upload_image(f)
        if image_fn is None:
            return jsonify({"ok": False, "error": "bad_image"}), 400
    if not uid or (not body and not image_fn):
        return jsonify({"ok": False, "error": "empty"}), 400
    mid = models.chat_append(uid, body, is_admin=True, image_file=image_fn or "")
    if not mid:
        return jsonify({"ok": False, "error": "invalid"}), 400
    row = models.chat_get_message_by_id(mid)
    message = _chat_message_to_json(row) if row else {
        "id": mid,
        "body": body,
        "is_admin": True,
        "created_at_fmt": _ts_fmt(time.time()),
        "image_url": f"/static/uploads/chat/{image_fn}" if image_fn else None,
    }
    return jsonify({"ok": True, "message": message})
@_apply_rate_limit(f"{RATELIMIT_DEFAULT_API_PER_MINUTE} per minute")
def api_products():
    force = request.args.get("refresh") == "1"
    data = load_or_scrape_data(force_scrape=force)
    
    products = []
    seen = set()
    for m in data["merchants"]:
        raw = (m.get("name") or "").strip() or get_product_name_from_url(m["url"])
        name = sanitize_merchant_display_name(raw) or raw
        if m["url"] in seen or len(name) < 3:
            continue
        seen.add(m["url"])
        products.append({"id": m["url"], "name": name, "url": m["url"], "image": m.get("image", "")})

    ga_home_img = models.ga_game_accounts_home_card_image_url()
    products.insert(
        0,
        {
            "id": "__game_accounts__",
            "name": "Game Accounts",
            "url": "__game_accounts__",
            "image": ga_home_img,
            "link_type": "game_accounts",
        },
    )
    products.insert(
        1,
        {
            "id": "__social_boosting__",
            "name": "Social Media Boosting",
            "url": "__social_boosting__",
            "image": "/static/img/social-boosting-card.svg",
            "link_type": "social_boosting",
        },
    )

    return jsonify({"products": products, "source": data["source"]})


@app.route("/api/social-boosting/order", methods=["POST"])
@_apply_rate_limit("20 per minute")
def api_social_boosting_order():
    """Charge user wallet (MMK), then submit order to SocialFastMM."""
    user = current_user()
    if not user:
        return jsonify({"ok": False, "error": "Login required."}), 401
    body = request.get_json(silent=True) or {}
    from social_boosting_order import place_social_boost_order

    out, code = place_social_boost_order(user, body)
    return jsonify(out), code


@app.route("/api/me", methods=["GET"])
@_apply_rate_limit(f"{RATELIMIT_ME_PER_MINUTE} per minute")
def api_me():
    """Current user + balance — session cookie or API key."""
    user = current_user_session_or_api_key()
    if not user:
        return jsonify({"ok": False, "error": "Unauthorized"}), 401
    bal = models.get_balance(user["id"])
    return jsonify({
        "ok": True,
        "username": user["username"],
        "user_id": user["id"],
        "balance_mmk": bal,
        "has_api_key": models.user_has_api_key(user["id"]),
    })


@app.route("/api/me/api-key", methods=["POST", "DELETE"])
@_apply_rate_limit(f"{RATELIMIT_ME_PER_MINUTE} per minute")
def api_me_api_key():
    """Create or revoke API key — web login only (not via API key)."""
    user = current_user()
    if not user:
        return jsonify({"ok": False, "error": "Login required"}), 401
    try:
        if request.method == "POST":
            key = models.generate_api_key(user["id"])
            return jsonify({
                "ok": True,
                "api_key": key,
                "message": "Save this key now; it cannot be shown again.",
            })
        models.revoke_api_key(user["id"])
        return jsonify({"ok": True, "message": "API key revoked."})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)[:220]}), 500


def _compute_verified_order_charge_mmk(url: str, package_brl_cents, package_php_cents) -> int | None:
    """MMK from Smile storefront cents only — never parse mmk_price (easy to under-parse)."""
    u = (url or "").lower()
    is_ph = "/ph/" in u
    php_v = None
    brl_v = None
    if package_php_cents is not None:
        try:
            php_v = int(package_php_cents)
        except (TypeError, ValueError):
            php_v = None
    if package_brl_cents is not None:
        try:
            brl_v = int(package_brl_cents)
        except (TypeError, ValueError):
            brl_v = None

    if is_ph:
        if php_v is None or php_v <= 0:
            return None
        php_amt = Decimal(php_v) / Decimal(100)
        _, mmk_dec, _ = php_amount_to_mmk_str(php_amt, DEFAULT_MARKUP_PCT)
        return int(mmk_dec.quantize(Decimal("1"), rounding=ROUND_HALF_UP))

    if brl_v is None or brl_v <= 0:
        return None
    _, mmk_dec, _ = brl_cents_to_mmk_str(brl_v, DEFAULT_MARKUP_PCT)
    return int(mmk_dec.quantize(Decimal("1"), rounding=ROUND_HALF_UP))


@app.route("/api/order", methods=["POST"])
@_apply_rate_limit(f"{RATELIMIT_ORDER_PER_MINUTE} per minute")
def api_order():
    """Start auto order — checks wallet balance, deducts on success."""
    user = current_user_session_or_api_key()
    if not user:
        return jsonify({"ok": False, "error": "Please login or provide a valid API key."}), 401

    data = request.get_json() or {}
    url = data.get("url", "").strip()
    package_brl_cents = data.get("package_brl_cents", None)
    package_php_cents = data.get("package_php_cents", None)
    package_name = data.get("package_name", "")
    package_index = data.get("package_index", None)
    form_data = data.get("form_data", None)
    smile_li_id = (data.get("smile_li_id") or "").strip() or None

    if not url or "smile.one" not in url:
        return jsonify({"ok": False, "error": "Invalid product URL"}), 400

    if package_php_cents is not None and "/ph/" not in url.lower():
        return jsonify(
            {
                "ok": False,
                "error": "Philippines (₱) packages need a /ph/ storefront URL (e.g. …/ph/merchant/…).",
            }
        ), 400

    cost_mmk = _compute_verified_order_charge_mmk(url, package_brl_cents, package_php_cents)

    if cost_mmk is None or cost_mmk <= 0:
        return jsonify(
            {
                "ok": False,
                "error": "price_verification_failed",
                "message": "Could not verify package price. Reload the product page and try again.",
            }
        ), 400

    uid = int(user["id"])
    prev = _order_by_user.get(uid)
    if prev and prev.get("status") == "running":
        return jsonify({"ok": False, "error": "An order is already in progress. Please wait."}), 409

    debit = models.debit_wallet_if_possible(uid, cost_mmk)
    if not debit.get("ok"):
        bal = debit.get("balance_mmk", models.get_balance(uid))
        if debit.get("error") == "insufficient_balance":
            return jsonify(
                {
                    "ok": False,
                    "error": "insufficient_balance",
                    "message": (
                        f"Insufficient balance. You need {cost_mmk:,} MMK but have {bal:,} MMK. "
                        "Please top up your wallet."
                    ),
                }
            ), 402
        return jsonify(
            {"ok": False, "error": debit.get("error") or "wallet_error", "message": "Wallet debit failed."}
        ), 400

    product_name = data.get("product_name", "") or ""
    order_id = models.create_order(uid, url, product_name, package_name, cost_mmk)

    def do_order():
        try:
            from order_automation import run_auto_order

            def _on_node_progress(line: str):
                st = _order_by_user.get(uid)
                if not st or st.get("status") != "running":
                    return
                st["message"] = _format_order_progress_line(line)

            result = run_auto_order(
                url,
                package_brl_cents=package_brl_cents,
                package_php_cents=package_php_cents,
                package_name=package_name,
                package_index=package_index,
                form_data=form_data,
                smile_li_id=smile_li_id,
                progress_callback=_on_node_progress,
            )
            _order_by_user[uid] = {**result, "status": "done", "order_id": order_id, "user_id": uid}
            ok = result.get("ok", False)
            models.update_order_status(
                order_id,
                status="completed" if ok else "failed",
                smile_step=result.get("step", ""),
                smile_message=result.get("message", ""),
            )
            if not ok:
                models.adjust_balance(uid, cost_mmk)
        except Exception as e:
            _order_by_user[uid] = {
                "order_id": order_id,
                "user_id": uid,
                "ok": False,
                "message": f"Order error: {str(e)[:100]}",
                "step": "error",
                "status": "done",
            }
            models.update_order_status(order_id, status="failed", smile_message=str(e)[:200])
            models.adjust_balance(uid, cost_mmk)

    _order_by_user[uid] = {
        "order_id": order_id,
        "user_id": uid,
        "status": "running",
        "message": "Placing order…",
        "ok": None,
        "step": "started",
    }
    t = threading.Thread(target=do_order, daemon=True)
    t.start()

    return jsonify(
        {"ok": True, "status": "running", "message": "Order started", "order_id": order_id}
    )


@app.route("/api/order-status")
@_apply_rate_limit(f"{RATELIMIT_ORDER_STATUS_PER_MINUTE} per minute")
def api_order_status():
    """Poll order status. Optional ?order_id= — requires session or API key."""
    user = current_user_session_or_api_key()
    if not user:
        return jsonify({"ok": False, "error": "Unauthorized"}), 401

    uid = int(user["id"])
    oid_param = request.args.get("order_id", type=int)

    if oid_param is not None:
        st = _order_by_user.get(uid)
        if st and st.get("order_id") == oid_param:
            return jsonify(_json_from_memory_order_state(st))
        row = models.get_order_by_id(oid_param, uid)
        if not row:
            return jsonify({"ok": False, "error": "Order not found"}), 404
        return jsonify(_order_status_payload_from_db(row))

    st = _order_by_user.get(uid)
    if st:
        return jsonify(_json_from_memory_order_state(st))
    return jsonify({"status": "idle"})


@app.route("/api/docs")
@_apply_rate_limit(f"{RATELIMIT_DEFAULT_API_PER_MINUTE} per minute")
def api_docs():
    """Machine-readable list of JSON API endpoints."""
    return jsonify({
        "title": "JungTzy Store API",
        "base_url": PUBLIC_SITE_URL,
        "auth": {
            "browser": f"Session cookie after logging in at {PUBLIC_SITE_URL}/login.",
            "api_key": "Authorization: Bearer <key>, header X-API-Key, or query ?api_key=",
        },
        "product_url_param": "For url query/body fields: use the same merchant product link as in /api/products[].url (from your store catalog).",
        "endpoints": [
            {"path": "/api/me", "methods": ["GET"], "auth": True, "notes": "Current user and wallet balance."},
            {"path": "/api/me/api-key", "methods": ["POST", "DELETE"], "auth": "session_only", "notes": "Create or revoke API key (browser login only)."},
            {"path": "/api/orders", "methods": ["GET"], "auth": True, "notes": "Game credit orders (orders) plus social_boost_orders (newest per list)."},
            {"path": "/api/orders/<id>", "methods": ["GET"], "auth": True, "notes": "Single order by id (must belong to you)."},
            {"path": "/api/order", "methods": ["POST"], "auth": True, "notes": "Start auto-order; returns order_id."},
            {"path": "/api/order-status", "methods": ["GET"], "auth": True, "notes": "Poll status; optional ?order_id=."},
            {"path": "/api/product-details", "methods": ["GET"], "auth": False, "query": ["url", "refresh"], "notes": "Packages and MMK prices; url = catalog product link (see /api/products)."},
            {"path": "/api/account-fields", "methods": ["GET"], "auth": False, "query": ["url"], "notes": "Account fields before checkout (e.g. MLBB IDs); url = catalog product link."},
            {"path": "/api/checkout-requirements", "methods": ["GET"], "auth": False, "query": ["url", "package_brl_cents", "package_php_cents", "package_index", "package_name"], "notes": "Extra checkout fields for a package; url = catalog product link."},
            {"path": "/api/products", "methods": ["GET"], "auth": False, "query": ["refresh"], "notes": "Merchant list for the store (cached)."},
        ],
    })


@app.route("/api/orders")
@_apply_rate_limit(f"{RATELIMIT_DEFAULT_API_PER_MINUTE} per minute")
def api_orders_list():
    user = current_user_session_or_api_key()
    if not user:
        return jsonify({"ok": False, "error": "Unauthorized"}), 401
    rows = models.get_orders(user["id"])
    orders = []
    for r in rows:
        orders.append({
            "id": r["id"],
            "product_url": r["product_url"],
            "product_name": r.get("product_name") or "",
            "package_name": r.get("package_name") or "",
            "amount_mmk": r.get("amount_mmk") or 0,
            "status": r.get("status") or "",
            "smile_step": r.get("smile_step") or "",
            "smile_message": r.get("smile_message") or "",
            "created_at": r.get("created_at"),
        })
    sb_rows = models.get_social_boost_orders(user["id"], limit=100)
    social_boost_orders = []
    for s in sb_rows:
        social_boost_orders.append({
            "id": s["id"],
            "provider_order_id": s.get("provider_order_id"),
            "service_id": s.get("service_id"),
            "service_name": s.get("service_name") or "",
            "service_type": s.get("service_type") or "",
            "link": s.get("link") or "",
            "quantity": s.get("quantity") or 0,
            "comments_preview": s.get("comments_preview") or "",
            "amount_mmk": s.get("amount_mmk") or 0,
            "status": s.get("status") or "",
            "provider_message": (s.get("provider_message") or "")[:500],
            "created_at": s.get("created_at"),
        })
    return jsonify({"ok": True, "orders": orders, "social_boost_orders": social_boost_orders})


@app.route("/api/orders/<int:order_id>")
@_apply_rate_limit(f"{RATELIMIT_DEFAULT_API_PER_MINUTE} per minute")
def api_orders_one(order_id: int):
    user = current_user_session_or_api_key()
    if not user:
        return jsonify({"ok": False, "error": "Unauthorized"}), 401
    row = models.get_order_by_id(order_id, user["id"])
    if not row:
        return jsonify({"ok": False, "error": "Not found"}), 404
    return jsonify({
        "ok": True,
        "order": {
            "id": row["id"],
            "product_url": row["product_url"],
            "product_name": row.get("product_name") or "",
            "package_name": row.get("package_name") or "",
            "amount_mmk": row.get("amount_mmk") or 0,
            "status": row.get("status") or "",
            "smile_step": row.get("smile_step") or "",
            "smile_message": row.get("smile_message") or "",
            "created_at": row.get("created_at"),
            "username": row.get("username") or "",
        },
    })


@app.route("/api/checkout-requirements", methods=["GET"])
@_apply_rate_limit(f"{RATELIMIT_DEFAULT_API_PER_MINUTE} per minute")
def api_checkout_requirements():
    """
    smile.one checkout မှာလိုအပ်တဲ့ input fields တွေကို scrape လုပ်ပြီး UI မှာပြပေးသည်။
    """
    url = request.args.get("url", "").strip()
    package_brl_cents = request.args.get("package_brl_cents", None)
    package_php_cents = request.args.get("package_php_cents", None)
    package_index = request.args.get("package_index", None)
    package_name = request.args.get("package_name", None)

    if not url or "smile.one" not in url:
        return jsonify({"ok": False, "error": "Invalid product URL"}), 400

    # Normalize empty strings to None
    if package_brl_cents == "":
        package_brl_cents = None
    if package_php_cents == "":
        package_php_cents = None
    if package_index == "":
        package_index = None
    if package_name == "":
        package_name = None

    try:
        from order_automation import run_checkout_requirements

        res = run_checkout_requirements(
            url,
            package_brl_cents=package_brl_cents,
            package_php_cents=package_php_cents,
            package_name=package_name,
            package_index=package_index,
        )
        if not res.get("ok", False):
            return jsonify(res), 500
        return jsonify({"ok": True, "fields": res.get("fields", [])})
    except Exception as e:
        return jsonify({"ok": False, "error": f"Requirements scrape failed: {str(e)[:120]}"}), 500


@app.route("/api/account-fields", methods=["GET"])
@_apply_rate_limit(f"{RATELIMIT_DEFAULT_API_PER_MINUTE} per minute")
def api_account_fields():
    """
    Fields before package selection — served from manual_account_fields.py (per-merchant labels),
    so the checkout form always loads without Playwright scraping.
    Set ACCOUNT_FIELDS_SCRAPE=1 to try Playwright first, then fall back to manual.
    """
    url = request.args.get("url", "").strip()
    if not url or "smile.one" not in url:
        return jsonify({"ok": False, "error": "Invalid product URL"}), 400

    if (os.getenv("ACCOUNT_FIELDS_SCRAPE", "") or "").strip().lower() in ("1", "true", "yes"):
        try:
            from order_automation import run_checkout_requirements

            res = run_checkout_requirements(url, first_screen_only=True)
            fields = list(res.get("fields") or []) if res.get("ok") else []
            if fields:
                return jsonify({"ok": True, "fields": fields, "source": "scrape"})
        except Exception:
            pass

    fields = get_manual_fields_for_url(url)
    return jsonify({"ok": True, "fields": fields, "source": "manual"})


@app.route("/api/product-details", methods=["GET"])
@_apply_rate_limit(f"{RATELIMIT_PRODUCT_DETAILS_PER_MINUTE} per minute")
def api_product_details():
    """
    merchant/game page ထဲက package list ကို ထုတ်ပြီး MMK ပြမည် (/ph/ = PHP→MMK တိုက်ရိုက်, အခြား = BRL→MMK)။
    """
    url = request.args.get("url", "").strip()
    if not url or "smile.one" not in url:
        return jsonify({"ok": False, "error": "Invalid product URL"}), 400

    bypass = request.args.get("refresh") == "1"

    try:
        scraped = run_game_packages_cached(url, bypass_cache=bypass)
        # Old disk cache may lack smile_li_id (any game). The order auto_order strict
        # matcher needs li.id to avoid same-price SKU collisions, so auto-refresh once.
        pk0 = scraped.get("packages") or []
        if (
            not bypass
            and pk0
            and not any(str((p or {}).get("smile_li_id") or "").strip() for p in pk0)
        ):
            scraped = run_game_packages_cached(url, bypass_cache=True)
    except Exception as e:
        # One fresh retry: first attempt can time out while the grid is still loading.
        if not bypass:
            try:
                scraped = run_game_packages_cached(url, bypass_cache=True)
            except Exception as e2:
                return jsonify({"ok": False, "error": f"Scrape failed: {str(e2)[:200]}"}), 500
        else:
            return jsonify({"ok": False, "error": f"Scrape failed: {str(e)[:200]}"}), 500

    is_ph = "/ph/" in url.lower()
    staged: list[dict] = []
    for idx, p in enumerate(scraped.get("packages", []) or []):
        raw_name = (p.get("name") or "").strip()
        if is_spurious_package_label(raw_name):
            continue

        price_text = (p.get("price_text") or "").strip()
        if is_ph:
            php_amt = parse_php_amount_from_text(price_text)
            if php_amt is None:
                continue
            mmk_display, mmk_dec, fx_src = php_amount_to_mmk_str(php_amt, DEFAULT_MARKUP_PCT)
            php_cents = int((php_amt * Decimal(100)).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
            charge_mmk = int(mmk_dec.quantize(Decimal("1"), rounding=ROUND_HALF_UP))
            staged.append({
                "idx": idx,
                "raw_name": raw_name,
                "price_text": price_text,
                "brl_cents": None,
                "php_cents": php_cents,
                "mmk_price": mmk_display,
                "charge_mmk": charge_mmk,
                "fx_source": fx_src,
                "smile_li_id": p.get("smile_li_id", ""),
            })
            continue

        brl_cents = parse_price_to_brl_cents(price_text)
        if brl_cents is None:
            continue

        mmk_display, mmk_dec, fx = brl_cents_to_mmk_str(
            brl_cents=brl_cents,
            markup_pct=DEFAULT_MARKUP_PCT,
        )
        charge_mmk = int(mmk_dec.quantize(Decimal("1"), rounding=ROUND_HALF_UP))

        staged.append({
            "idx": idx,
            "raw_name": raw_name,
            "price_text": price_text,
            "brl_cents": brl_cents,
            "php_cents": None,
            "mmk_price": mmk_display,
            "charge_mmk": charge_mmk,
            "fx_source": fx.source,
            "smile_li_id": p.get("smile_li_id", ""),
        })

    staged = mlbb_drop_plain_duplicate_at_same_price(staged, product_url=url, raw_key="raw_name")

    raw_title = (scraped.get("title") or "").strip()
    eff_title = effective_page_title(raw_title, url)
    if staged and eff_title:
        raws = [s["raw_name"] for s in staged]
        fixed_raw = strip_shared_page_title_prefix(raws, eff_title, product_url=url)
        for s, nr in zip(staged, fixed_raw):
            s["raw_name"] = nr

    packages = []
    for s in staged:
        display_name = package_name_to_english(
            s["raw_name"],
            product_url=url,
            page_title=raw_title,
        )
        packages.append({
            "package_index": s["idx"],
            "package_name": display_name,
            "package_name_en": display_name,
            "price_text": s["price_text"],
            "brl_cents": s["brl_cents"],
            "php_cents": s.get("php_cents"),
            "mmk_price": s["mmk_price"],
            "charge_mmk": s.get("charge_mmk"),
            "fx_source": s["fx_source"],
            "smile_li_id": s.get("smile_li_id", ""),
        })

    if is_ph:
        packages = dedupe_packages_by_php_cents(packages)
    else:
        packages = dedupe_packages_by_brl_cents(packages)
    # list_position = card order in our UI only. package_index must stay the scrape/DOM index
    # (same order as order_automation._SELECT_PACKAGE_JS `lis`) — re-numbering after dedupe/sort
    # broke Brazil MLBB: chosen price row ≠ row user tapped.
    for i, p in enumerate(packages):
        p["list_position"] = i

    disp_title = package_name_to_english(raw_title, product_url=url, page_title=raw_title)
    if is_trash_page_title(disp_title) or is_trash_page_title(raw_title):
        disp_title = effective_page_title(raw_title, url) or disp_title

    return jsonify({
        "ok": True,
        "url": url,
        "title": disp_title or scraped.get("title"),
        "markup_percent": int(round(DEFAULT_MARKUP_PCT * 100)),
        "packages": packages,
    })


@app.route("/api/scrape")
@_apply_rate_limit(f"{RATELIMIT_DEFAULT_API_PER_MINUTE} per minute")
def api_scrape():
    """Force fresh scrape - Playwright ဖြင့် ဂိမ်းအားလုံး ရယူသည်"""
    if not require_admin():
        return jsonify({"ok": False, "error": "admin_required"}), 403
    data = load_or_scrape_data(force_scrape=True)
    return jsonify({"ok": True, "count": len(data["merchants"])})


_login_result = {"data": None}
_cdp_save_result = {"data": None}


@app.route("/api/login", methods=["POST"])
@_apply_rate_limit(f"{RATELIMIT_DEFAULT_API_PER_MINUTE} per minute")
def api_login():
    """Google ဖြင့် login - Browser ဖွင့်ပြီး user က Google မှာ login ဝင်ပါ"""
    if not require_admin():
        return jsonify({"ok": False, "message": "admin_required"}), 403
    def do_login():
        try:
            from order_automation import run_google_login
            _login_result["data"] = run_google_login()
        except Exception as e:
            _login_result["data"] = {"ok": False, "message": str(e)}
    
    _login_result["data"] = None
    t = threading.Thread(target=do_login)
    t.start()
    t.join(timeout=90)
    
    r = _login_result.get("data") or {}
    return jsonify({"ok": r.get("ok", False), "message": r.get("message", "")})


@app.route("/api/login-cdp", methods=["POST"])
@_apply_rate_limit(f"{RATELIMIT_DEFAULT_API_PER_MINUTE} per minute")
def api_login_cdp():
    """
    Save smile_auth.json from a manually opened Chrome with remote debugging (avoids Google blocking Playwright).
    """
    if not require_admin():
        return jsonify({"ok": False, "message": "admin_required"}), 403

    def do_save():
        try:
            from order_automation import run_save_session_from_cdp

            _cdp_save_result["data"] = run_save_session_from_cdp()
        except Exception as e:
            _cdp_save_result["data"] = {"ok": False, "message": str(e)}

    _cdp_save_result["data"] = None
    t = threading.Thread(target=do_save)
    t.start()
    t.join(timeout=120)

    r = _cdp_save_result.get("data") or {}
    return jsonify({"ok": r.get("ok", False), "message": r.get("message", "")})


@app.route("/api/admin/status")
@_apply_rate_limit(f"{RATELIMIT_DEFAULT_API_PER_MINUTE} per minute")
def api_admin_status():
    if not require_admin():
        return jsonify({"ok": False, "error": "admin_required"}), 403
    exists = AUTH_FILE.exists()
    size = AUTH_FILE.stat().st_size if exists else 0
    return jsonify({"ok": True, "session_saved": exists, "auth_file_bytes": size})


if __name__ == "__main__":
    import logging
    logging.getLogger("werkzeug").setLevel(logging.ERROR)
    
    url = "http://127.0.0.1:5000"
    print("\n" + "=" * 50)
    print("  Smile.One Hub is running!")
    print("  Open in browser:", url)
    print("  (127.0.0.1 with DOT between 127 and 0)")
    print("=" * 50 + "\n")
    app.run(debug=True, port=5000, use_reloader=False)
