"""Advanced Glass Design Studio — Web Edition.

Flask port of the original CustomTkinter desktop app. All AI-calling and
SVG-processing logic (ai_client.py, svg_cleanup.py, color_utils.py,
app_config.py) is reused unchanged from the desktop version.
"""
from __future__ import annotations

import io
import os
import re
import time
import uuid
import zipfile
import threading
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from functools import wraps

from flask import Flask, jsonify, render_template, request, send_file, send_from_directory, session, url_for
from werkzeug.security import check_password_hash

import ai_client
import app_config
import color_utils
import history_store
import notifications_store
import security
import users_store
import api_monitor
import rotation_store

app = Flask(__name__)
# Production must provide a persistent, private Flask signing secret.
# Refuse to start with a known/default secret rather than silently weakening sessions.
_SECRET_KEY = os.environ.get("SECRET_KEY")
if not _SECRET_KEY:
    raise RuntimeError("SECRET_KEY environment variable must be set in production.")
app.secret_key = _SECRET_KEY

# Session-cookie hardening. SESSION_COOKIE_SECURE is opt-in via env var because
# turning it on before the site is served over HTTPS would silently break
# login (browsers refuse to send a Secure cookie over plain http://) — set
# FORCE_HTTPS_COOKIES=1 once the domain has SSL.
app.config.update(
    SESSION_COOKIE_HTTPONLY=True,
    SESSION_COOKIE_SAMESITE="Lax",
    SESSION_COOKIE_SECURE=os.environ.get("FORCE_HTTPS_COOKIES", "1") == "1",
)

# In-memory provider health is intentionally short-lived, but rotation position
# is persisted by rotation_store.py so it survives Passenger/WSGI worker restarts
# and is shared safely by multiple server processes.
_dead_provider_ids: set[str] = set()
_limit_until: dict[str, float] = {}
_rotation_lock = threading.Lock()
LIMIT_COOLDOWN_SECONDS = 300          # how long a rate-limited key rests before it is retried
GENERATION_TIME_BUDGET = 150          # stop trying further keys for one design after this many seconds

# In-memory provider health, keyed by provider id: {"state": "live"|"dead"|"limit"|"error"|"unchecked",
# "message": str, "checked_at": iso timestamp}. Populated both by the manual "Check" button
# and passively whenever a real generate/refine call succeeds or fails.
_provider_status: dict[str, dict] = {}

# Shared fallback protection. Primary user pools never use these locks.
_GLOBAL_FALLBACK_CONCURRENCY = 4
_global_fallback_semaphore = threading.BoundedSemaphore(_GLOBAL_FALLBACK_CONCURRENCY)
_fallback_user_locks: dict[str, threading.BoundedSemaphore] = {}
_fallback_user_locks_guard = threading.Lock()

# Lightweight per-process request throttles for expensive/authenticated API calls.
# This is defense-in-depth; keep any upstream proxy/WAF rate limits as well.
_rate_lock = threading.Lock()
_rate_buckets: dict[str, list[float]] = {}
_RATE_RULES = {
    "/x/a12": (60, 60),
    "/x/a13": (60, 60),
    "/x/a10": (20, 60),
}

def _rate_limited(key: str, limit: int, window: int) -> bool:
    now = time.time()
    with _rate_lock:
        values = [t for t in _rate_buckets.get(key, []) if now - t < window]
        if len(values) >= limit:
            _rate_buckets[key] = values
            return True
        values.append(now)
        _rate_buckets[key] = values
    return False

@app.after_request
def _security_headers(resp):
    # Keep implementation details and browser capabilities from being advertised.
    resp.headers.setdefault("X-Content-Type-Options", "nosniff")
    resp.headers.setdefault("X-Frame-Options", "DENY")
    resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
    resp.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
    resp.headers.setdefault("X-Robots-Tag", "noindex, nofollow, noarchive")
    resp.headers.pop("Server", None)
    resp.headers.pop("X-Powered-By", None)
    if request.path.startswith("/x/") or request.path in ("/", "/x7p4m"):
        resp.headers.setdefault("Cache-Control", "no-store")
    return resp

@app.before_request
def _security_gate():
    # Do not expose application/admin JavaScript bundles before authentication.
    # CSS remains public because the login page needs it.
    if request.path == "/static/js/app.js" and not (session.get("user") or session.get("is_admin")):
        return ("", 404)
    if request.path == "/static/js/admin.js" and not session.get("is_admin"):
        return ("", 404)
    # Reject cross-origin browser writes. Same-origin fetches from the app remain unchanged.
    if request.path.startswith("/x/") and request.method in {"POST", "PUT", "PATCH", "DELETE"}:
        origin = request.headers.get("Origin")
        if origin:
            expected = request.host_url.rstrip("/")
            if origin.rstrip("/") != expected:
                return jsonify({"error": "forbidden"}), 403
    rule = _RATE_RULES.get(request.path)
    if rule and request.method == "POST":
        limit, window = rule
        ip = request.headers.get("X-Real-IP") or request.remote_addr or "unknown"
        if _rate_limited(f"{ip}:{request.path}", limit, window):
            return jsonify({"error": "Too many requests. Please try again shortly."}), 429
    return None


def _set_status(pid: str, state: str, message: str = "") -> None:
    _provider_status[pid] = {
        "state": state,
        "message": (message or "")[:300],
        "checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
    }
    if state == "dead":
        _dead_provider_ids.add(pid)
        _limit_until.pop(pid, None)
    elif state == "limit":
        _limit_until[pid] = time.time() + LIMIT_COOLDOWN_SECONDS
    elif state == "live":
        _dead_provider_ids.discard(pid)
        _limit_until.pop(pid, None)
    # Persist provider health so a process restart does not erase the diagnosis.
    try:
        api_monitor.set_provider_health(pid, state, message, _limit_until.get(pid))
    except Exception:
        pass


def _classify_error(pid: str, message: str) -> str:
    """Classifies an exception message from a provider call and records status."""
    if ai_client.looks_like_rate_limit(message):
        _set_status(pid, "limit", message)
        return "limit"
    if ai_client.looks_like_bad_key(message):
        _set_status(pid, "dead", message)
        return "dead"
    if any(h in (message or "").lower() for h in ("timeout", "timed out", "deadline exceeded", "read timed out")):
        _set_status(pid, "error", "Provider request timed out — " + message)
        return "timeout"
    if ai_client.looks_like_overloaded(message):
        # Google's model was overloaded even after ai_client's own in-place retries.
        # Not this key's fault — record it as a plain "error" (never "dead", never a long
        # cooldown) so it's tried again on the very next generation.
        _set_status(pid, "error", "Model temporarily overloaded (503) — " + message)
        return "error"
    _set_status(pid, "error", message)
    return "error"


def _forget_provider(pid: str) -> None:
    """Drop runtime health for a key; request history remains intact."""
    _dead_provider_ids.discard(pid)
    _limit_until.pop(pid, None)
    _provider_status.pop(pid, None)
    try:
        api_monitor.clear_provider_health(pid)
    except Exception:
        pass


def _maintenance_payload(site: dict) -> dict:
    return {"error": "maintenance", "title": site["maintenance_title"], "message": site["maintenance_message"]}


def _maintenance_block():
    """A 503 response if the signed-in (non-admin) visitor is locked out by maintenance mode, else None."""
    user = session.get("user")
    if not user or session.get("is_admin"):
        return None
    site = app_config.get_site(app_config.load_config())
    if site["maintenance"] and not users_store.is_tester(user):
        return jsonify(_maintenance_payload(site)), 503
    return None


def login_required(fn):
    @wraps(fn)
    def wrapped(*args, **kwargs):
        user = session.get("user")
        if not user:
            return jsonify({"error": "auth_required"}), 401
        # Maintenance mode: everyone except tester accounts is switched off, even if they
        # were already signed in when the admin flipped the switch.
        site = app_config.get_site(app_config.load_config())
        if site["maintenance"] and not users_store.is_tester(user):
            return jsonify(_maintenance_payload(site)), 503
        return fn(*args, **kwargs)
    return wrapped


def admin_required(fn):
    @wraps(fn)
    def wrapped(*args, **kwargs):
        if not session.get("is_admin"):
            return jsonify({"error": "admin_auth_required"}), 401
        return fn(*args, **kwargs)
    return wrapped


# ---------------------------------------------------------------------------
# Auth — every visitor is a named account created from the backend (see
# manage_users.py, or the /admintools dashboard below); there is no anonymous/open
# mode any more since access is tied to a per-user credit balance.
# ---------------------------------------------------------------------------

