"""
Core infrastructure: database, config, email, and shared utilities.
"""

import hashlib
import hmac
import logging
import os
import random
import re
import secrets
import unicodedata
from contextvars import ContextVar
from datetime import UTC, datetime, timedelta
from functools import wraps
from pathlib import Path

import aiosqlite
import resend
from dotenv import load_dotenv

# Cap all Resend API calls at 10 s — the default RequestsClient timeout is 30 s.
# These calls run synchronously on the event loop thread; a shorter cap limits stalls.
_RESEND_TIMEOUT_SECONDS = 10
resend.default_http_client = resend.RequestsClient(timeout=_RESEND_TIMEOUT_SECONDS)
from quart import g, make_response, render_template, request, session  # noqa: E402

load_dotenv()

# Repo root: web/src/padelnomics/core.py → 4 levels up
REPO_ROOT = Path(__file__).parents[3]


def _env(key: str, default: str) -> str:
    """Get env var, treating empty string same as unset."""
    return os.getenv(key, "") or default


# =============================================================================
# Configuration
# =============================================================================


class Config:
    APP_NAME: str = _env("APP_NAME", "Padelnomics")
    SECRET_KEY: str = _env("SECRET_KEY", "change-me-in-production")
    BASE_URL: str = _env("BASE_URL", "http://localhost:5000")
    DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true"

    DATABASE_PATH: str = os.getenv("DATABASE_PATH", "data/app.db")

    MAGIC_LINK_EXPIRY_MINUTES: int = int(os.getenv("MAGIC_LINK_EXPIRY_MINUTES", "15"))
    SESSION_LIFETIME_DAYS: int = int(os.getenv("SESSION_LIFETIME_DAYS", "30"))

    PAYMENT_PROVIDER: str = _env("PAYMENT_PROVIDER", "paddle").lower()

    PADDLE_API_KEY: str = os.getenv("PADDLE_API_KEY", "")
    PADDLE_CLIENT_TOKEN: str = os.getenv("PADDLE_CLIENT_TOKEN", "")
    PADDLE_WEBHOOK_SECRET: str = os.getenv("PADDLE_WEBHOOK_SECRET", "")
    PADDLE_ENVIRONMENT: str = _env("PADDLE_ENVIRONMENT", "sandbox")

    STRIPE_SECRET_KEY: str = os.getenv("STRIPE_SECRET_KEY", "") or os.getenv("STRIPE_API_PRIVATE_KEY", "")
    STRIPE_PUBLISHABLE_KEY: str = os.getenv("STRIPE_PUBLISHABLE_KEY", "") or os.getenv("STRIPE_API_PUBLIC_KEY", "")
    STRIPE_WEBHOOK_SECRET: str = os.getenv("STRIPE_WEBHOOK_SECRET", "")

    UMAMI_API_URL: str = os.getenv("UMAMI_API_URL", "https://umami.padelnomics.io")
    UMAMI_API_TOKEN: str = os.getenv("UMAMI_API_TOKEN", "")
    UMAMI_WEBSITE_ID: str = "4474414b-58d6-4c6e-89a1-df5ea1f49d70"

    # SEO metrics sync
    GSC_SERVICE_ACCOUNT_PATH: str = os.getenv("GSC_SERVICE_ACCOUNT_PATH", "")
    GSC_SITE_URL: str = os.getenv("GSC_SITE_URL", "")
    BING_WEBMASTER_API_KEY: str = os.getenv("BING_WEBMASTER_API_KEY", "")
    BING_SITE_URL: str = os.getenv("BING_SITE_URL", "")

    RESEND_API_KEY: str = os.getenv("RESEND_API_KEY", "")
    EMAIL_FROM: str = _env("EMAIL_FROM", "hello@padelnomics.io")
    LEADS_EMAIL: str = _env("LEADS_EMAIL", "leads@padelnomics.io")
    ADMIN_EMAILS: list[str] = [
        e.strip().lower() for e in os.getenv("ADMIN_EMAILS", "").split(",") if e.strip()
    ]
    RESEND_AUDIENCE_PLANNER: str = os.getenv("RESEND_AUDIENCE_PLANNER", "")
    RESEND_WEBHOOK_SECRET: str = os.getenv("RESEND_WEBHOOK_SECRET", "")
    WAITLIST_MODE: bool = os.getenv("WAITLIST_MODE", "false").lower() == "true"

    RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
    RATE_LIMIT_WINDOW_SECONDS: int = int(os.getenv("RATE_LIMIT_WINDOW", "60"))

    PLAN_FEATURES: dict = {
        "free": ["basic"],
        "starter": ["basic", "export"],
        "pro": ["basic", "export", "api", "priority_support"],
    }

    PLAN_LIMITS: dict = {
        "free": {"items": 100, "api_calls": 1000},
        "starter": {"items": 1000, "api_calls": 10000},
        "pro": {"items": -1, "api_calls": -1},  # -1 = unlimited
    }


