import logging
import os
import socket
import ssl
from decimal import Decimal
from urllib.parse import unquote, urlparse

import click
from flask import Flask, has_request_context, jsonify, make_response, render_template, request, url_for
from sqlalchemy import text
from flask_wtf.csrf import CSRFError
from werkzeug.security import generate_password_hash
from werkzeug.middleware.proxy_fix import ProxyFix

from config import Config, database_summary
from extensions import csrf, db, limiter, migrate
from models import DashboardBanner, Investment, Plan, RechargeRequest, SystemConfig, User
from routes import register_blueprints
from services.bootstrap import backfill_referral_codes, seed_defaults
from services.alerts import note_database_down, note_redis_down, note_repeat_500
from services.earnings import process_due_investment_earnings
from services.logging import configure_logging
from services.security_abuse import cleanup_expired_ip_blocks, get_client_ip, is_ip_blocked
from utils.formatters import mask_clabe, mask_phone
from utils.decorators import is_admin_authenticated


logger = logging.getLogger(__name__)


def ensure_schema():
    if not db.engine.dialect.name.startswith("sqlite"):
        return

    with db.engine.begin() as connection:
        user_columns = {
            row[1]
            for row in connection.exec_driver_sql("PRAGMA table_info(users)").fetchall()
        }
        system_config_columns = {
            row[1]
            for row in connection.exec_driver_sql("PRAGMA table_info(system_config)").fetchall()
        }
        if "referral_code" not in user_columns:
            connection.exec_driver_sql("ALTER TABLE users ADD COLUMN referral_code VARCHAR(32)")
        if "referred_by_id" not in user_columns:
            connection.exec_driver_sql("ALTER TABLE users ADD COLUMN referred_by_id INTEGER")
        if "withdrawal_bank_name" not in user_columns:
            connection.exec_driver_sql("ALTER TABLE users ADD COLUMN withdrawal_bank_name VARCHAR(120)")
        if "withdrawal_account_holder" not in user_columns:
            connection.exec_driver_sql("ALTER TABLE users ADD COLUMN withdrawal_account_holder VARCHAR(120)")
        if "withdrawal_clabe" not in user_columns:
            connection.exec_driver_sql("ALTER TABLE users ADD COLUMN withdrawal_clabe VARCHAR(18)")
        if "withdrawal_pin_hash" not in user_columns:
            connection.exec_driver_sql("ALTER TABLE users ADD COLUMN withdrawal_pin_hash VARCHAR(255)")
        if "notifications_seen_at" not in user_columns:
            connection.exec_driver_sql("ALTER TABLE users ADD COLUMN notifications_seen_at DATETIME")
        if "whatsapp_url" not in system_config_columns:
            connection.exec_driver_sql("ALTER TABLE system_config ADD COLUMN whatsapp_url VARCHAR(255)")
        if "telegram_group_url" not in system_config_columns:
            connection.exec_driver_sql("ALTER TABLE system_config ADD COLUMN telegram_group_url VARCHAR(255)")
        if "telegram_channel_url" not in system_config_columns:
            connection.exec_driver_sql("ALTER TABLE system_config ADD COLUMN telegram_channel_url VARCHAR(255)")
        if "admin_allowed_ips" not in system_config_columns:
            connection.exec_driver_sql("ALTER TABLE system_config ADD COLUMN admin_allowed_ips TEXT")
        if "admin_subdomain_only" not in system_config_columns:
            connection.exec_driver_sql("ALTER TABLE system_config ADD COLUMN admin_subdomain_only BOOLEAN NOT NULL DEFAULT 0")
        if "admin_subdomain_name" not in system_config_columns:
            connection.exec_driver_sql("ALTER TABLE system_config ADD COLUMN admin_subdomain_name VARCHAR(120)")
        if "security_alert_webhook_url" not in system_config_columns:
            connection.exec_driver_sql("ALTER TABLE system_config ADD COLUMN security_alert_webhook_url VARCHAR(255)")
        if "admin_mfa_force_all" not in system_config_columns:
            connection.exec_driver_sql("ALTER TABLE system_config ADD COLUMN admin_mfa_force_all BOOLEAN NOT NULL DEFAULT 0")
        if "admin_mfa_enabled" not in system_config_columns:
            connection.exec_driver_sql("ALTER TABLE system_config ADD COLUMN admin_mfa_enabled BOOLEAN NOT NULL DEFAULT 0")
        if "admin_mfa_secret" not in system_config_columns:
            connection.exec_driver_sql("ALTER TABLE system_config ADD COLUMN admin_mfa_secret VARCHAR(255)")
        if "admin_mfa_recovery_codes_json" not in system_config_columns:
            connection.exec_driver_sql("ALTER TABLE system_config ADD COLUMN admin_mfa_recovery_codes_json TEXT")
        if "small_recharge_bank_name" not in system_config_columns:
            connection.exec_driver_sql("ALTER TABLE system_config ADD COLUMN small_recharge_bank_name VARCHAR(120)")
        if "small_recharge_account_holder" not in system_config_columns:
            connection.exec_driver_sql("ALTER TABLE system_config ADD COLUMN small_recharge_account_holder VARCHAR(120)")
        if "small_recharge_account_number" not in system_config_columns:
            connection.exec_driver_sql("ALTER TABLE system_config ADD COLUMN small_recharge_account_number VARCHAR(120)")
        if "small_recharge_clabe" not in system_config_columns:
            connection.exec_driver_sql("ALTER TABLE system_config ADD COLUMN small_recharge_clabe VARCHAR(120)")
        if "withdrawal_start_time" not in system_config_columns:
            connection.exec_driver_sql("ALTER TABLE system_config ADD COLUMN withdrawal_start_time VARCHAR(5)")
        if "withdrawal_end_time" not in system_config_columns:
            connection.exec_driver_sql("ALTER TABLE system_config ADD COLUMN withdrawal_end_time VARCHAR(5)")
        for column_name in [
            "withdrawal_monday",
            "withdrawal_tuesday",
            "withdrawal_wednesday",
            "withdrawal_thursday",
            "withdrawal_friday",
            "withdrawal_saturday",
            "withdrawal_sunday",
        ]:
            if column_name not in system_config_columns:
                connection.exec_driver_sql(f"ALTER TABLE system_config ADD COLUMN {column_name} BOOLEAN")
        investment_columns = {
            row[1]
            for row in connection.exec_driver_sql("PRAGMA table_info(investments)").fetchall()
        }
        plan_columns = {
            row[1]
            for row in connection.exec_driver_sql("PRAGMA table_info(plans)").fetchall()
        }
        if "vip_level" not in plan_columns:
            connection.exec_driver_sql("ALTER TABLE plans ADD COLUMN vip_level INTEGER NOT NULL DEFAULT 1")
        if "task_reward" not in plan_columns:
            connection.exec_driver_sql("ALTER TABLE plans ADD COLUMN task_reward NUMERIC(14, 2) NOT NULL DEFAULT 0")
        if "tasks_per_day" not in plan_columns:
            connection.exec_driver_sql("ALTER TABLE plans ADD COLUMN tasks_per_day INTEGER NOT NULL DEFAULT 1")
        if "last_profit_at" not in investment_columns:
            connection.exec_driver_sql("ALTER TABLE investments ADD COLUMN last_profit_at DATETIME")
        if "membership_type" not in investment_columns:
            connection.exec_driver_sql("ALTER TABLE investments ADD COLUMN membership_type VARCHAR(20) NOT NULL DEFAULT 'product'")
        if "vip_level_snapshot" not in investment_columns:
            connection.exec_driver_sql("ALTER TABLE investments ADD COLUMN vip_level_snapshot INTEGER")
        if "task_reward_snapshot" not in investment_columns:
            connection.exec_driver_sql("ALTER TABLE investments ADD COLUMN task_reward_snapshot NUMERIC(14, 2) NOT NULL DEFAULT 0")
        if "tasks_per_day_snapshot" not in investment_columns:
            connection.exec_driver_sql("ALTER TABLE investments ADD COLUMN tasks_per_day_snapshot INTEGER NOT NULL DEFAULT 1")
        if "discount_code_snapshot" not in investment_columns:
            connection.exec_driver_sql("ALTER TABLE investments ADD COLUMN discount_code_snapshot VARCHAR(64)")
        if "discount_type_snapshot" not in investment_columns:
            connection.exec_driver_sql("ALTER TABLE investments ADD COLUMN discount_type_snapshot VARCHAR(20)")
        if "discount_value_snapshot" not in investment_columns:
            connection.exec_driver_sql("ALTER TABLE investments ADD COLUMN discount_value_snapshot NUMERIC(14, 2)")
        if "discount_amount_snapshot" not in investment_columns:
            connection.exec_driver_sql("ALTER TABLE investments ADD COLUMN discount_amount_snapshot NUMERIC(14, 2)")
        index_rows = {
            row[1]
            for row in connection.exec_driver_sql("PRAGMA index_list(investments)").fetchall()
        }
        if "uq_investments_user_plan_active" not in index_rows:
            connection.exec_driver_sql(
                """
                WITH ranked AS (
                    SELECT
                        id,
                        row_number() OVER (
                            PARTITION BY user_id, plan_id
                            ORDER BY created_at ASC, id ASC
                        ) AS rn
                    FROM investments
                    WHERE status IN ('active', 'vip_active')
                )
                UPDATE investments
                SET status = 'completed'
                WHERE id IN (
                    SELECT id
                    FROM ranked
                    WHERE rn > 1
                )
                """
            )
            connection.exec_driver_sql(
                """
                CREATE UNIQUE INDEX IF NOT EXISTS uq_investments_user_plan_active
                ON investments (user_id, plan_id)
                WHERE status IN ('active', 'vip_active')
                """
            )
        if "image_path" not in plan_columns:
            connection.exec_driver_sql("ALTER TABLE plans ADD COLUMN image_path VARCHAR(255)")
        withdrawal_columns = {
            row[1]
            for row in connection.exec_driver_sql("PRAGMA table_info(withdrawal_requests)").fetchall()
        }
        if "deducted_from_main" not in withdrawal_columns:
            connection.exec_driver_sql("ALTER TABLE withdrawal_requests ADD COLUMN deducted_from_main NUMERIC(14, 2) NOT NULL DEFAULT 0")
        if "deducted_from_referrals" not in withdrawal_columns:
            connection.exec_driver_sql("ALTER TABLE withdrawal_requests ADD COLUMN deducted_from_referrals NUMERIC(14, 2) NOT NULL DEFAULT 0")
        discount_columns = {
            row[1]
            for row in connection.exec_driver_sql("PRAGMA table_info(discount_codes)").fetchall()
        }
        if not discount_columns:
            connection.exec_driver_sql(
                """
                CREATE TABLE discount_codes (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    code VARCHAR(64) NOT NULL UNIQUE,
                    discount_type VARCHAR(20) NOT NULL,
                    discount_value NUMERIC(14, 2) NOT NULL,
                    note VARCHAR(255),
                    is_active BOOLEAN NOT NULL DEFAULT 1,
                    used_at DATETIME,
                    used_by_user_id INTEGER,
                    used_investment_id INTEGER,
                    created_by_admin VARCHAR(120),
                    expires_at DATETIME,
                    created_at DATETIME NOT NULL,
                    FOREIGN KEY(used_by_user_id) REFERENCES users(id),
                    FOREIGN KEY(used_investment_id) REFERENCES investments(id)
                )
                """
            )
        mission_columns = {
            row[1]
            for row in connection.exec_driver_sql("PRAGMA table_info(mission_completions)").fetchall()
        }
        if not mission_columns:
            connection.exec_driver_sql(
                """
                CREATE TABLE mission_completions (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    user_id INTEGER NOT NULL,
                    mission_key VARCHAR(80) NOT NULL,
                    mission_name VARCHAR(160) NOT NULL,
                    reward_kind VARCHAR(20) NOT NULL,
                    reward_amount NUMERIC(14, 2) NOT NULL DEFAULT 0,
                    reward_percent NUMERIC(14, 2),
                    reward_code_id INTEGER,
                    progress_snapshot TEXT,
                    claimed_at DATETIME NOT NULL,
                    created_at DATETIME NOT NULL,
                    FOREIGN KEY(user_id) REFERENCES users(id),
                    FOREIGN KEY(reward_code_id) REFERENCES discount_codes(id),
                    UNIQUE(user_id, mission_key)
                )
                """
            )
        admin_lock_columns = {
            row[1]
            for row in connection.exec_driver_sql("PRAGMA table_info(security_admin_login_locks)").fetchall()
        }
        if not admin_lock_columns:
            connection.exec_driver_sql(
                """
                CREATE TABLE security_admin_login_locks (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    ip_address VARCHAR(64) NOT NULL,
                    username VARCHAR(120) NOT NULL,
                    failed_attempts INTEGER NOT NULL DEFAULT 0,
                    locked_until DATETIME,
                    last_failed_at DATETIME,
                    created_at DATETIME NOT NULL,
                    updated_at DATETIME NOT NULL,
                    CONSTRAINT uq_security_admin_login_locks_ip_username UNIQUE (ip_address, username)
                )
                """
            )
            connection.exec_driver_sql(
                "CREATE INDEX IF NOT EXISTS ix_security_admin_login_locks_created_at ON security_admin_login_locks (created_at)"
            )
            connection.exec_driver_sql(
                "CREATE INDEX IF NOT EXISTS ix_security_admin_login_locks_failed_attempts ON security_admin_login_locks (failed_attempts)"
            )
            connection.exec_driver_sql(
                "CREATE INDEX IF NOT EXISTS ix_security_admin_login_locks_ip_address ON security_admin_login_locks (ip_address)"
            )
            connection.exec_driver_sql(
                "CREATE INDEX IF NOT EXISTS ix_security_admin_login_locks_locked_until ON security_admin_login_locks (locked_until)"
            )
            connection.exec_driver_sql(
                "CREATE INDEX IF NOT EXISTS ix_security_admin_login_locks_last_failed_at ON security_admin_login_locks (last_failed_at)"
            )
            connection.exec_driver_sql(
                "CREATE INDEX IF NOT EXISTS ix_security_admin_login_locks_updated_at ON security_admin_login_locks (updated_at)"
            )
            connection.exec_driver_sql(
                "CREATE INDEX IF NOT EXISTS ix_security_admin_login_locks_username ON security_admin_login_locks (username)"
            )