@app.route("/x/a1", methods=["POST"])
def login():
    data = request.get_json(force=True, silent=True) or {}
    username = (data.get("username") or "").strip()
    password = data.get("password") or ""

    # If the username matches the admin account, route this same login box
    # into the admin flow instead — a separate throttle key (by IP only, not
    # IP+username) so it isn't affected by other users mistyping passwords,
    # and a separate session flag so it never gets treated as a normal
    # customer session.
    if security.safe_equal(username, app_config.ADMIN_USERNAME):
        throttle_key = f"admin:{request.remote_addr}"
        locked_for = security.is_locked(throttle_key)
        if locked_for:
            return jsonify({
                "ok": False,
                "error": f"Too many attempts. Try again in {locked_for // 60 + 1} minute(s).",
            }), 429
        if not check_password_hash(app_config.ADMIN_PASSWORD_HASH, password):
            security.register_failure(throttle_key)
            return jsonify({"ok": False, "error": "Wrong username or password"}), 401
        security.register_success(throttle_key)
        session.clear()
        session["is_admin"] = True
        session["admin_user"] = app_config.ADMIN_USERNAME
        return jsonify({"ok": True, "is_admin": True, "username": app_config.ADMIN_USERNAME})

    throttle_key = f"user:{request.remote_addr}:{username.lower()}"
    locked_for = security.is_locked(throttle_key)
    if locked_for:
        return jsonify({
            "ok": False,
            "error": f"Too many attempts. Try again in {locked_for // 60 + 1} minute(s).",
        }), 429

    if not username or not users_store.verify_login(username, password):
        security.register_failure(throttle_key)
        return jsonify({"ok": False, "error": "Wrong username or password"}), 401

    security.register_success(throttle_key)

    site = app_config.get_site(app_config.load_config())
    if site["maintenance"] and not users_store.is_tester(username):
        payload = _maintenance_payload(site)
        payload["ok"] = False
        return jsonify(payload), 503

    session.clear()
    session["user"] = username
    return jsonify({
        "ok": True, "username": username, "credits": users_store.get_credits(username),
        "maintenance_on": site["maintenance"],
    })


@app.route("/x/a2")
def session_status():
    user = session.get("user")
    site = app_config.get_site(app_config.load_config())
    blocked = bool(user) and site["maintenance"] and not users_store.is_tester(user)
    authed = bool(user) and not blocked
    if not user:
        # Do not disclose maintenance state or application details to anonymous visitors.
        return jsonify({"authed": False})
    if blocked:
        return jsonify({"authed": False, "maintenance_on": True,
                        "maintenance_title": site["maintenance_title"],
                        "maintenance_message": site["maintenance_message"]})
    return jsonify({
        "authed": True,
        "username": user,
        "credits": users_store.get_credits(user),
        "maintenance_on": site["maintenance"],
        "maintenance_title": site["maintenance_title"],
        "maintenance_message": site["maintenance_message"],
    })


@app.route("/x/a3", methods=["POST"])
def logout():
    session.pop("user", None)
    return jsonify({"ok": True})


# ---------------------------------------------------------------------------
# Admin dashboard auth — one super-user account (see app_config.ADMIN_USERNAME/
# ADMIN_PASSWORD_HASH), completely separate from the per-customer accounts in
# users_store.py. This is what replaces running manage_users.py / editing
# users.json by hand on the server: sign in at /admintools instead.
# ---------------------------------------------------------------------------

@app.route("/x/b1", methods=["POST"])
def admin_login():
    data = request.get_json(force=True, silent=True) or {}
    username = (data.get("username") or "").strip()
    password = data.get("password") or ""
    throttle_key = f"admin:{request.remote_addr}"

    locked_for = security.is_locked(throttle_key)
    if locked_for:
        return jsonify({
            "ok": False,
            "error": f"Too many attempts. Try again in {locked_for // 60 + 1} minute(s).",
        }), 429

    valid = (
        security.safe_equal(username, app_config.ADMIN_USERNAME)
        and check_password_hash(app_config.ADMIN_PASSWORD_HASH, password)
    )
    if not valid:
        security.register_failure(throttle_key)
        return jsonify({"ok": False, "error": "Wrong admin username or password"}), 401

    security.register_success(throttle_key)
    session.clear()  # an admin session never doubles as a customer session
    session["is_admin"] = True
    session["admin_user"] = app_config.ADMIN_USERNAME
    return jsonify({"ok": True, "username": app_config.ADMIN_USERNAME})


@app.route("/x/b2")
def admin_session_status():
    return jsonify({
        "authed": bool(session.get("is_admin")),
        "username": session.get("admin_user"),
    })


@app.route("/x/b3", methods=["POST"])
def admin_logout():
    session.pop("is_admin", None)
    session.pop("admin_user", None)
    return jsonify({"ok": True})


@app.route("/x/b4", methods=["GET"])
@admin_required
def admin_list_users():
    return jsonify({"users": users_store.list_users()})


@app.route("/x/b5", methods=["GET"])
@admin_required
def admin_user_api_keys(username):
    """Read-only admin view of one user's assigned + user-owned API keys.

    Full API keys are never returned to the browser. This endpoint also does
    not touch rotation state, provider health, credits, or generation logic.
    """
    username = (username or "").strip()
    users = users_store.load_users()
    if username not in users:
        return jsonify({"ok": False, "error": "No such user."}), 404

    cfg = app_config.load_config()

    assigned = []
    for provider in _backend_providers(cfg):
        if (
            _backend_provider_scope(provider) == "assigned"
            and provider.get("assigned_to") == username
        ):
            item = _admin_provider_view(provider)
            item["source"] = "admin_assigned"
            assigned.append(item)

    own = []
    for provider in users_store.list_user_providers(username):
        item = _masked_provider(provider)
        item["source"] = "user_added"
        own.append(item)

    return jsonify({
        "ok": True,
        "username": username,
        "summary": {
            "total": len(assigned) + len(own),
            "admin_assigned": len(assigned),
            "user_added": len(own),
        },
        "admin_assigned": assigned,
        "user_added": own,
    })


@app.route("/x/b4", methods=["POST"])
@admin_required
def admin_create_user():
    data = request.get_json(force=True, silent=True) or {}
    username = (data.get("username") or "").strip()
    password = data.get("password") or ""
    try:
        credits = int(data.get("credits", 0))
    except (TypeError, ValueError):
        credits = 0

    if not username or not password:
        return jsonify({"ok": False, "error": "Username and password are required."}), 400
    if len(password) < 6:
        return jsonify({"ok": False, "error": "Password must be at least 6 characters."}), 400

    try:
        users_store.create_user(username, password, credits, tester=bool(data.get("tester")))
    except ValueError as exc:
        return jsonify({"ok": False, "error": str(exc)}), 400
    return jsonify({"ok": True, "users": users_store.list_users()})


@app.route("/x/b6", methods=["DELETE"])
@admin_required
def admin_delete_user(username):
    if not users_store.delete_user(username):
        return jsonify({"ok": False, "error": "No such user."}), 404
    notifications_store.forget_user(username)
    return jsonify({"ok": True, "users": users_store.list_users()})


@app.route("/x/b7", methods=["POST"])
@admin_required
def admin_set_tester(username):
    data = request.get_json(force=True, silent=True) or {}
    if not users_store.set_tester(username, bool(data.get("tester"))):
        return jsonify({"ok": False, "error": "No such user."}), 404
    return jsonify({"ok": True, "users": users_store.list_users()})


@app.route("/x/b8", methods=["POST"])
@admin_required
def admin_update_credits(username):
    data = request.get_json(force=True, silent=True) or {}
    mode = data.get("mode", "add")  # "add" (delta, can be negative) or "set" (exact balance)
    try:
        amount = int(data.get("amount"))
    except (TypeError, ValueError):
        return jsonify({"ok": False, "error": "Amount must be a whole number."}), 400
    if not users_store.user_exists(username):
        return jsonify({"ok": False, "error": "No such user."}), 404

    try:
        balance = users_store.set_credits(username, amount) if mode == "set" \
            else users_store.add_credits(username, amount)
    except ValueError as exc:
        return jsonify({"ok": False, "error": str(exc)}), 400
    return jsonify({"ok": True, "credits": balance, "users": users_store.list_users()})


@app.route("/x/b9", methods=["POST"])
@admin_required
def admin_change_password(username):
    data = request.get_json(force=True, silent=True) or {}
    new_password = data.get("password") or ""
    if len(new_password) < 6:
        return jsonify({"ok": False, "error": "Password must be at least 6 characters."}), 400
    if not users_store.change_password(username, new_password):
        return jsonify({"ok": False, "error": "No such user."}), 404
    return jsonify({"ok": True})


# ---------------------------------------------------------------------------
# Site branding (title, logo, favicon, texts) — set from /admintools -> Site Settings
# ---------------------------------------------------------------------------

def _asset_url(filename: str) -> str:
    if filename and (app_config.ASSET_DIR / filename).is_file():
        return url_for("site_asset", filename=filename)
    return ""


def _public_site(cfg: dict | None = None) -> dict:
    """Everything the templates need to brand the page."""
    site = app_config.get_site(cfg or app_config.load_config())
    words = site["brand_name"].split()
    brand_main, brand_accent = (" ".join(words[:-1]), words[-1]) if len(words) > 1 else ("", site["brand_name"])
    return {
        "site_title": site["site_title"], "brand_name": site["brand_name"],
        "brand_main": brand_main, "brand_accent": brand_accent,
        "subtitle": site["subtitle"], "tagline": site["tagline"], "badge_text": site["badge_text"],
        "login_text": site["login_text"],
        "logo_url": _asset_url(site["logo_file"]), "favicon_url": _asset_url(site["favicon_file"]),
    }


