"""Trade-In MVP — FastAPI entry point.

Standalone версия, выделена из основного gendesign repo для локальной разработки.
Только trade-in фича; никаких других роутеров (concepts/parcels/analytics/etc) нет.
"""

from __future__ import annotations

import logging
import os
import re
import secrets
from collections.abc import AsyncGenerator, Awaitable, Callable
from contextlib import asynccontextmanager

import sentry_sdk
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, Response
from sentry_sdk.integrations.fastapi import FastApiIntegration
from sentry_sdk.integrations.httpx import HttpxIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from sentry_sdk.integrations.starlette import StarletteIntegration

from app.api.v1 import (
    admin,
    audit,
    brand,
    buildings,
    geocode,
    lead,
    me,
    search,
    support,
    trade_in,
)
from app.core.auth import get_role, is_path_allowed
from app.core.config import settings
from app.core.db import SessionLocal
from app.core.fdw import ensure_fdw_user_mapping
from app.core.ratelimit import RateLimitMiddleware
from app.core.request_audit import RequestAuditMiddleware
from app.observability.sentry_scrub import scrub_pii_event

logger = logging.getLogger(__name__)

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)

# #tgsupport-web: этот процесс теперь тоже зовёт Telegram Bot API напрямую
# (app/api/v1/support.py — sendMessage при отправке веб-сообщения в топик), не
# только изолированный tgbot_main.py. httpx-INFO логирует ПОЛНЫЙ request URL,
# включая токен в пути (https://api.telegram.org/bot<TOKEN>/...) — то же самое
# закрытие, что уже стоит в tgbot_main.py (см. его комментарий), нужно и здесь.
logging.getLogger("httpx").setLevel(logging.WARNING)

# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396).
# DSN из env GLITCHTIP_DSN; пусто (dev/текущий prod) → init не вызывается, NO-OP.
# Integrations: Starlette/FastAPI (request errors), SQLAlchemy/Httpx (breadcrumbs),
# Logging (logger.error → events). БЕЗ CeleryIntegration — prod не гоняет celery
# worker (in-app scheduler зовёт task-функции напрямую; compose = postgres/backend/
# frontend), отдельного broker нет → мониторить нечего.
if settings.glitchtip_dsn:
    from app.observability.sentry_scrub import redact_telegram_bot_token

    def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
        """Композиция PII-scrub + Telegram bot-токен redaction (#tgsupport-web) —
        см. app/tgbot_main.py._before_send (идентичная композиция, тот же риск:
        теперь этот процесс тоже держит TelegramClient в стек-фреймах при ошибке
        sendMessage, а include_local_variables=False ниже — первый рубеж защиты)."""
        scrubbed = scrub_pii_event(event, hint)  # type: ignore[arg-type]
        if scrubbed is None:
            return None
        return redact_telegram_bot_token(scrubbed, hint)  # type: ignore[arg-type,return-value]

    sentry_sdk.init(
        dsn=settings.glitchtip_dsn,
        environment=settings.environment,
        release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
        traces_sample_rate=0.0,  # только ошибки, без performance-трейсов
        send_default_pii=False,  # не шлём client_name / client_phone в отчёты
        include_local_variables=False,  # #tgsupport-web: TelegramClient._request
        # держит base URL с токеном в локальных переменных стек-фрейма — default
        # sentry_sdk (True) приложил бы их к traceback открытым текстом.
        before_send=_before_send,
        integrations=[
            StarletteIntegration(),
            FastApiIntegration(),
            SqlalchemyIntegration(),
            HttpxIntegration(),
            LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
        ],
    )
    logging.getLogger("app.main").info("GlitchTip monitoring enabled")


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
    # #2213 defense-in-depth: если общий секрет не задан — trusted-header auth
    # уязвим к подделке X-Authenticated-User изнутри docker-сети gendesign_shared.
    # Один явный WARNING на старте, чтобы это не осталось незамеченным в проде.
    if not settings.tradein_internal_auth_secret:
        logger.warning(
            "defense-in-depth НЕ активен: X-Authenticated-User принимается без "
            "проверки внутреннего секрета — задай TRADEIN_INTERNAL_AUTH_SECRET в "
            ".env.runtime ОБОИХ стеков (Caddy главного стека + tradein-backend)"
        )

    # FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server.
    # Best-effort: failure does not abort startup, just logs.
    try:
        with SessionLocal() as db:
            ensure_fdw_user_mapping(db)
    except Exception:
        logger.exception("FDW user mapping bootstrap failed — cadastral queries may fail")

    # #2397 Part C: legacy in-app scheduler launch removed — scheduling lives exclusively
    # in the tradein-scraper container (`python -m app.scheduler_main`, kit scheduler).
    # Prod backend has always run with SCHEDULER_ENABLE=false (see docker-compose.prod.yml);
    # this API process never actually launched scheduler_loop() in production.
    yield


app = FastAPI(
    title="Trade-In MVP API",
    description="Оценка вторичного жилья (выкупная стоимость) — копия trade-in feature из gendesign",  # noqa: E501
    version="0.1.0",
    lifespan=lifespan,
)