def ensure_referral_codes():
    users_without_code = User.query.filter(
        (User.referral_code.is_(None)) | (User.referral_code == "")
    ).all()
    for user in users_without_code:
        while True:
            code = f"AV{os.urandom(4).hex().upper()}"
            exists = User.query.filter(User.referral_code == code, User.id != user.id).first()
            if not exists:
                user.referral_code = code
                break
    if users_without_code:
        db.session.commit()


def ensure_vip_tiers():
    catalog_specs = [
        {
            "vip_level": 1,
            "name": "Grano de Cafe Natural",
            "description": "Paquete de inversión con rendimiento diario.",
            "price": Decimal("550.00"),
            "daily_profit": Decimal("137.00"),
            "task_reward": Decimal("137.00"),
            "tasks_per_day": 1,
            "duration_days": 200,
            "is_featured": True,
            "is_active": True,
        },
        {
            "vip_level": 2,
            "name": "Grano de Cafe Tostado Obscuro",
            "description": "Paquete premium de café tostado oscuro.",
            "price": Decimal("1600.00"),
            "daily_profit": Decimal("400.00"),
            "task_reward": Decimal("400.00"),
            "tasks_per_day": 1,
            "duration_days": 200,
            "is_featured": False,
            "is_active": False,
        },
        {
            "vip_level": 3,
            "name": "Grano de Cafe Selecto - Ahumado",
            "description": "Selección ahumada de alto rendimiento.",
            "price": Decimal("3000.00"),
            "daily_profit": Decimal("750.00"),
            "task_reward": Decimal("750.00"),
            "tasks_per_day": 1,
            "duration_days": 200,
            "is_featured": False,
            "is_active": False,
        },
    ]

    coming_soon_specs = [
        {"vip_level": 4, "name": "VIP 4"},
        {"vip_level": 5, "name": "VIP 5"},
        {"vip_level": 6, "name": "VIP 6"},
    ]

    changed = False
    for spec in catalog_specs:
        plan = (
            Plan.query.filter(Plan.vip_level == spec["vip_level"])
            .order_by(Plan.id.asc())
            .first()
        )
        if plan is None:
            db.session.add(
                Plan(
                    name=spec["name"],
                    description=spec["description"],
                    vip_level=spec["vip_level"],
                    price=spec["price"],
                    daily_profit=spec["daily_profit"],
                    task_reward=spec["task_reward"],
                    tasks_per_day=spec["tasks_per_day"],
                    duration_days=spec["duration_days"],
                    is_featured=spec["is_featured"],
                    is_active=spec["is_active"],
                )
            )
            changed = True
            continue

        for field in (
            "name",
            "description",
            "price",
            "daily_profit",
            "task_reward",
            "tasks_per_day",
            "duration_days",
            "is_featured",
            "is_active",
        ):
            if getattr(plan, field) != spec[field]:
                setattr(plan, field, spec[field])
                changed = True

    for spec in coming_soon_specs:
        plan = (
            Plan.query.filter(Plan.vip_level == spec["vip_level"], Plan.name == spec["name"])
            .order_by(Plan.id.asc())
            .first()
        )
        if plan is None:
            db.session.add(
                Plan(
                    name=spec["name"],
                    description="Próximamente.",
                    vip_level=spec["vip_level"],
                    price=Decimal("0.00"),
                    daily_profit=Decimal("0.00"),
                    task_reward=Decimal("0.00"),
                    tasks_per_day=1,
                    duration_days=365,
                    is_featured=False,
                    is_active=False,
                )
            )
            changed = True
            continue

        if plan.task_reward != Decimal("0.00"):
            plan.task_reward = Decimal("0.00")
            changed = True
        if plan.daily_profit != Decimal("0.00"):
            plan.daily_profit = Decimal("0.00")
            changed = True
        if plan.price != Decimal("0.00"):
            plan.price = Decimal("0.00")
            changed = True
        if plan.is_active:
            plan.is_active = False
            changed = True
        if plan.name != spec["name"]:
            plan.name = spec["name"]
            changed = True
        if plan.vip_level != spec["vip_level"]:
            plan.vip_level = spec["vip_level"]
            changed = True

    if changed:
        db.session.commit()