config = Config()


def setup_logging() -> None:
    """Configure root logger. Call once from each entry point (app, worker, scripts)."""
    level_name = os.environ.get("LOG_LEVEL", "DEBUG" if config.DEBUG else "INFO")
    level = getattr(logging, level_name.upper(), logging.INFO)
    logging.basicConfig(
        level=level,
        format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
    )
    logging.getLogger("granian").setLevel(logging.WARNING)
    logging.getLogger("granian.access").setLevel(logging.WARNING)
    logging.getLogger("hypercorn").setLevel(logging.WARNING)
    logging.getLogger("hypercorn.error").setLevel(logging.WARNING)
    logging.getLogger("hypercorn.access").setLevel(logging.WARNING)
    logging.getLogger("asyncio").setLevel(logging.WARNING)
    logging.getLogger("aiosqlite").setLevel(logging.WARNING)


logger = logging.getLogger(__name__)

# =============================================================================
# Datetime helpers
# =============================================================================


def utcnow() -> datetime:
    """Timezone-aware UTC now (replaces deprecated datetime.utcnow())."""
    return datetime.now(UTC)


def utcnow_iso() -> str:
    """UTC now as naive ISO string for SQLite TEXT columns.

    Produces YYYY-MM-DD HH:MM:SS (space separator, no +00:00 suffix) to match
    SQLite's native datetime('now') format so lexicographic SQL comparisons work.
    """
    return datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S")

# =============================================================================
# Database
# =============================================================================

_db: aiosqlite.Connection | None = None


async def init_db(path: str = None) -> None:
    """Initialize database connection with WAL mode."""
    global _db
    db_path = path or config.DATABASE_PATH
    Path(db_path).parent.mkdir(parents=True, exist_ok=True)

    _db = await aiosqlite.connect(db_path)
    _db.row_factory = aiosqlite.Row

    await _db.execute("PRAGMA journal_mode=WAL")
    await _db.execute("PRAGMA foreign_keys=ON")
    _BUSY_TIMEOUT_MS = 5000
    await _db.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
    await _db.execute("PRAGMA synchronous=NORMAL")
    await _db.execute("PRAGMA cache_size=-64000")
    await _db.execute("PRAGMA temp_store=MEMORY")
    await _db.execute("PRAGMA mmap_size=268435456")
    await _db.commit()


async def close_db() -> None:
    """Close database connection."""
    global _db
    if _db:
        await _db.execute("PRAGMA wal_checkpoint(TRUNCATE)")
        await _db.close()
        _db = None


async def get_db() -> aiosqlite.Connection:
    """Get database connection."""
    if _db is None:
        await init_db()
    return _db


async def fetch_one(sql: str, params: tuple = ()) -> dict | None:
    """Fetch a single row as dict."""
    db = await get_db()
    async with db.execute(sql, params) as cursor:
        row = await cursor.fetchone()
        return dict(row) if row else None


async def fetch_all(sql: str, params: tuple = ()) -> list[dict]:
    """Fetch all rows as list of dicts."""
    db = await get_db()
    async with db.execute(sql, params) as cursor:
        rows = await cursor.fetchall()
        return [dict(row) for row in rows]