@app.context_processor
def inject_site():
    return {"site": _public_site()}


@app.route("/site-assets/<path:filename>")
def site_asset(filename):
    resp = send_from_directory(app_config.ASSET_DIR, filename, max_age=31536000)  # filenames are unique per upload
    if filename.lower().endswith(".svg"):
        resp.headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
    return resp


# ---------------------------------------------------------------------------
# Pages
# ---------------------------------------------------------------------------

@app.route("/")
def index():
    if not (session.get("user") or session.get("is_admin")):
        return render_template("login.html")
    return render_template("index.html")


@app.route("/x7p4m")
def admin_page():
    # Do not expose the admin dashboard/login UI to unauthenticated visitors.
    # The normal login endpoint establishes the admin session and then redirects here.
    if not session.get("is_admin"):
        return ("", 404)
    return render_template("admin.html")


# ---------------------------------------------------------------------------
# Config / options
# ---------------------------------------------------------------------------

def _masked_provider(p: dict) -> dict:
    key = p.get("api_key") or ""
    masked = ("••••" + key[-4:]) if key else ""
    legacy = (p.get("type") or "gemini") != "gemini"   # keys saved back when Claude/ChatGPT were supported
    status = _provider_status.get(p["id"]) or api_monitor.get_provider_health(p["id"]) or {"state": "unchecked", "message": "", "checked_at": None}
    if p["id"] not in _provider_status and status.get("state") not in (None, "unchecked"):
        _provider_status[p["id"]] = status
        if status.get("state") == "dead":
            _dead_provider_ids.add(p["id"])
        elif status.get("state") == "limit" and status.get("cooldown_until", 0) > time.time():
            _limit_until[p["id"]] = status.get("cooldown_until")
    if not p.get("enabled", True):
        status = {"state": "disabled", "message": "Provider is disabled by the admin.", "checked_at": status.get("checked_at")}
    elif legacy:
        status = {"state": "legacy", "message": "This key is not usable — remove it.", "checked_at": None}
    elif not key:
        status = {"state": "nokey", "message": "No API key configured.", "checked_at": status.get("checked_at")}
    out = {
        "id": p["id"], "label": p.get("label") or "", "has_key": bool(key), "key_preview": masked,
        "legacy": legacy,
        "status": status["state"], "status_message": status["message"], "checked_at": status["checked_at"],
        "enabled": bool(p.get("enabled", True)),
    }
    if status["state"] == "limit" and _limit_until.get(p["id"], 0) > time.time():
        out["cooldown_until"] = _limit_until[p["id"]]
    if p.get("owner"):
        out["owner"] = p["owner"]  # only set on entries returned to the admin view
    return out


def _status_summary(providers: list) -> dict:
    counts = {"total": len(providers), "live": 0, "dead": 0, "limit": 0, "error": 0, "nokey": 0, "unchecked": 0, "legacy": 0, "disabled": 0}
    for p in providers:
        state = _masked_provider(p)["status"]
        counts[state] = counts.get(state, 0) + 1
    return counts


def _split_keys(raw) -> list:
    """Accepts one key or several (separated by spaces, commas or new lines); drops junk and repeats."""
    seen, out = set(), []
    for part in re.split(r"[\s,;]+", str(raw or "")):
        k = part.strip().strip("'\"")
        if len(k) >= 16 and k not in seen:
            seen.add(k)
            out.append(k)
    return out


MODEL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{2,79}$")


@app.route("/x/a4")
def options():
    if not (session.get("user") or session.get("is_admin")):
        return jsonify({"error": "auth_required"}), 401
    blocked = _maintenance_block()
    if blocked:
        return blocked
    return jsonify({
        "glass_types": list(app_config.GLASS_TYPES.keys()),
        "glass_styles": list(app_config.GLASS_STYLES.keys()),
        "highlight_intensities": list(app_config.HIGHLIGHT_INTENSITIES.keys()),
        "canvas_sizes": list(app_config.CANVAS_SIZES.keys()),
        "background_colors": list(app_config.BACKGROUND_COLORS.keys()),
        "background_swatches": app_config.BACKGROUND_COLORS,
        "defaults": app_config.DEFAULTS,
    })


# ---------------------------------------------------------------------------
# Per-user API keys ("My API Keys")
# ---------------------------------------------------------------------------
# Provider pools are deliberately separated:
#   1) user-owned providers live in users.json and belong only to that user;
#   2) backend-owned providers live in config.json and are either assigned to
#      one user or marked as GLOBAL_FALLBACK;
#   3) global fallback providers are never part of a user's primary pool.
# ---------------------------------------------------------------------------

def _backend_provider_scope(p: dict) -> str:
    """Return the persistent backend provider scope, with legacy keys treated
    as global fallback for backward compatibility."""
    return str(p.get("scope") or ("assigned" if p.get("assigned_to") else "global")).lower()


def _prepare_provider(p: dict, model: str) -> dict:
    q = dict(p)
    q["type"] = "gemini"
    q["model"] = model
    q.setdefault("enabled", True)
    return q


def _backend_providers(cfg: dict) -> list:
    return [p for p in cfg.get("providers", []) if (p.get("type") or "gemini") == "gemini"]


def get_user_owned_pool(username: str, cfg: dict) -> list:
    """Return only API keys explicitly added by this user.

    A normal user must add at least one personal Gemini API key before
    generation/refinement is allowed. Admin-assigned and global backend keys
    remain available only after that requirement is satisfied.
    """
    model = app_config.get_gemini_model(cfg)
    out = []
    for p in users_store.list_user_providers(username):
        if (p.get("type") or "gemini") != "gemini":
            continue
        out.append(_prepare_provider(p, model))
    return out


def get_user_primary_pool(username: str, cfg: dict) -> list:
    """Return only this user's admin-assigned providers + own providers."""
    model = app_config.get_gemini_model(cfg)
    out = []
    assigned = []
    for p in _backend_providers(cfg):
        if _backend_provider_scope(p) == "assigned" and p.get("assigned_to") == username and p.get("enabled", True):
            assigned.append(_prepare_provider(p, model))
    own = []
    for p in users_store.list_user_providers(username):
        if (p.get("type") or "gemini") != "gemini":
            continue
        own.append(_prepare_provider(p, model))
    # Keep backend-assigned keys first, followed by the user's own keys.
    return assigned + own


def get_global_fallback_pool(cfg: dict) -> list:
    """Return only enabled backend-owned providers in GLOBAL_FALLBACK scope."""
    model = app_config.get_gemini_model(cfg)
    return [
        _prepare_provider(p, model)
        for p in _backend_providers(cfg)
        if _backend_provider_scope(p) == "global" and p.get("enabled", True)
    ]


def _pool_key_set(cfg: dict) -> set:
    keys = set()
    for p in _backend_providers(cfg):
        if p.get("api_key"):
            keys.add(p["api_key"])
    for p in users_store.all_user_providers():
        if p.get("api_key"):
            keys.add(p["api_key"])
    return keys


def _validate_gemini_api_key(api_key: str, cfg: dict) -> tuple[bool, str, str]:
    """Verify a Gemini key with Google's API before saving it.

    This is deliberately a lightweight authentication check: list the models
    available to the key rather than generating content. Invalid/unauthorized
    keys are never stored. Temporary network/provider errors are also rejected
    from being saved so the UI never presents an unverified key as usable.
    """
    key = (api_key or "").strip()
    if not key:
        return False, "invalid", "Paste an API key first."

    try:
        from google import genai
        from google.genai import types

        client = genai.Client(
            api_key=key,
            http_options=types.HttpOptions(
                timeout=15000,
                retry_options=types.HttpRetryOptions(attempts=1),
            ),
        )

        # Force the request to execute. We do not generate content here, so
        # adding/checking a key does not spend a design credit.
        models = client.models.list()
        next(iter(models), None)
        return True, "live", "API key verified with Google."
    except Exception as exc:
        message = str(exc)[:500]
        if ai_client.looks_like_bad_key(message):
            return False, "dead", "This API key is invalid or not authorized."
        if ai_client.looks_like_rate_limit(message):
            return False, "limit", "The key check is temporarily rate-limited. Please try again in a moment."
        return False, "error", "The key could not be verified right now. Please check it and try again."


def _user_key_view(p: dict) -> dict:
    """What a normal user may know about their own key."""
    m = _masked_provider(p)
    dead = m["status"] in ("dead", "legacy")
    return {
        "id": m["id"], "label": m["label"], "has_key": m["has_key"], "key_preview": m["key_preview"],
        "status": "dead" if dead else "ok",
        "status_message": ("This key is not usable — remove it." if m["status"] == "legacy"
                           else "This key was rejected — replace or remove it.") if dead else "",
    }