def ensure_default_vip_tier():
    ensure_vip_tiers()


def _request_wants_json():
    if not has_request_context():
        return False
    accept = (request.headers.get("Accept") or "").lower()
    return request.headers.get("X-Requested-With") == "XMLHttpRequest" or "application/json" in accept


def _json_error_response(status_code, message, **extra):
    payload = {"ok": False, "error": message, **extra}
    response = jsonify(payload)
    response.status_code = status_code
    response.headers["Cache-Control"] = "no-store"
    return response


def _fallback_url_for_error():
    from flask import session

    if request.path.startswith("/admin"):
        return url_for("admin.dashboard") if is_admin_authenticated() else url_for("admin.login")
    if session.get("user_id"):
        return url_for("user.dashboard")
    if request.path.startswith("/register"):
        return url_for("auth.register")
    return url_for("auth.login")


def _redis_uri_summary(redis_uri):
    parsed = urlparse(redis_uri or "")
    host = parsed.hostname or "unknown"
    port = parsed.port or (6380 if parsed.scheme == "rediss" else 6379)
    db_index = parsed.path.lstrip("/") or "0"
    scheme = parsed.scheme or "redis"
    return f"{scheme}://{host}:{port}/{db_index}"


def _resp_read_line(sock):
    buffer = bytearray()
    while True:
        chunk = sock.recv(1)
        if not chunk:
            raise ConnectionError("Redis cerró la conexión inesperadamente.")
        buffer.extend(chunk)
        if buffer.endswith(b"\r\n"):
            return bytes(buffer[:-2])