async def count_where(table_where: str, params: tuple = ()) -> int:
    """Count rows matching a condition. Compresses the fetch_one + null-check pattern.

    Usage: await count_where("users WHERE deleted_at IS NULL")
    """
    row = await fetch_one(f"SELECT COUNT(*) AS n FROM {table_where}", params)
    return row["n"] if row else 0


async def execute(sql: str, params: tuple = ()) -> int:
    """Execute SQL and return lastrowid."""
    db = await get_db()
    async with db.execute(sql, params) as cursor:
        await db.commit()
        return cursor.lastrowid


async def execute_many(sql: str, params_list: list[tuple]) -> None:
    """Execute SQL for multiple parameter sets."""
    db = await get_db()
    await db.executemany(sql, params_list)
    await db.commit()


class transaction:
    """Async context manager for transactions."""

    async def __aenter__(self):
        self.db = await get_db()
        return self.db

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        if exc_type is None:
            await self.db.commit()
        else:
            await self.db.rollback()
        return False


# =============================================================================
# Email
# =============================================================================

EMAIL_ADDRESSES = {
    "transactional": "Padelnomics <hello@notifications.padelnomics.io>",
    "leads": "Padelnomics Leads <leads@notifications.padelnomics.io>",
    "nurture": "Padelnomics <coach@notifications.padelnomics.io>",
    # Separate sending domain isolates outreach reputation from transactional emails.
    "outreach": "Padelnomics <hello@hello.padelnomics.io>",
}


# ──────────────────────────────────────────────────────────────────────────────
# Input validation helpers
# ──────────────────────────────────────────────────────────────────────────────

_DISPOSABLE_EMAIL_DOMAINS: frozenset[str] = frozenset(
    {
        # Germany / Austria / Switzerland common disposables
        "byom.de",
        "trash-mail.de",
        "spamgourmet.de",
        "mailnull.com",
        "spambog.de",
        "trashmail.de",
        "wegwerf-email.de",
        "spam4.me",
        "yopmail.de",
        # Global well-known disposables
        "guerrillamail.com",
        "guerrillamail.net",
        "guerrillamail.org",
        "guerrillamail.biz",
        "guerrillamail.de",
        "guerrillamail.info",
        "guerrillamailblock.com",
        "grr.la",
        "spam4.me",
        "mailinator.com",
        "mailinator.net",
        "mailinator.org",
        "tempmail.com",
        "temp-mail.org",
        "tempmail.net",
        "tempmail.io",
        "10minutemail.com",
        "10minutemail.net",
        "10minutemail.org",
        "10minemail.com",
        "10minutemail.de",
        "yopmail.com",
        "yopmail.fr",
        "yopmail.net",
        "sharklasers.com",
        "guerrillamail.info",
        "grr.la",
        "throwam.com",
        "throwam.net",
        "maildrop.cc",
        "dispostable.com",
        "discard.email",
        "discardmail.com",
        "discardmail.de",
        "spamgourmet.com",
        "spamgourmet.net",
        "trashmail.at",
        "trashmail.com",
        "trashmail.io",
        "trashmail.me",
        "trashmail.net",
        "trashmail.org",
        "trash-mail.at",
        "trash-mail.com",
        "fakeinbox.com",
        "fakemail.fr",
        "fakemail.net",
        "getnada.com",
        "getairmail.com",
        "bccto.me",
        "chacuo.net",
        "crapmail.org",
        "crap.email",
        "spamherelots.com",
        "spamhereplease.com",
        "throwam.com",
        "throwam.net",
        "spamspot.com",
        "spamthisplease.com",
        "filzmail.com",
        "mytemp.email",
        "mynullmail.com",
        "mailnesia.com",
        "mailnull.com",
        "no-spam.ws",
        "noblepioneer.com",
        "nospam.ze.tc",
        "nospam4.us",
        "owlpic.com",
        "pookmail.com",
        "poof.email",
        "qq1234.org",
        "receivemail.org",
        "rtrtr.com",
        "s0ny.net",
        "safetymail.info",
        "shitmail.me",
        "smellfear.com",
        "spamavert.com",
        "spambog.com",
        "spambog.net",
        "spambog.ru",
        "spamgob.com",
        "spamherelots.com",
        "spamslicer.com",
        "spamthisplease.com",
        "spoofmail.de",
        "super-auswahl.de",
        "tempr.email",
        "throwam.com",
        "tilien.com",
        "tmailinator.com",
        "trashdevil.com",
        "trashdevil.de",
        "trbvm.com",
        "turual.com",
        "uggsrock.com",
        "viditag.com",
        "vomoto.com",
        "vpn.st",
        "wegwerfemail.de",
        "wegwerfemail.net",
        "wegwerfemail.org",
        "wetrainbayarea.com",
        "willhackforfood.biz",
        "wuzupmail.net",
        "xemaps.com",
        "xmailer.be",
        "xoxy.net",
        "yep.it",
        "yogamaven.com",
        "z1p.biz",
        "zoemail.org",
    }
)