@app.route("/x/a10", methods=["GET"])
@login_required
def list_providers():
    username = session["user"]
    providers = users_store.list_user_providers(username)
    views = [_user_key_view(p) for p in providers]
    return jsonify({
        "providers": views,
        "summary": {"total": len(views), "dead": sum(1 for v in views if v["status"] == "dead")},
    })


@app.route("/x/a10", methods=["POST"])
@login_required
def add_provider():
    data = request.get_json(force=True, silent=True) or {}
    username = session["user"]
    keys = _split_keys(data.get("api_key"))
    if not keys:
        return jsonify({"ok": False, "error": "Paste a valid API key."}), 400
    cfg = app_config.load_config()
    known = _pool_key_set(cfg)
    n = len(users_store.list_user_providers(username))
    added = 0
    skipped = 0
    for k in keys:
        if k in known:
            skipped += 1
            continue

        # A user-owned key must be verified by Google before it enters the
        # rotation pool. Invalid keys are never persisted.
        valid, state, message = _validate_gemini_api_key(k, cfg)
        if not valid:
            return jsonify({
                "ok": False,
                "error": message,
                "validation": state,
            }), 400

        known.add(k)
        n += 1
        provider = app_config.new_provider(f"Key #{n}")
        provider["api_key"] = k
        users_store.add_user_provider(username, provider)
        _set_status(provider["id"], "live", "API key verified with Google.")
        added += 1

    if not added:
        return jsonify({"ok": False, "error": "That key is already in the pool."}), 409
    return jsonify({"ok": True, "added": added, "skipped": skipped, "verified": added})


@app.route("/x/a11", methods=["PUT"])
@login_required
def update_provider(pid):
    data = request.get_json(force=True, silent=True) or {}
    username = session["user"]
    fields = {}
    if data.get("api_key"):
        new_key = _split_keys(data.get("api_key"))
        if not new_key:
            return jsonify({"ok": False, "error": "Paste an API key first."}), 400
        valid, state, message = _validate_gemini_api_key(new_key[0], app_config.load_config())
        if not valid:
            return jsonify({"ok": False, "error": message, "validation": state}), 400
        fields["api_key"] = new_key[0]
    if data.get("label"):
        fields["label"] = str(data["label"]).strip()[:60]
    provider = users_store.update_user_provider(username, pid, **fields)
    if not provider:
        return jsonify({"error": "Not found"}), 404
    if "api_key" in fields:
        _forget_provider(pid)  # new key — old health reading no longer applies
    return jsonify({"ok": True, "provider": _user_key_view(provider)})


@app.route("/x/a11", methods=["DELETE"])
@login_required
def delete_provider(pid):
    username = session["user"]
    if not users_store.delete_user_provider(username, pid):
        return jsonify({"error": "Not found"}), 404
    _forget_provider(pid)
    return jsonify({"ok": True})


# ---------------------------------------------------------------------------
# Admin-owned provider registry. Existing config["providers"] entries are
# automatically interpreted as GLOBAL_FALLBACK unless they already have an
# assigned_to/scope value. This keeps existing installations intact.
# ---------------------------------------------------------------------------

def _admin_provider_view(p: dict) -> dict:
    out = _masked_provider(p)
    out.update({
        "scope": _backend_provider_scope(p),
        "assigned_to": p.get("assigned_to"),
        "enabled": bool(p.get("enabled", True)),
        "project_id": p.get("project_id") or "",
    })
    return out


def _admin_provider_summary(providers: list) -> dict:
    return _status_summary(providers)


def _find_backend_provider(cfg: dict, pid: str):
    for p in cfg.get("providers", []):
        if p.get("id") == pid:
            return p
    return None


def _user_has_backend_assignment(cfg: dict, username: str, pid: str) -> bool:
    p = _find_backend_provider(cfg, pid)
    return bool(p and _backend_provider_scope(p) == "assigned" and p.get("assigned_to") == username)


@app.route("/x/b10", methods=["GET"])
@admin_required
def admin_list_providers():
    cfg = app_config.load_config()
    backend = _backend_providers(cfg)
    global_providers = [p for p in backend if _backend_provider_scope(p) == "global"]
    assigned = [p for p in backend if _backend_provider_scope(p) == "assigned"]
    user_providers = users_store.all_user_providers()
    assignments = {}
    for p in assigned:
        assignments.setdefault(p.get("assigned_to"), []).append(_admin_provider_view(p))
    return jsonify({
        "global_providers": [_admin_provider_view(p) for p in global_providers],
        "assigned_providers": [_admin_provider_view(p) for p in assigned],
        "assignments": assignments,
        "user_providers": [_masked_provider(p) for p in user_providers],
        "summary": _admin_provider_summary(backend),
        "global_summary": _admin_provider_summary(global_providers),
        "assigned_summary": _admin_provider_summary(assigned),
    })


@app.route("/x/b10", methods=["POST"])
@admin_required
def admin_add_provider():
    data = request.get_json(force=True, silent=True) or {}
    cfg = app_config.load_config()
    keys = _split_keys(data.get("api_key"))
    if not keys:
        return jsonify({"ok": False, "error": "Paste at least one valid API key."}), 400
    known = _pool_key_set(cfg)
    providers = cfg.setdefault("providers", [])
    added = 0
    for k in keys:
        if k in known:
            continue
        known.add(k)
        provider = app_config.new_provider(f"Key #{len(providers) + 1}")
        provider.update({"api_key": k, "scope": "global", "assigned_to": None, "enabled": True})
        providers.append(provider)
        added += 1
    if not added:
        return jsonify({"ok": False, "error": "Those keys are already in the provider registry."}), 409
    app_config.save_config(cfg)
    return jsonify({"ok": True, "added": added, "skipped": len(keys) - added})


@app.route("/x/b11", methods=["PUT"])
@admin_required
def admin_update_provider(pid):
    data = request.get_json(force=True, silent=True) or {}
    cfg = app_config.load_config()
    provider = _find_backend_provider(cfg, pid)
    if not provider:
        return jsonify({"error": "Not found"}), 404
    key_changed = False
    if data.get("api_key"):
        new_key = _split_keys(data.get("api_key"))
        if not new_key:
            return jsonify({"ok": False, "error": "That doesn't look like a valid API key."}), 400
        new_key = new_key[0]
        for other in _backend_providers(cfg):
            if other.get("id") != pid and other.get("api_key") == new_key:
                return jsonify({"ok": False, "error": "That API key is already in the provider registry."}), 409
        provider["api_key"] = new_key
        key_changed = True
    if "label" in data:
        provider["label"] = str(data.get("label") or "").strip()[:60] or provider.get("label") or "Gemini key"
    if "enabled" in data:
        provider["enabled"] = bool(data.get("enabled"))
    if "project_id" in data:
        provider["project_id"] = str(data.get("project_id") or "").strip()[:160]
    if "scope" in data:
        scope = str(data.get("scope") or "").strip().lower()
        if scope not in ("global", "assigned"):
            return jsonify({"ok": False, "error": "Invalid provider scope."}), 400
        username = str(data.get("assigned_to") or "").strip()
        if scope == "assigned":
            if not username or not users_store.user_exists(username):
                return jsonify({"ok": False, "error": "Select an existing user before assigning this key."}), 400
            provider["scope"] = "assigned"
            provider["assigned_to"] = username
        else:
            provider["scope"] = "global"
            provider["assigned_to"] = None
    elif "assigned_to" in data:
        username = str(data.get("assigned_to") or "").strip()
        if username:
            if not users_store.user_exists(username):
                return jsonify({"ok": False, "error": "User not found."}), 404
            provider["scope"] = "assigned"
            provider["assigned_to"] = username
        else:
            provider["scope"] = "global"
            provider["assigned_to"] = None
    app_config.save_config(cfg)
    if key_changed:
        _forget_provider(pid)
    return jsonify({"ok": True, "provider": _admin_provider_view(provider)})


@app.route("/x/b11", methods=["DELETE"])
@admin_required
def admin_delete_provider(pid):
    cfg = app_config.load_config()
    before = len(cfg.get("providers", []))
    cfg["providers"] = [p for p in cfg.get("providers", []) if p.get("id") != pid]
    if len(cfg["providers"]) == before:
        return jsonify({"error": "Not found"}), 404
    app_config.save_config(cfg)
    _forget_provider(pid)
    return jsonify({"ok": True})


@app.route("/x/b12", methods=["GET"])
@admin_required
def admin_get_settings():
    cfg = app_config.load_config()
    return jsonify({
        "model": app_config.get_gemini_model(cfg),
        "models": app_config.GEMINI_MODELS,
        "default_model": app_config.DEFAULT_GEMINI_MODEL,
    })


@app.route("/x/b12", methods=["PUT"])
@admin_required
def admin_update_settings():
    data = request.get_json(force=True, silent=True) or {}
    model = str(data.get("model") or "").strip()
    if not MODEL_ID_RE.match(model):
        return jsonify({"ok": False, "error": "Enter a valid Gemini model ID."}), 400
    cfg = app_config.load_config()
    cfg["gemini_model"] = model
    app_config.save_config(cfg)
    # A different model means every earlier health reading (dead / limit / error) is out of date.
    _provider_status.clear(); _dead_provider_ids.clear(); _limit_until.clear()
    try:
        rotation_store.clear_all()
    except Exception:
        pass
    try:
        api_monitor.clear_all_provider_health()
    except Exception:
        pass
    return jsonify({"ok": True, "model": model})