def _resp_read_exact(sock, length):
    data = bytearray()
    remaining = length + 2
    while remaining > 0:
        chunk = sock.recv(remaining)
        if not chunk:
            raise ConnectionError("Redis cerró la conexión inesperadamente.")
        data.extend(chunk)
        remaining -= len(chunk)
    return bytes(data[:-2])


def _redis_read_response(sock):
    prefix = sock.recv(1)
    if not prefix:
        raise ConnectionError("Redis no devolvió respuesta.")
    if prefix == b"+":
        return _resp_read_line(sock).decode("utf-8", errors="replace")
    if prefix == b"-":
        raise ConnectionError(_resp_read_line(sock).decode("utf-8", errors="replace"))
    if prefix == b":":
        return int(_resp_read_line(sock))
    if prefix == b"$":
        length = int(_resp_read_line(sock))
        if length == -1:
            return None
        return _resp_read_exact(sock, length).decode("utf-8", errors="replace")
    if prefix == b"*":
        count = int(_resp_read_line(sock))
        return [_redis_read_response(sock) for _ in range(count)]
    raise ConnectionError("Respuesta Redis desconocida.")


def _redis_send_command(sock, *parts):
    payload = [f"*{len(parts)}\r\n".encode("utf-8")]
    for part in parts:
        value = str(part).encode("utf-8")
        payload.append(f"${len(value)}\r\n".encode("utf-8"))
        payload.append(value + b"\r\n")
    sock.sendall(b"".join(payload))
    return _redis_read_response(sock)