def is_disposable_email(email: str) -> bool:
    """Return True if the email address uses a known disposable domain."""
    if not email or "@" not in email:
        return False
    domain = email.rsplit("@", 1)[1].strip().lower()
    return domain in _DISPOSABLE_EMAIL_DOMAINS


def is_plausible_phone(phone: str) -> bool:
    """Return True if the phone number looks like a real number.

    Rejects:
    - Too short after stripping formatting (<7 digits)
    - All-same digits (e.g. 0000000000, 1111111111)
    - The entire digit string is a sequential run (e.g. 1234567890, 0987654321)
    """
    if not phone:
        return False
    digits = "".join(c for c in phone if c.isdigit())
    if len(digits) < 7:
        return False
    if len(set(digits)) == 1:
        return False
    # Reject only when the entire digit string is a consecutive run
    ascending = "0123456789"
    descending = "9876543210"
    if digits in ascending or digits in descending:
        return False
    return True


async def send_email(
    to: str, subject: str, html: str, text: str = None,
    from_addr: str = None, email_type: str = "ad_hoc",
) -> str | None:
    """Send email via Resend SDK. Returns resend_id on success, None on failure.

    Truthy string works like True for existing boolean callers; None is falsy.
    """
    sender = from_addr or config.EMAIL_FROM
    resend_id = None

    if not config.RESEND_API_KEY:
        logger.info("Would send to %s: %s", to, subject)
        resend_id = "dev"
    else:
        resend.api_key = config.RESEND_API_KEY
        try:
            result = resend.Emails.send(
                {
                    "from": sender,
                    "to": to,
                    "subject": subject,
                    "html": html,
                    "text": text or html,
                }
            )
            resend_id = result.get("id") if isinstance(result, dict) else getattr(result, "id", None)
        except Exception as e:
            logger.error("Error sending to %s: %s", to, e)
            return None

    # Log to email_log (best-effort, never fail the send)
    try:
        await execute(
            """INSERT INTO email_log (resend_id, from_addr, to_addr, subject, email_type)
               VALUES (?, ?, ?, ?, ?)""",
            (resend_id, sender, to, subject, email_type),
        )
    except Exception as e:
        logger.error("Failed to log email: %s", e)

    return resend_id


# =============================================================================
# Waitlist
# =============================================================================


async def _get_or_create_resend_audience(name: str) -> str | None:
    """Get cached Resend audience ID, or create one via API. Returns None on failure."""
    row = await fetch_one("SELECT audience_id FROM resend_audiences WHERE name = ?", (name,))
    if row:
        return row["audience_id"]

    try:
        resend.api_key = config.RESEND_API_KEY
        result = resend.Audiences.create({"name": name})
        audience_id = result["id"]
        await execute(
            "INSERT OR IGNORE INTO resend_audiences (name, audience_id) VALUES (?, ?)",
            (name, audience_id),
        )
        return audience_id
    except Exception:
        logger.exception("Failed to create Resend audience %r", name)
        return None