# ---------------------------------------------------------------------------
# Site Settings (admin): branding texts, logo, favicon, credit cost, maintenance mode
# ---------------------------------------------------------------------------

ASSET_MAX_BYTES = 2 * 1024 * 1024


def _detect_image_ext(data: bytes):
    """Identify the image by its content (not its filename). Returns an extension or None."""
    if data.startswith(b"\x89PNG\r\n\x1a\n"):
        return "png"
    if data[:3] == b"\xff\xd8\xff":
        return "jpg"
    if data[:6] in (b"GIF87a", b"GIF89a"):
        return "gif"
    if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
        return "webp"
    if data[:4] == b"\x00\x00\x01\x00":
        return "ico"
    head = data[:4096].lstrip(b"\xef\xbb\xbf").lstrip().lower()
    if head.startswith((b"<svg", b"<?xml", b"<!doctype svg", b"<!--")) and b"<svg" in head:
        return "svg"
    return None


def _admin_site_view(cfg: dict) -> dict:
    site = app_config.get_site(cfg)
    users = users_store.list_users()
    return {
        **{k: site[k] for k in app_config.SITE_TEXT_LIMITS},
        "maintenance": site["maintenance"],
        "credit_cost": site["credit_cost"],
        "credit_cost_effective": app_config.get_credit_cost(cfg),
        "credit_cost_default": app_config.CREDIT_COST_PER_DESIGN,
        "logo_url": _asset_url(site["logo_file"]),
        "favicon_url": _asset_url(site["favicon_file"]),
        "tester_count": sum(1 for u in users if u.get("tester")),
        "defaults": {k: app_config.SITE_DEFAULTS[k] for k in app_config.SITE_TEXT_LIMITS},
    }


@app.route("/x/b13", methods=["GET"])
@admin_required
def admin_get_site():
    return jsonify(_admin_site_view(app_config.load_config()))


@app.route("/x/b13", methods=["PUT"])
@admin_required
def admin_update_site():
    data = request.get_json(force=True, silent=True) or {}
    cfg = app_config.load_config()
    site = dict(cfg.get("site") or {})
    for key, limit in app_config.SITE_TEXT_LIMITS.items():
        if key in data:
            site[key] = str(data.get(key) or "").strip()[:limit]
    if "maintenance" in data:
        site["maintenance"] = bool(data.get("maintenance"))
    if "credit_cost" in data:
        raw = data.get("credit_cost")
        if raw in (None, ""):
            site["credit_cost"] = None
        else:
            try:
                n = int(raw)
            except (TypeError, ValueError):
                return jsonify({"ok": False, "error": "Credit cost must be a whole number."}), 400
            if n < 0 or n > 1000:
                return jsonify({"ok": False, "error": "Credit cost must be between 0 and 1000."}), 400
            site["credit_cost"] = n
    cfg["site"] = site
    app_config.save_config(cfg)
    return jsonify({"ok": True, **_admin_site_view(cfg)})


@app.route("/x/b14", methods=["POST"])
@admin_required
def admin_clear_site_cache():
    """Clear server-side runtime/cache state and Python bytecode caches.

    Browser Cache Storage and service workers are cleared by the admin UI after
    this endpoint succeeds; a normal HTTP server cannot directly delete a
    visitor's browser cache.
    """
    global _provider_status, _dead_provider_ids, _limit_until
    _provider_status.clear()
    _dead_provider_ids.clear()
    _limit_until.clear()
    try:
        rotation_store.clear_all()
    except Exception:
        pass
    try:
        api_monitor.clear_all_provider_health()
    except Exception:
        pass

    removed = 0
    root = os.path.dirname(os.path.abspath(__file__))
    for base, dirs, files in os.walk(root):
        # Never touch user-uploaded/static asset files; only remove Python bytecode caches.
        dirs[:] = [d for d in dirs if d != ".git"]
        if os.path.basename(base) != "__pycache__":
            continue
        for name in files:
            if name.endswith((".pyc", ".pyo")):
                try:
                    os.remove(os.path.join(base, name))
                    removed += 1
                except OSError:
                    pass

    return jsonify({"ok": True, "removed_bytecode": removed})


@app.route("/x/b15", methods=["POST"])
@admin_required
def admin_upload_site_asset(kind):
    """Upload (or replace) the logo or the favicon. multipart/form-data, field name 'file'."""
    if kind not in ("logo", "favicon"):
        return jsonify({"ok": False, "error": "Unknown asset."}), 404
    upload = request.files.get("file")
    if not upload:
        return jsonify({"ok": False, "error": "Choose an image first."}), 400
    data = upload.read(ASSET_MAX_BYTES + 1)
    if len(data) > ASSET_MAX_BYTES:
        return jsonify({"ok": False, "error": "Image is too big — keep it under 2 MB."}), 400
    ext = _detect_image_ext(data)
    if not ext:
        return jsonify({"ok": False, "error": "Use a PNG, JPG, WEBP, GIF, ICO or SVG image."}), 400

    cfg = app_config.load_config()
    site = dict(cfg.get("site") or {})
    field = f"{kind}_file"
    old = site.get(field)
    app_config.ASSET_DIR.mkdir(parents=True, exist_ok=True)
    filename = f"{kind}-{uuid.uuid4().hex[:10]}.{ext}"
    (app_config.ASSET_DIR / filename).write_bytes(data)
    site[field] = filename
    cfg["site"] = site
    app_config.save_config(cfg)
    if old and old != filename:
        try:
            (app_config.ASSET_DIR / old).unlink()
        except OSError:
            pass
    return jsonify({"ok": True, **_admin_site_view(cfg)})


@app.route("/x/b15", methods=["DELETE"])
@admin_required
def admin_remove_site_asset(kind):
    if kind not in ("logo", "favicon"):
        return jsonify({"ok": False, "error": "Unknown asset."}), 404
    cfg = app_config.load_config()
    site = dict(cfg.get("site") or {})
    old = site.pop(f"{kind}_file", None)
    cfg["site"] = site
    app_config.save_config(cfg)
    if old:
        try:
            (app_config.ASSET_DIR / old).unlink()
        except OSError:
            pass
    return jsonify({"ok": True, **_admin_site_view(cfg)})


# ---------------------------------------------------------------------------
# Notifications — the admin sends them, users read them under the bell icon
# ---------------------------------------------------------------------------

@app.route("/x/a5", methods=["GET"])
@login_required
def get_notifications():
    items = notifications_store.for_user(session["user"])
    return jsonify({"items": items, "unread": sum(1 for i in items if not i["read"])})


@app.route("/x/a6", methods=["POST"])
@login_required
def read_notifications():
    data = request.get_json(force=True, silent=True) or {}
    ids = data.get("ids")
    notifications_store.mark_read(session["user"], ids if isinstance(ids, list) else None)
    return jsonify({"ok": True})


@app.route("/x/b16", methods=["GET"])
@admin_required
def admin_list_notifications():
    return jsonify({"items": notifications_store.list_all()})


@app.route("/x/b16", methods=["POST"])
@admin_required
def admin_send_notification():
    data = request.get_json(force=True, silent=True) or {}
    title = (data.get("title") or "").strip()[:100]
    message = (data.get("message") or "").strip()[:1000]
    kind = data.get("kind") or "info"
    target = (data.get("target") or "all").strip()
    if not title:
        return jsonify({"ok": False, "error": "Write a title first."}), 400
    if target != "all" and not users_store.user_exists(target):
        return jsonify({"ok": False, "error": f"No user named '{target}'."}), 404
    notifications_store.add(title, message, kind, target)
    return jsonify({"ok": True, "items": notifications_store.list_all()})


@app.route("/x/b17", methods=["DELETE"])
@admin_required
def admin_delete_notification(notif_id):
    if not notifications_store.delete(notif_id):
        return jsonify({"ok": False, "error": "Notification not found."}), 404
    return jsonify({"ok": True, "items": notifications_store.list_all()})


# ---------------------------------------------------------------------------
# Resource links — a small admin-curated shortcut list (Facebook group,
# Telegram group, a new tool's link, etc.) shown to every signed-in user.
# Fully backend-driven: the admin adds/edits/removes entries from the
# /admintools dashboard and they appear in the app immediately, no redeploy.
# ---------------------------------------------------------------------------

def _normalize_link_url(url: str) -> str:
    url = (url or "").strip()
    if url and not re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", url):
        url = "https://" + url
    return url


@app.route("/x/a7")
def get_links():
    if not (session.get("user") or session.get("is_admin")):
        return jsonify({"error": "auth_required"}), 401
    blocked = _maintenance_block()
    if blocked:
        return blocked
    cfg = app_config.load_config()
    return jsonify({"links": cfg.get("resource_links", [])})