def check_redis_health(redis_uri, timeout_seconds=3):
    if not redis_uri:
        return False, "missing", "RATELIMIT_STORAGE_URI no está configurado."

    parsed = urlparse(redis_uri)
    if parsed.scheme not in {"redis", "rediss"}:
        if parsed.scheme == "memory":
            return True, "memory", None
        return False, "unsupported", "RATELIMIT_STORAGE_URI debe usar Redis en producción."

    host = parsed.hostname
    if not host:
        return False, "invalid", "RATELIMIT_STORAGE_URI no incluye host."

    port = parsed.port or (6380 if parsed.scheme == "rediss" else 6379)
    try:
        with socket.create_connection((host, port), timeout=timeout_seconds) as raw_sock:
            if parsed.scheme == "rediss":
                context = ssl.create_default_context()
                sock = context.wrap_socket(raw_sock, server_hostname=host)
            else:
                sock = raw_sock

            password = parsed.password
            username = parsed.username
            if password:
                if username:
                    _redis_send_command(sock, "AUTH", unquote(username), unquote(password))
                else:
                    _redis_send_command(sock, "AUTH", unquote(password))

            db_index = parsed.path.lstrip("/")
            if db_index.isdigit():
                _redis_send_command(sock, "SELECT", db_index)

            pong = _redis_send_command(sock, "PING")
            if pong != "PONG":
                raise ConnectionError(f"Respuesta Redis inesperada: {pong!r}")

        return True, "ok", None
    except Exception as exc:
        return False, "down", str(exc)