_BLUEPRINT_TO_AUDIENCE = {
    "suppliers": "suppliers",
    "planner": "leads",
    "leads": "leads",
    "auth": "newsletter",
    "content": "newsletter",
    "public": "newsletter",
}


def _audience_for_blueprint(blueprint: str) -> str:
    """Map blueprint name to one of 3 Resend audiences (free plan limit)."""
    return _BLUEPRINT_TO_AUDIENCE.get(blueprint, "newsletter")


async def capture_waitlist_email(
    email: str, intent: str, plan: str = None, email_intent: str = None
) -> bool:
    """Insert email into waitlist, enqueue confirmation, add to Resend audience.

    Args:
        email: Email address to capture
        intent: Intent value stored in database
        plan: Optional plan name stored in database
        email_intent: Optional intent value for email (defaults to `intent`)

    Returns:
        True if new row inserted, False if duplicate.
    """
    # INSERT OR IGNORE
    try:
        cursor_result = await execute(
            "INSERT OR IGNORE INTO waitlist (email, intent, plan, ip_address) VALUES (?, ?, ?, ?)",
            (email, intent, plan, request.remote_addr),
        )
        is_new = cursor_result > 0
    except Exception:
        logger.exception("Failed to insert waitlist entry for %r", email)
        # Treat as not-new to avoid double-sending on retry
        is_new = False

    # Enqueue confirmation email only if new
    if is_new:
        from .worker import enqueue

        email_intent_value = email_intent if email_intent is not None else intent
        lang = g.get("lang", "en") if g else "en"
        await enqueue("send_waitlist_confirmation", {"email": email, "intent": email_intent_value, "lang": lang})

    # Add to Resend audience (silent fail - not critical)
    # 3 named audiences: suppliers, leads, newsletter (free plan limit = 3)
    if config.RESEND_API_KEY:
        blueprint = request.blueprints[0] if request.blueprints else "public"
        audience_name = _audience_for_blueprint(blueprint)
        audience_id = await _get_or_create_resend_audience(audience_name)
        if audience_id:
            try:
                resend.api_key = config.RESEND_API_KEY
                resend.Contacts.create({"email": email, "audience_id": audience_id})
            except Exception:
                # Non-critical: audience sync failure doesn't affect waitlist capture
                logger.warning("Failed to add %r to Resend audience %r", email, audience_name, exc_info=True)

    return is_new


# =============================================================================
# CSRF Protection
# =============================================================================


def get_csrf_token() -> str:
    """Get or create CSRF token for current session."""
    if "csrf_token" not in session:
        session["csrf_token"] = secrets.token_urlsafe(32)
    return session["csrf_token"]


def validate_csrf_token(token: str) -> bool:
    """Validate CSRF token."""
    return token and secrets.compare_digest(token, session.get("csrf_token", ""))


def csrf_protect(f):
    """Decorator to require valid CSRF token for POST requests."""

    @wraps(f)
    async def decorated(*args, **kwargs):
        if request.method == "POST":
            form = await request.form
            token = form.get("csrf_token") or request.headers.get("X-CSRF-Token")
            if not validate_csrf_token(token):
                return {"error": "Invalid CSRF token"}, 403
        return await f(*args, **kwargs)

    return decorated


# =============================================================================
# Rate Limiting (SQLite-based)
# =============================================================================


async def check_rate_limit(key: str, limit: int = None, window: int = None) -> tuple[bool, dict]:
    """
    Check if rate limit exceeded. Returns (is_allowed, info).
    Uses SQLite for storage - no Redis needed.
    """
    limit = limit or config.RATE_LIMIT_REQUESTS
    window = window or config.RATE_LIMIT_WINDOW_SECONDS
    now = utcnow()
    window_start = now - timedelta(seconds=window)

    # Clean old entries and count recent
    await execute(
        "DELETE FROM rate_limits WHERE key = ? AND timestamp < ?",
        (key, window_start.strftime("%Y-%m-%d %H:%M:%S")),
    )

    result = await fetch_one(
        "SELECT COUNT(*) as count FROM rate_limits WHERE key = ? AND timestamp > ?",
        (key, window_start.strftime("%Y-%m-%d %H:%M:%S")),
    )
    count = result["count"] if result else 0

    info = {
        "limit": limit,
        "remaining": max(0, limit - count - 1),
        "reset": int((window_start + timedelta(seconds=window)).timestamp()),
    }

    if count >= limit:
        return False, info

    # Record this request
    await execute(
        "INSERT INTO rate_limits (key, timestamp) VALUES (?, ?)",
        (key, now.strftime("%Y-%m-%d %H:%M:%S")),
    )

    return True, info