@app.route("/x/b18", methods=["GET"])
@admin_required
def admin_get_links():
    cfg = app_config.load_config()
    return jsonify({"links": cfg.get("resource_links", [])})


@app.route("/x/b18", methods=["POST"])
@admin_required
def admin_add_link():
    data = request.get_json(force=True, silent=True) or {}
    label = (data.get("label") or "").strip()
    url = _normalize_link_url(data.get("url") or "")
    if not label or not url:
        return jsonify({"ok": False, "error": "Enter both a name and a link."}), 400
    cfg = app_config.load_config()
    links = list(cfg.get("resource_links", []))
    links.append({"id": uuid.uuid4().hex, "label": label, "url": url})
    cfg["resource_links"] = links
    app_config.save_config(cfg)
    return jsonify({"ok": True, "links": links})


@app.route("/x/b19", methods=["PUT"])
@admin_required
def admin_update_link(link_id):
    data = request.get_json(force=True, silent=True) or {}
    cfg = app_config.load_config()
    links = list(cfg.get("resource_links", []))
    target = next((l for l in links if l.get("id") == link_id), None)
    if not target:
        return jsonify({"ok": False, "error": "Link not found."}), 404
    if "label" in data:
        label = (data.get("label") or "").strip()
        if not label:
            return jsonify({"ok": False, "error": "Name can't be empty."}), 400
        target["label"] = label
    if "url" in data:
        url = _normalize_link_url(data.get("url") or "")
        if not url:
            return jsonify({"ok": False, "error": "Link can't be empty."}), 400
        target["url"] = url
    cfg["resource_links"] = links
    app_config.save_config(cfg)
    return jsonify({"ok": True, "links": links})


@app.route("/x/b19", methods=["DELETE"])
@admin_required
def admin_delete_link(link_id):
    cfg = app_config.load_config()
    links = [l for l in cfg.get("resource_links", []) if l.get("id") != link_id]
    cfg["resource_links"] = links
    app_config.save_config(cfg)
    return jsonify({"ok": True, "links": links})


# ---------------------------------------------------------------------------
# Generation rotation order — each user's PRIMARY pool is isolated and
# contains that user's admin-assigned keys + user-added keys. The GLOBAL
# fallback pool is separate and is used only after a primary key fails.
# ---------------------------------------------------------------------------

def _health_tier(p: dict, now: float):
    """0 = healthy/not yet used, 1 = ordinary temporary error, 2 = active cooldown.
    Dead providers are never selected."""
    pid = p["id"]
    if pid in _dead_provider_ids:
        return None
    status = _provider_status.get(pid) or api_monitor.get_provider_health(pid) or {}
    state = status.get("state", "unchecked")
    if state == "dead":
        _dead_provider_ids.add(pid)
        return None
    if state == "limit":
        cooldown = _limit_until.get(pid, status.get("cooldown_until", 0))
        if cooldown > now:
            _limit_until[pid] = cooldown
        return 1 if cooldown <= now else 2
    if state == "error":
        return 1
    return 0


def _rotation_pool_id(providers: list) -> str:
    """Return a stable identity for a rotation pool.

    Primary pools are isolated by username; the global fallback pool is shared.
    The identity deliberately does NOT include the current provider IDs, because
    adding/removing a key must not reset the user's round-robin cursor.
    """
    return "global:fallback" if providers and all(
        (p.get("scope") == "global") for p in providers
    ) else "primary"


def _rotation_order(providers: list, advance: bool = True, pool_id: str | None = None) -> list:
    """Reserve a strict FIFO round-robin order across all Passenger workers.

    The old implementation stored the queue only in process memory. On a
    multi-worker host (such as Passenger/cPanel), different requests could land
    on different workers and every worker would start at key #1 again. The
    persistent rotation store fixes that by reserving the next key atomically
    in SQLite.

    Returns all currently eligible providers in queue order. The caller normally
    consumes only the first provider for a primary request; fallback callers may
    continue through the remaining eligible keys if the first fallback fails.
    """
    now = time.time()
    usable = app_config.usable_providers({"providers": providers})
    if not usable:
        return []

    by_id = {str(p.get("id")): p for p in usable if p.get("id")}
    if not by_id:
        return []

    key = pool_id or _rotation_pool_id(usable)

    def eligible(pid: str) -> bool:
        tier = _health_tier(by_id[pid], now)
        return tier in (0, 1)

    try:
        selected_id, ordered_ids = rotation_store.reserve(
            key,
            list(by_id.keys()),
            eligible,
            advance=advance,
        )
    except Exception:
        # Rotation must never take the site down. If persistent state is
        # temporarily unavailable, serialize within this worker as a safe
        # emergency fallback and keep the request functional.
        with _rotation_lock:
            queue = list(by_id.keys())
            eligible_ids = [pid for pid in queue if eligible(pid)]
            if not eligible_ids:
                return []
            selected_id = eligible_ids[0]
            ordered_ids = eligible_ids
    if not ordered_ids:
        return []
    return [by_id[pid] for pid in ordered_ids if pid in by_id]

def _user_provider_orders(username: str, cfg: dict) -> tuple[list, list]:
    primary_pool = get_user_primary_pool(username, cfg)
    fallback_pool = get_global_fallback_pool(cfg)
    # Reserve a primary key immediately because every request must start on a
    # different key in the user's own/assigned pool.  Do NOT advance the global
    # fallback cursor here: global keys should rotate only when fallback is
    # actually needed after the primary pool is exhausted.
    primary = _rotation_order(primary_pool, pool_id=f"user:{username}:primary")
    return primary, fallback_pool


def _no_keys_response(primary: list, fallback: list | None = None):
    fallback = fallback or []
    if primary or fallback:
        return jsonify({"error": "All available Gemini providers are temporarily unavailable — please try again shortly."}), 503
    return jsonify({"error": "No Gemini API keys are available yet — add your own key in Settings, or ask the admin to assign/add one."}), 400


def _fallback_user_lock(username: str) -> threading.BoundedSemaphore:
    with _fallback_user_locks_guard:
        lock = _fallback_user_locks.get(username)
        if lock is None:
            lock = threading.BoundedSemaphore(1)
            _fallback_user_locks[username] = lock
        return lock


def _call_with_fallback_guard(username: str, provider: dict, fn):
    """Run one global-fallback provider call with lightweight shared-pool protection."""
    if not _global_fallback_semaphore.acquire(blocking=False):
        return None, "Global fallback is busy"
    user_lock = _fallback_user_lock(username)
    if not user_lock.acquire(blocking=False):
        _global_fallback_semaphore.release()
        return None, "User fallback capacity is busy"
    try:
        return fn(), None
    finally:
        user_lock.release()
        _global_fallback_semaphore.release()


def _generate_one(username: str, primary_providers: list, fallback_providers: list, glass_type, style, canvas_size, tint, bg, extra_prompt, label, intensity, request_id: str):
    """Use exactly one normal rotating provider, then admin-only fallback.

    Normal rotation pool = admin-assigned keys for this user + the user's own keys.
    The first provider in primary_providers is the reserved serial slot for this
    request. If that provider fails, we immediately switch to the separate global
    admin fallback pool. We never consume another user's key as fallback.
    """
    log = []
    if not primary_providers and not fallback_providers:
        return {"status": "failed", "svg": None, "error": "No provider available", "log": log, "label": label}

    last_err = "No provider available"
    started = time.time()

    # IMPORTANT: primary_providers is already a reserved round-robin order.
    # Only the first key belongs to this request's normal rotation slot.
    primary = primary_providers[:1]
    fallback = fallback_providers

    def attempt_one(p, is_fallback=False):
        nonlocal last_err
        if time.time() - started > GENERATION_TIME_BUDGET:
            return None
        if _health_tier(p, time.time()) not in (0, 1):
            last_err = f"{p.get('label') or p.get('type')} is temporarily unavailable"
            log.append(f"⚠ [{label}] {last_err}.")
            return None
        lbl = p.get("label") or p["type"]
        prefix = "admin fallback " if is_fallback else ""
        log.append(f"→ [{label}] Generating with {prefix}{lbl}…")
        attempt_id = api_monitor.begin_attempt(
            request_id, username=username, provider=p,
            model=p.get("model") or app_config.DEFAULT_GEMINI_MODEL,
            is_fallback=is_fallback,
        )
        try:
            def do_call():
                return ai_client.generate_glass_svg(
                    p, glass_type, style, canvas_size, tint, bg,
                    extra_prompt=extra_prompt, intensity_label=intensity,
                )
            if is_fallback:
                svg, guard_error = _call_with_fallback_guard(username, p, do_call)
                if guard_error:
                    last_err = guard_error
                    api_monitor.finish_attempt(attempt_id, status="skipped", error_code="capacity", error_message=guard_error)
                    log.append(f"⚠ [{label}] {lbl} skipped ({guard_error}).")
                    return None
            else:
                svg = do_call()
            api_monitor.finish_attempt(attempt_id, status="success")
            _set_status(p["id"], "live", "Generated successfully.")
            log.append(f"✅ [{label}] {lbl} created glass design successfully.")
            app_config.OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
            path = app_config.OUTPUT_DIR / f"{datetime.now().strftime('%Y%m%d-%H%M%S')}_{uuid.uuid4().hex[:6]}_glass.svg"
            path.write_text(svg, encoding="utf-8")
            api_monitor.finish_request(request_id, status="success", provider_id=p.get("id", ""), provider_label=lbl)
            return {"status": "done", "svg": svg, "error": None, "log": log, "label": label}
        except ai_client.InvalidSVGError as exc:
            last_err = str(exc)
            api_monitor.finish_attempt(attempt_id, status="invalid_response", error_code="invalid_response", error_message=last_err)
            log.append(f"⚠ [{label}] {lbl} returned no usable SVG.")
        except Exception as exc:
            msg = str(exc)
            last_err = msg
            state = _classify_error(p["id"], msg)
            api_monitor.finish_attempt(attempt_id, status=state, error_code=state, error_message=msg)
            log.append(f"⚠ [{label}] {lbl} failed ({state}).")
        return None

    # 1) Exactly one serially-rotated normal key.
    if primary:
        result = attempt_one(primary[0], False)
        if result is not None:
            return result
        log.append(f"↪ [{label}] Normal API key failed — switching to admin server fallback…")

    # 2) Fallback is ONLY the backend/global admin pool. User-owned and
    #    user-assigned keys are never used in this fallback stage.
    if fallback:
        rotated_fallback = _rotation_order(fallback, pool_id="global:fallback")
        for p in rotated_fallback:
            result = attempt_one(p, True)
            if result is not None:
                return result

    log.append(f"❌ [{label}] Generation failed: {last_err}")
    api_monitor.finish_request(request_id, status="failed", error_code="provider_exhausted", error_message=last_err)
    return {"status": "failed", "svg": None, "error": last_err, "log": log, "label": label}