def build_error_page_context(status_code):
    titles = {
        500: ("Algo salió mal", "Estamos resolviendo un problema interno."),
        502: ("Puerta de enlace no disponible", "El servidor de origen respondió con un error temporal."),
        503: ("Servicio temporalmente no disponible", "La aplicación no puede atender la solicitud en este momento."),
    }
    title, subtitle = titles.get(status_code, ("Error", "La solicitud no pudo completarse."))
    fallback = _fallback_url_for_error()
    return {
        "status_code": status_code,
        "title": title,
        "subtitle": subtitle,
        "fallback_url": fallback,
        "fallback_label": "Volver al inicio" if not request.path.startswith("/admin") else "Volver al panel",
    }


def _render_error_response(status_code, message):
    if _request_wants_json():
        return _json_error_response(status_code, message)

    context = build_error_page_context(status_code)
    context["message"] = message
    response = make_response(render_template("errors/server_error.html", **context), status_code)
    response.headers["Cache-Control"] = "no-store"
    return response


def create_app():
    app = Flask(__name__)
    app.config.from_object(Config)
    try:
        Config.validate()
    except RuntimeError as exc:
        logger.error("Validation de configuracion fallida al iniciar la app: %s", exc)
        raise

    database_label = database_summary(app.config.get("SQLALCHEMY_DATABASE_URI"))
    proxy_fix_options = {
        "x_for": int(app.config.get("PROXY_FIX_X_FOR", 0) or 0),
        "x_proto": int(app.config.get("PROXY_FIX_X_PROTO", 0) or 0),
        "x_host": int(app.config.get("PROXY_FIX_X_HOST", 0) or 0),
        "x_port": int(app.config.get("PROXY_FIX_X_PORT", 0) or 0),
        "x_prefix": int(app.config.get("PROXY_FIX_X_PREFIX", 0) or 0),
    }
    if any(proxy_fix_options.values()):
        app.wsgi_app = ProxyFix(app.wsgi_app, **proxy_fix_options)

    os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)

    db.init_app(app)
    csrf.init_app(app)
    migrate.init_app(
        app,
        db,
        compare_type=True,
        render_as_batch=app.config["SQLALCHEMY_DATABASE_URI"].startswith("sqlite:"),
    )
    limiter.init_app(app)
    with app.app_context():
        ensure_schema()
    register_blueprints(app)

    from utils.decorators import user_required

    @app.route("/about")
    @user_required
    def about_us():
        return render_template("user/about.html")

    configure_logging(app)
    try:
        app.logger.info("Base de datos activa: %s (backend=%s)", database_label, Config.database_backend())
    except BrokenPipeError:
        pass
    app.jinja_env.filters["mask_phone"] = mask_phone
    app.jinja_env.filters["mask_clabe"] = mask_clabe

    @app.context_processor
    def inject_auth_flags():
        return {
            "admin_authenticated": is_admin_authenticated(),
        }

    @app.context_processor
    def inject_admin_links():
        try:
            security_url = url_for("admin.security_dashboard")
        except Exception:
            security_url = url_for("admin.dashboard")
        return {
            "admin_security_url": security_url,
        }

    @app.after_request
    def apply_security_headers(response):
        response.headers["X-Content-Type-Options"] = "nosniff"
        response.headers["X-Frame-Options"] = "DENY"
        response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
        response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=(), payment=()"
        response.headers["Content-Security-Policy"] = (
            "default-src 'self'; "
            "base-uri 'self'; "
            "object-src 'none'; "
            "frame-ancestors 'none'; "
            "img-src 'self' data: https:; "
            "style-src 'self' 'unsafe-inline' https:; "
            "script-src 'self' 'unsafe-inline' https:; "
            "font-src 'self' data: https:; "
            "connect-src 'self' https:; "
            "form-action 'self'"
        )
        if request.is_secure:
            response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload"
        return response

    @app.before_request
    def enforce_blocked_ip():
        from flask import jsonify, request, render_template_string

        honeypot_paths = {
            "/admin-old",
            "/admin-login",
            "/cpanel-login",
            "/phpmyadmin",
            "/administrator",
        }
        if request.endpoint == "static" or request.path in honeypot_paths:
            return None

        client_ip = get_client_ip()
        if not is_ip_blocked(client_ip):
            return None

        message = "Acceso bloqueado por seguridad. Contacta al administrador si crees que es un error."
        accept = (request.headers.get("Accept") or "").lower()
        wants_json = request.headers.get("X-Requested-With") == "XMLHttpRequest" or "application/json" in accept
        if wants_json:
            response = jsonify({"ok": False, "error": message})
            response.status_code = 403
            response.headers["Cache-Control"] = "no-store"
            return response

        return (
            render_template_string(
                """
                <!doctype html>
                <html lang="es">
                <head>
                  <meta charset="utf-8">
                  <meta name="viewport" content="width=device-width, initial-scale=1">
                  <title>Acceso bloqueado</title>
                  <style>
                    body { font-family: Arial, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; padding: 32px; }
                    .card { max-width: 560px; margin: 10vh auto; background: #111827; border: 1px solid #334155; border-radius: 16px; padding: 28px; }
                    h1 { margin-top: 0; font-size: 28px; }
                    p { line-height: 1.6; color: #cbd5e1; }
                  </style>
                </head>
                <body>
                  <div class="card">
                    <h1>Acceso bloqueado</h1>
                    <p>{{ message }}</p>
                  </div>
                </body>
                </html>
                """,
                message=message,
            ),
            403,
        )

    @app.cli.command("seed-defaults")
    def seed_defaults_command():
        """Create the default system record and VIP tiers."""
        with app.app_context():
            seed_defaults()
            app.logger.info("Default bootstrap data seeded.")

    @app.cli.command("backfill-referral-codes")
    @click.option("--batch-size", default=500, show_default=True, type=int)
    def backfill_referral_codes_command(batch_size):
        """Fill missing referral codes in batches."""
        with app.app_context():
            updated = backfill_referral_codes(batch_size=batch_size)
            app.logger.info("Referral codes backfilled: %s", updated)

    @app.cli.command("process-due-earnings")
    @click.option("--batch-size", default=500, show_default=True, type=int)
    @click.option("--user-id", default=None, type=int)
    def process_due_earnings_command(batch_size, user_id):
        """Apply due investment earnings outside the request cycle."""
        with app.app_context():
            processed = process_due_investment_earnings(batch_size=batch_size, user_id=user_id)
            app.logger.info("Investment earnings processed: %s", processed)

    @app.cli.command("cleanup-expired-ip-blocks")
    @click.option("--batch-size", default=500, show_default=True, type=int)
    def cleanup_expired_ip_blocks_command(batch_size):
        """Clear expired IP blocks outside the request cycle."""
        with app.app_context():
            cleaned = cleanup_expired_ip_blocks(batch_size=batch_size)
            app.logger.info("Expired IP blocks cleaned: %s", cleaned)

    @app.cli.command("hash-admin-password")
    @click.argument("password")
    def hash_admin_password_command(password):
        """Generate a secure password hash for ADMIN_PASSWORD_HASH."""
        click.echo(generate_password_hash(password))

    @app.errorhandler(CSRFError)
    def handle_csrf_error(error):
        from flask import flash, jsonify, redirect, request, url_for

        message = "La sesion del formulario expiro o se abrio desde otro host. Recarga la pagina e intenta de nuevo."
        if _request_wants_json():
            return _json_error_response(400, message)

        flash(message, "error")
        if request.path.startswith("/admin"):
            return redirect(request.referrer or url_for("admin.settings"))
        return redirect(request.referrer or url_for("auth.login"))

    @app.errorhandler(429)
    def handle_rate_limit_error(error):
        from flask import jsonify, make_response, render_template_string, request, session, url_for

        canned_response = getattr(error, "response", None)
        if canned_response is not None and canned_response.headers.get("X-RateLimit-Handled") == "1":
            return canned_response

        message = "Has hecho demasiadas solicitudes. Espera un momento e intenta de nuevo."
        wants_json = _request_wants_json()
        retry_after_seconds = getattr(error, "retry_after", None)
        retry_after_text = "Intenta de nuevo en aproximadamente 1 minuto."
        if retry_after_seconds:
            try:
                seconds = int(retry_after_seconds)
                minutes = max((seconds + 59) // 60, 1)
                retry_after_text = f"Intenta de nuevo en aproximadamente {minutes} minuto{'s' if minutes != 1 else ''}."
            except (TypeError, ValueError):
                pass

        if wants_json:
            response = _json_error_response(
                429,
                message,
                retry_after_text=retry_after_text,
                retry_after_seconds=retry_after_seconds,
            )
            if retry_after_seconds:
                response.headers["Retry-After"] = str(retry_after_seconds)
            return response

        if request.path.startswith("/admin"):
            fallback = url_for("admin.dashboard") if is_admin_authenticated() else url_for("admin.login")
        elif session.get("user_id"):
            fallback = url_for("user.dashboard")
        elif request.path.startswith("/register"):
            fallback = url_for("auth.register")
        else:
            fallback = url_for("auth.login")

        html = render_template_string(
            """
            <!doctype html>
            <html lang="es">
            <head>
              <meta charset="utf-8">
              <meta name="viewport" content="width=device-width, initial-scale=1">
              <title>Demasiadas solicitudes</title>
              <style>
                body { font-family: Arial, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; padding: 32px; }
                .card { max-width: 560px; margin: 10vh auto; background: #111827; border: 1px solid #334155; border-radius: 16px; padding: 28px; }
                h1 { margin-top: 0; font-size: 28px; }
                p { line-height: 1.6; color: #cbd5e1; }
                a { display: inline-block; margin-top: 16px; color: #fff; background: #2563eb; padding: 12px 16px; border-radius: 10px; text-decoration: none; }
              </style>
            </head>
            <body>
                <div class="card">
                  <h1>Demasiadas solicitudes</h1>
                  <p>{{ message }}</p>
                  <p>{{ retry_after_text }}</p>
                  <p>Si sigues viendo este aviso, vuelve al login y entra otra vez.</p>
                  <a href="{{ fallback }}">Volver</a>
                </div>
              </body>
              </html>
            """,
            message=message,
            fallback=fallback,
            retry_after_text=retry_after_text,
        )
        response = make_response(html, 429)
        response.headers["Cache-Control"] = "no-store"
        if retry_after_seconds:
            response.headers["Retry-After"] = str(retry_after_seconds)
        return response

    @app.errorhandler(500)
    def handle_internal_server_error(error):
        try:
            db.session.rollback()
        except Exception:
            logger.exception("No se pudo hacer rollback después de un error interno.")
        original = getattr(error, "original_exception", None)
        if original is not None:
            app.logger.error("Error interno no controlado: %s", original, exc_info=True)
        else:
            app.logger.error("Error interno no controlado", exc_info=True)
        note_repeat_500()
        return _render_error_response(500, "Estamos resolviendo un problema interno. Intenta de nuevo en unos minutos.")

    @app.errorhandler(502)
    def handle_bad_gateway_error(error):
        try:
            db.session.rollback()
        except Exception:
            logger.exception("No se pudo hacer rollback después de un 502.")
        app.logger.warning("Error 502 atendido por la aplicación: %s", error)
        return _render_error_response(502, "El servidor temporalmente no pudo completar la solicitud.")

    @app.errorhandler(503)
    def handle_service_unavailable_error(error):
        try:
            db.session.rollback()
        except Exception:
            logger.exception("No se pudo hacer rollback después de un 503.")
        app.logger.warning("Error 503 atendido por la aplicación: %s", error)
        return _render_error_response(503, "El servicio está temporalmente indisponible. Intenta más tarde.")

    @app.route("/healthz", methods=["GET"])
    def healthz():
        from flask import jsonify

        critical_checks = {
            "database": "ok",
            "redis": "ok",
            "secret_key": "ok" if bool(app.config.get("SECRET_KEY")) else "missing",
            "base_url": "ok" if (app.config.get("PUBLIC_BASE_URL") or "").startswith("http") else "missing",
        }

        try:
            db.session.execute(text("SELECT 1"))
        except Exception as exc:  # pragma: no cover - health endpoint
            db.session.rollback()
            critical_checks["database"] = "down"
            critical_checks["database_error"] = str(exc)
            app.logger.exception("Health check: PostgreSQL no disponible")
            note_database_down(str(exc))

        redis_ok, redis_status, redis_error = check_redis_health(app.config.get("RATELIMIT_STORAGE_URI"))
        critical_checks["redis"] = redis_status
        if redis_error:
            critical_checks["redis_error"] = redis_error
        if not redis_ok:
            app.logger.error(
                "Health check: Redis no disponible (%s)",
                _redis_uri_summary(app.config.get("RATELIMIT_STORAGE_URI")),
            )
            note_redis_down(redis_error or "Redis no respondió.")

        missing_envs = [name for name, value in critical_checks.items() if value == "missing"]
        if critical_checks["database"] != "ok":
            logger.error("Health check falló por PostgreSQL.")
        if critical_checks["redis"] not in {"ok", "memory"}:
            logger.error("Health check falló por Redis.")
        if missing_envs:
            logger.error("Health check detectó variables o datos faltantes: %s", ", ".join(missing_envs))

        status_code = 200 if critical_checks["database"] == "ok" and critical_checks["redis"] in {"ok", "memory"} and not missing_envs else 503
        payload = {"ok": status_code == 200, **critical_checks}
        return jsonify(payload), status_code

    return app


app = create_app()


if __name__ == "__main__":
    # Evita reinicios automáticos del servidor mientras Cloudflare Tunnel mantiene conexiones abiertas.
    app.run(debug=app.config["DEBUG"], use_reloader=False)