def rate_limit(limit: int = None, window: int = None, key_func=None):
    """Decorator for rate limiting routes."""

    def decorator(f):
        @wraps(f)
        async def decorated(*args, **kwargs):
            if key_func:
                key = key_func()
            else:
                key = f"ip:{request.remote_addr}"

            allowed, info = await check_rate_limit(key, limit, window)

            if not allowed:
                response = {"error": "Rate limit exceeded", **info}
                return response, 429

            return await f(*args, **kwargs)

        return decorated

    return decorator


# =============================================================================
# Request ID Tracking
# =============================================================================

request_id_var: ContextVar[str] = ContextVar("request_id", default="")


def get_request_id() -> str:
    """Get current request ID."""
    return request_id_var.get()


def setup_request_id(app):
    """Setup request ID middleware."""

    @app.before_request
    async def set_request_id():
        rid = request.headers.get("X-Request-ID") or secrets.token_hex(8)
        request_id_var.set(rid)
        g.request_id = rid

    @app.after_request
    async def add_request_id_header(response):
        response.headers["X-Request-ID"] = get_request_id()
        return response


# =============================================================================
# Webhook Signature Verification
# =============================================================================


def verify_hmac_signature(payload: bytes, signature: str, secret: str) -> bool:
    """Verify HMAC-SHA256 webhook signature."""
    expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature, expected)


# =============================================================================
# Soft Delete Helpers
# =============================================================================


async def soft_delete(table: str, id: int) -> bool:
    """Mark record as deleted."""
    result = await execute(
        f"UPDATE {table} SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL",
        (utcnow_iso(), id),
    )
    return result > 0


async def restore(table: str, id: int) -> bool:
    """Restore soft-deleted record."""
    result = await execute(f"UPDATE {table} SET deleted_at = NULL WHERE id = ?", (id,))
    return result > 0


async def hard_delete(table: str, id: int) -> bool:
    """Permanently delete record."""
    result = await execute(f"DELETE FROM {table} WHERE id = ?", (id,))
    return result > 0