@app.route("/x/a12", methods=["POST"])
@login_required
def generate():
    data = request.get_json(force=True, silent=True) or {}
    cfg = app_config.load_config()
    username = session["user"]

    glass_type = data.get("glass_type", app_config.DEFAULT_GLASS_TYPE)
    style = data.get("glass_style", app_config.DEFAULT_GLASS_STYLE)
    # Canvas is fixed server-side at 1000×1000; never trust a client-supplied size.
    canvas_size = app_config.DEFAULT_CANVAS_SIZE
    tint = (data.get("tint") or "auto").strip()
    bg = data.get("bg", app_config.DEFAULT_BACKGROUND)
    extra_prompt = (data.get("extra_prompt") or "").strip()
    intensity = data.get("intensity", app_config.DEFAULT_HIGHLIGHT_INTENSITY)
    # One Generate click creates exactly one design. Bulk/variant generation is
    # intentionally disabled; users can start another independent request while
    # an earlier request is still running.
    variant_count = 1

    # IMPORTANT: do not reserve/advance the primary rotation here.
    # The job below is the single place where Generate reserves its serial slot.
    # Reserving once here and again in the job would skip every other key
    # (1,2,3,4 would effectively become 1,3,1,3).
    # Normal users must explicitly add at least one of their own Gemini API
    # keys before Generate is allowed. This gate is intentionally separate
    # from provider rotation/fallback so those systems continue to work
    # exactly as before once the requirement is met.
    own_pool = get_user_owned_pool(username, cfg)
    if not session.get("is_admin") and not own_pool:
        rid = api_monitor.begin_request(username=username, endpoint="/x/a12", kind="generate", model=app_config.get_gemini_model(cfg))
        api_monitor.finish_request(rid, status="blocked_no_provider", error_code="own_key_required", error_message="User must add at least one own Gemini API key before generating.")
        return jsonify({
            "error": "Add at least one of your own Gemini API keys in Settings before generating.",
            "need_own_key": True,
        }), 400

    primary_pool = get_user_primary_pool(username, cfg)
    fallback = get_global_fallback_pool(cfg)
    if not primary_pool and not fallback:
        rid = api_monitor.begin_request(username=username, endpoint="/x/a12", kind="generate", model=app_config.get_gemini_model(cfg))
        api_monitor.finish_request(rid, status="blocked_no_provider", error_code="no_provider", error_message="No Gemini API keys are available.")
        return _no_keys_response(primary_pool, fallback)

    cost = app_config.get_credit_cost(cfg)
    balance = users_store.get_credits(username) or 0
    if balance < cost:
        return jsonify({
            "error": f"Not enough credits — each design costs {cost}, you have {balance}.",
            "credits": balance,
        }), 402

    # Credits are only spent on designs that actually come back, so how many designs the balance
    # can cover is known up front; the rest are reported as "out of credits" like before.
    affordable = variant_count if cost <= 0 else min(variant_count, balance // cost)

    results = [None] * variant_count
    jobs = []  # (index, label, history entry id, isolated primary pool, isolated fallback pool)
    for i in range(variant_count):
        label = f"Concept {i + 1}" if variant_count > 1 else "Glass Design"
        if i >= affordable:
            rid = api_monitor.begin_request(username=username, endpoint="/x/a12", kind="generate", model=app_config.get_gemini_model(cfg))
            api_monitor.finish_request(rid, status="blocked_credit", error_code="insufficient_credits", error_message="Skipped because the user did not have enough credits.")
            results[i] = {
                "status": "failed", "svg": None, "error": "Out of credits.",
                "log": [f"⚠ [{label}] Skipped — out of credits."], "label": label,
            }
            continue
        entry_id = history_store.add_entry(
            username, label=label, glass_type=glass_type, glass_style=style, kind="generate",
            canvas_size=canvas_size, bg=bg, extra_prompt=extra_prompt, intensity=intensity, tint=tint,
        )
        request_id = api_monitor.begin_request(username=username, endpoint="/x/a12", kind="generate", model=app_config.get_gemini_model(cfg), history_id=entry_id)
        # Reserve a distinct starting key for every parallel design from the
        # same user's primary pool. Global fallback rotation is shared separately.
        jobs.append((
            i, label, entry_id, request_id,
            _rotation_order(get_user_primary_pool(username, cfg), pool_id=f"user:{username}:primary"),
            get_global_fallback_pool(cfg),
        ))

    def run(job):
        _, label, _, request_id, primary_providers, fallback_providers = job
        return _generate_one(username, primary_providers, fallback_providers, glass_type, style, canvas_size, tint, bg, extra_prompt, label, intensity, request_id)

    if len(jobs) > 1:
        with ThreadPoolExecutor(max_workers=len(jobs)) as pool:
            outputs = list(pool.map(run, jobs))
    else:
        outputs = [run(j) for j in jobs]

    for (i, _label, entry_id, _request_id, _primary, _fallback), result in zip(jobs, outputs):
        result["history_id"] = entry_id
        # Only spend credits on a design that actually came back — a failed
        # provider call (bad key, rate limit, etc.) shouldn't cost the user anything.
        if result["status"] == "done":
            users_store.deduct_credits(username, cost)
            history_store.mark_done(username, entry_id, result["svg"])
        else:
            history_store.mark_failed(username, entry_id, result.get("error") or "Generation failed.")
        results[i] = result

    return jsonify({"results": results, "credits": users_store.get_credits(username)})


@app.route("/x/a13", methods=["POST"])
@login_required
def refine():
    data = request.get_json(force=True, silent=True) or {}
    cfg = app_config.load_config()
    username = session["user"]

    previous_svg = data.get("svg", "")
    instruction = (data.get("instruction") or "").strip()
    glass_type = data.get("glass_type", app_config.DEFAULT_GLASS_TYPE)
    style = data.get("glass_style", app_config.DEFAULT_GLASS_STYLE)
    # Canvas is fixed server-side at 1000×1000; never trust a client-supplied size.
    canvas_size = app_config.DEFAULT_CANVAS_SIZE
    tint = (data.get("tint") or "auto").strip()
    bg = data.get("bg", app_config.DEFAULT_BACKGROUND)
    extra_prompt = (data.get("extra_prompt") or "").strip()
    intensity = data.get("intensity", app_config.DEFAULT_HIGHLIGHT_INTENSITY)

    if not previous_svg or not instruction:
        return jsonify({"error": "Missing svg or instruction."}), 400

    cost = app_config.get_credit_cost(cfg)
    balance = users_store.get_credits(username) or 0
    if balance < cost:
        rid = api_monitor.begin_request(username=username, endpoint="/x/a13", kind="refine", model=app_config.get_gemini_model(cfg))
        api_monitor.finish_request(rid, status="blocked_credit", error_code="insufficient_credits", error_message="Refine skipped because the user did not have enough credits.")
        return jsonify({
            "status": "failed",
            "error": f"Not enough credits — refining costs {cost}, you have {balance}.",
            "credits": balance,
        }), 402

    # Refine follows the same own-key requirement as Generate.
    own_pool = get_user_owned_pool(username, cfg)
    if not session.get("is_admin") and not own_pool:
        rid = api_monitor.begin_request(username=username, endpoint="/x/a13", kind="refine", model=app_config.get_gemini_model(cfg))
        api_monitor.finish_request(rid, status="blocked_no_provider", error_code="own_key_required", error_message="User must add at least one own Gemini API key before refining.")
        return jsonify({
            "error": "Add at least one of your own Gemini API keys in Settings before refining.",
            "need_own_key": True,
        }), 400

    primary, fallback = _user_provider_orders(username, cfg)
    if not primary and not fallback:
        rid = api_monitor.begin_request(username=username, endpoint="/x/a13", kind="refine", model=app_config.get_gemini_model(cfg))
        api_monitor.finish_request(rid, status="blocked_no_provider", error_code="no_provider", error_message="No Gemini API keys are available.")
        return _no_keys_response(primary, fallback)
    # Only the reserved serial slot is the normal provider for this request.
    providers = primary[:1]

    entry_id = history_store.add_entry(
        username, label="Refine", glass_type=glass_type, glass_style=style, kind="refine",
        canvas_size=canvas_size, bg=bg, extra_prompt=extra_prompt, intensity=intensity, tint=tint,
    )
    request_id = api_monitor.begin_request(username=username, endpoint="/x/a13", kind="refine", model=app_config.get_gemini_model(cfg), history_id=entry_id)
    log = []
    last_err = "No provider available"
    started = time.time()
    for p in providers:
        if time.time() - started > GENERATION_TIME_BUDGET:
            break
        lbl = p.get("label") or p["type"]
        log.append(f"→ Refining with {lbl}…")
        attempt_id = api_monitor.begin_attempt(request_id, username=username, provider=p, model=p.get("model") or app_config.DEFAULT_GEMINI_MODEL, is_fallback=False)
        try:
            svg = ai_client.refine_glass_svg(
                p, previous_svg, instruction, glass_type, style, canvas_size, tint, bg, extra_prompt=extra_prompt, intensity_label=intensity
            )
            api_monitor.finish_attempt(attempt_id, status="success")
            _set_status(p["id"], "live", "Generated successfully.")
            users_store.deduct_credits(username, cost)
            log.append(f"✅ {lbl} applied the change.")
            history_store.mark_done(username, entry_id, svg)
            api_monitor.finish_request(request_id, status="success", provider_id=p.get("id", ""), provider_label=lbl)
            return jsonify({"status": "done", "svg": svg, "log": log, "credits": users_store.get_credits(username), "history_id": entry_id})
        except ai_client.InvalidSVGError as exc:
            last_err = str(exc)
            api_monitor.finish_attempt(attempt_id, status="invalid_response", error_code="invalid_response", error_message=last_err)
            log.append(f"⚠ {lbl} returned no usable SVG. Trying next…")
        except Exception as exc:
            msg = str(exc)
            last_err = msg
            state = _classify_error(p["id"], msg)
            api_monitor.finish_attempt(attempt_id, status=state, error_code=state, error_message=msg)
            if state in ("dead", "limit"):
                log.append(f"⚠ {lbl} skipped ({state}). Trying next…")
            else:
                log.append(f"⚠ {lbl} error: {msg[:150]}")

    # The single normal rotation key failed. Only now may the admin server fallback be used.
    if fallback:
        log.append("↪ Primary pool exhausted — trying global fallback pool…")
        rotated_fallback = _rotation_order(fallback, pool_id="global:fallback")
        for p in rotated_fallback:
            if time.time() - started > GENERATION_TIME_BUDGET:
                break
            lbl = p.get("label") or p["type"]
            log.append(f"→ Refining with global fallback {lbl}…")
            attempt_id = api_monitor.begin_attempt(request_id, username=username, provider=p, model=p.get("model") or app_config.DEFAULT_GEMINI_MODEL, is_fallback=True)
            try:
                def do_refine():
                    return ai_client.refine_glass_svg(
                        p, previous_svg, instruction, glass_type, style, canvas_size, tint, bg, extra_prompt=extra_prompt, intensity_label=intensity
                    )
                svg, guard_error = _call_with_fallback_guard(username, p, do_refine)
                if guard_error:
                    last_err = guard_error
                    api_monitor.finish_attempt(attempt_id, status="skipped", error_code="capacity", error_message=guard_error)
                    log.append(f"⚠ {lbl} skipped ({guard_error}). Trying next…")
                    continue
                api_monitor.finish_attempt(attempt_id, status="success")
                _set_status(p["id"], "live", "Generated successfully.")
                users_store.deduct_credits(username, cost)
                log.append(f"✅ {lbl} applied the change.")
                history_store.mark_done(username, entry_id, svg)
                api_monitor.finish_request(request_id, status="success", provider_id=p.get("id", ""), provider_label=lbl)
                return jsonify({"status": "done", "svg": svg, "log": log, "credits": users_store.get_credits(username), "history_id": entry_id})
            except ai_client.InvalidSVGError as exc:
                last_err = str(exc)
                api_monitor.finish_attempt(attempt_id, status="invalid_response", error_code="invalid_response", error_message=last_err)
                log.append(f"⚠ {lbl} returned no usable SVG. Trying next…")
            except Exception as exc:
                msg = str(exc)
                last_err = msg
                state = _classify_error(p["id"], msg)
                api_monitor.finish_attempt(attempt_id, status=state, error_code=state, error_message=msg)
                log.append(f"⚠ {lbl} skipped ({state}). Trying next…")

    api_monitor.finish_request(request_id, status="failed", error_code="provider_exhausted", error_message=last_err)
    history_store.mark_failed(username, entry_id, last_err)
    return jsonify({"status": "failed", "error": last_err, "log": log, "history_id": entry_id}), 502


# ---------------------------------------------------------------------------
# API Monitor — passive observability only; no provider health-check calls.
# ---------------------------------------------------------------------------

@app.route("/x/b20", methods=["GET"])
@admin_required
def admin_monitor():
    try:
        hours = max(1, min(720, int(request.args.get("hours", 24))))
    except (TypeError, ValueError):
        hours = 24
    try:
        limit = max(1, min(500, int(request.args.get("limit", 100))))
    except (TypeError, ValueError):
        limit = 100
    return jsonify({
        "summary": api_monitor.summary(hours=hours),
        "requests": api_monitor.list_requests(hours=hours, limit=limit, username=str(request.args.get("username") or "").strip(), status=str(request.args.get("status") or "").strip()),
        "users": api_monitor.user_health(hours=hours),
        "providers": api_monitor.provider_health_stats(hours=hours),
        "retention_days": api_monitor.RETENTION_DAYS,
    })


@app.route("/x/b21", methods=["GET"])
@admin_required
def admin_monitor_detail(request_id):
    item = api_monitor.request_detail(request_id)
    if not item:
        return jsonify({"error": "Request not found."}), 404
    return jsonify(item)


@app.route("/x/b22", methods=["POST"])
@admin_required
def admin_monitor_clear():
    try:
        deleted = api_monitor.clear_all_data()
        return jsonify({"ok": True, "deleted": deleted})
    except Exception as exc:
        return jsonify({"ok": False, "error": f"Could not clear monitoring data: {exc}"}), 500


# ---------------------------------------------------------------------------
# Generation history — auto-clears itself after HISTORY_TTL_HOURS (default 24)
# ---------------------------------------------------------------------------

@app.route("/x/a8", methods=["GET"])
@login_required
def get_history():
    return jsonify({"entries": history_store.list_entries(session["user"]), "ttl_hours": history_store.HISTORY_TTL_HOURS})


@app.route("/x/a9", methods=["POST"])
@login_required
def clear_history():
    history_store.clear_entries(session["user"])
    return jsonify({"ok": True})


# ---------------------------------------------------------------------------
# Export
# ---------------------------------------------------------------------------

def _safe_stem(text: str, fallback: str) -> str:
    text = re.sub(r"[^A-Za-z0-9_-]+", "_", (text or "").strip()).strip("_")
    return text or fallback


@app.route("/x/a14", methods=["POST"])
@login_required
def export_zip():
    data = request.get_json(force=True, silent=True) or {}
    items = data.get("items", [])  # [{svg, label}]
    if not items:
        return jsonify({"error": "Nothing to export."}), 400

    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
        for idx, it in enumerate(items, start=1):
            svg = it.get("svg") or ""
            if not svg:
                continue
            label = _safe_stem(it.get("label", ""), "")
            stem = f"{idx:02d}_{label}" if label else f"{idx:02d}_glass"
            zf.writestr(f"{stem}.svg", svg)
    buf.seek(0)
    return send_file(buf, mimetype="application/zip", as_attachment=True,
                      download_name=f"glass_designs_{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip")


if __name__ == "__main__":
    port = int(os.environ.get("PORT", 5000))
    # Debug mode exposes the interactive Werkzeug debugger (arbitrary code
    # execution) to anyone who can trigger a stack trace — keep it off unless
    # explicitly requested for local development.
    debug = os.environ.get("FLASK_DEBUG", "0") == "1"
    app.run(host="0.0.0.0", port=port, debug=debug)