# RBAC: defense-in-depth поверх Caddy basic_auth + X-Authenticated-User
# (см. app/core/auth.py + auth/roles.yaml). Правила:
#   1) Любой non-public path требует X-Authenticated-User — иначе 401.
#   2) Юзер должен быть в roles.yaml — иначе 403 («неизвестный юзер ничего
#      не видит» — decided 2026-05-25).
#   3) /api/v1/admin/* (= внешний /trade-in/api/v1/admin/* после Caddy
#      `uri strip_prefix /trade-in`) — только role=admin, иначе 403.
# Public paths без auth (/health, /docs, /openapi.json) пропускаем —
# X-Authenticated-User там не приходит из Caddy.
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
# tradein-backend, а globs в roles.yaml — ВНЕШНИЕ (/trade-in/api/v1/**). Для
# scope-проверки восстанавливаем внешний путь.
_EXTERNAL_PREFIX = "/trade-in"
# Bootstrap-пути, доступные ЛЮБОМУ известному юзеру независимо от роли: /me отдаёт
# роль (expired → trial-экран), /brand/* — брендинг login/trial-экрана. Без них
# expired (roles.yaml paths:[] deny:/**) не получил бы роль и не увидел trial-экран.
_RBAC_BOOTSTRAP_EXEMPT = ("/api/v1/me", "/api/v1/brand")


@app.middleware("http")
async def rbac_guard(
    request: Request,
    call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
    path = request.url.path
    if path in _PUBLIC_PATHS:
        return await call_next(request)

    username = request.headers.get("X-Authenticated-User")
    if not username:
        return JSONResponse(
            status_code=401,
            content={"detail": "no authenticated user (Caddy basic_auth required)"},
        )

    # #2213 defense-in-depth: если общий секрет задан — запрос с X-Authenticated-User
    # ОБЯЗАН нести валидный X-Internal-Auth-Secret (его добавляет Caddy из env).
    # Иначе это подделка заголовка мимо Caddy (напр. изнутри gendesign_shared) → 401.
    # Constant-time compare против timing-атак. Пусто = защита не активна (fail-open).
    secret = settings.tradein_internal_auth_secret
    if secret:
        provided = request.headers.get("X-Internal-Auth-Secret", "")
        if not secrets.compare_digest(provided, secret):
            logger.warning(
                "RBAC: X-Authenticated-User=%r без валидного X-Internal-Auth-Secret "
                "на %s — возможная подделка заголовка мимо Caddy",
                username,
                path,
            )
            return JSONResponse(
                status_code=401,
                content={"detail": "invalid or missing internal auth secret"},
            )

    try:
        role = get_role(username)
    except KeyError:
        logger.warning("RBAC: unknown user %r tried %s", username, path)
        return JSONResponse(
            status_code=403,
            content={"detail": "user not in roles config"},
        )

    if _ADMIN_API_RE.match(path) and role != "admin":
        logger.info("RBAC: blocked %s (role=%s) from %s", username, role, path)
        return JSONResponse(
            status_code=403,
            content={"detail": "admin only"},
        )

    # #R2-H3: энфорсим roles.yaml scope (paths/deny) для ВСЕХ non-admin путей, а не
    # только /admin/*. Иначе revoked (role=expired, paths:[] deny:/**) или узко-
    # скоупленный аккаунт достаёт non-admin API (напр. POST /api/v1/search —
    # экспорт листингов), который roles.yaml ему запрещает. Bootstrap-пути (/me,
    # /brand) исключены выше по списку. roles.yaml globs внешние → восстанавливаем
    # внешний путь (Caddy срезал /trade-in). На сбой парса — fail-open + громкий
    # лог: не лочим платящего pilot из-за конфиг-бага (admin-гейт выше остаётся).
    if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
        external_path = _EXTERNAL_PREFIX + path
        try:
            allowed = is_path_allowed(role, external_path)
        except Exception:
            logger.exception(
                "RBAC scope-check raised for %s %s (ext=%s) — fail-open",
                username,
                path,
                external_path,
            )
            allowed = True
        if not allowed:
            logger.info(
                "RBAC: scope-blocked %s (role=%s) from %s (ext=%s)",
                username,
                role,
                path,
                external_path,
            )
            return JSONResponse(
                status_code=403,
                content={"detail": "forbidden for role"},
            )

    return await call_next(request)


app.add_middleware(
    CORSMiddleware,
    allow_origins=settings.cors_origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)
# Rate-limit публичного API (per-user / per-IP sliding window) — защита от абуза.
app.add_middleware(RateLimitMiddleware)
# Request-audit: пишет api_request/login события в user_events (Feature 2/3 foundation).
app.add_middleware(RequestAuditMiddleware)


@app.get("/health")
def health() -> dict[str, str]:
    return {"status": "ok", "environment": settings.environment}


app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"])
app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-support"])
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
app.include_router(search.router, prefix="/api/v1", tags=["search"])
app.include_router(me.router, prefix="/api/v1", tags=["me"])