async def purge_deleted(table: str, days: int = 30) -> int:
    """Purge records deleted more than X days ago."""
    cutoff = (utcnow() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
    return await execute(
        f"DELETE FROM {table} WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,)
    )


# =============================================================================
# Paddle Product Lookup
# =============================================================================


async def get_price_id(key: str, provider: str = None) -> str | None:
    """Look up a provider price ID by product key from the payment_products table."""
    provider = provider or config.PAYMENT_PROVIDER
    row = await fetch_one(
        "SELECT provider_price_id FROM payment_products WHERE provider = ? AND key = ?",
        (provider, key),
    )
    return row["provider_price_id"] if row else None


async def get_all_price_ids(provider: str = None) -> dict[str, str]:
    """Load all price IDs for a provider as a {key: price_id} dict."""
    provider = provider or config.PAYMENT_PROVIDER
    rows = await fetch_all(
        "SELECT key, provider_price_id FROM payment_products WHERE provider = ?",
        (provider,),
    )
    return {r["key"]: r["provider_price_id"] for r in rows}


async def get_paddle_price(key: str) -> str | None:
    """Deprecated: use get_price_id(). Falls back to paddle_products for pre-migration DBs."""
    result = await get_price_id(key, provider="paddle")
    if result:
        return result
    # Fallback to old table if payment_products not yet populated
    row = await fetch_one("SELECT paddle_price_id FROM paddle_products WHERE key = ?", (key,))
    return row["paddle_price_id"] if row else None


async def get_all_paddle_prices() -> dict[str, str]:
    """Deprecated: use get_all_price_ids()."""
    return await get_all_price_ids(provider="paddle")


# =============================================================================
# Text Utilities
# =============================================================================


def slugify(text: str, max_length_chars: int = 80) -> str:
    """Convert text to URL-safe slug.

    Pre-replaces ß→ss before NFKD normalization so output matches the SQL
    @slugify macro (which uses DuckDB STRIP_ACCENTS + REPLACE).
    """
    text = text.lower().replace("ß", "ss")
    text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
    text = re.sub(r"[^\w\s-]", "", text)
    text = re.sub(r"[-\s]+", "-", text).strip("-")
    return text[:max_length_chars]


# =============================================================================
# A/B Testing
# =============================================================================


def _has_functional_consent() -> bool:
    """Return True if the visitor has accepted functional cookies."""
    return "functional" in request.cookies.get("cookie_consent", "")


def ab_test(experiment: str, variants: tuple = ("control", "treatment")):
    """Assign visitor to an A/B test variant, tag Umami pageviews.

    Only persists the variant cookie when the visitor has given functional
    cookie consent. Without consent a random variant is picked per-request
    (so the page renders fine and Umami is tagged), but no cookie is set.
    """

    def decorator(f):
        @wraps(f)
        async def wrapper(*args, **kwargs):
            cookie_key = f"ab_{experiment}"
            has_consent = _has_functional_consent()

            assigned = request.cookies.get(cookie_key) if has_consent else None
            if assigned not in variants:
                assigned = random.choice(variants)

            g.ab_variant = assigned
            g.ab_tag = f"{experiment}-{assigned}"

            response = await make_response(await f(*args, **kwargs))
            if has_consent:
                response.set_cookie(cookie_key, assigned, max_age=30 * 24 * 60 * 60)
            return response

        return wrapper

    return decorator


async def is_flag_enabled(name: str, default: bool = False) -> bool:
    """Check if a feature flag is enabled. Falls back to default if flag doesn't exist.

    Reads from the feature_flags table. Flags are toggled via the admin UI
    and take effect immediately — no restart needed.
    """
    row = await fetch_one(
        "SELECT enabled FROM feature_flags WHERE name = ?", (name,)
    )
    if row is None:
        return default
    return bool(row["enabled"])


def feature_gate(flag_name: str, waitlist_template: str, **extra_context):
    """Gate a route behind a feature flag. Shows waitlist template if flag is disabled.

    Replaces the old waitlist_gate() which used a global WAITLIST_MODE env var.
    This checks per-feature flags from the database instead.

    Args:
        flag_name: Name of the feature flag (e.g., "payments", "supplier_signup")
        waitlist_template: Template to render when the flag is OFF and method is GET
        **extra_context: Additional context. Values can be callables (evaluated at request time).

    Usage:
        @bp.route("/signup", methods=["GET", "POST"])
        @csrf_protect
        @feature_gate("payments", "waitlist.html", plan=lambda: request.args.get("plan", "free"))
        async def signup():
            ...
    """

    def decorator(f):
        @wraps(f)
        async def decorated(*args, **kwargs):
            if not await is_flag_enabled(flag_name) and request.method == "GET":
                ctx = {}
                for key, val in extra_context.items():
                    ctx[key] = val() if callable(val) else val
                return await render_template(waitlist_template, **ctx)
            return await f(*args, **kwargs)

        return decorated

    return decorator


def waitlist_gate(template: str, **extra_context):
    """DEPRECATED: Use feature_gate() instead. Kept for backwards compatibility.

    Intercepts GET requests when WAITLIST_MODE is enabled.
    """

    def decorator(f):
        @wraps(f)
        async def decorated(*args, **kwargs):
            if config.WAITLIST_MODE and request.method == "GET":
                ctx = {}
                for key, val in extra_context.items():
                    ctx[key] = val() if callable(val) else val
                return await render_template(template, **ctx)
            return await f(*args, **kwargs)

        return decorated

    return decorator
