# retoor <retoor@molodetz.nl>

import asyncio
import fcntl
import logging
import os
import time
from collections import defaultdict
from contextlib import asynccontextmanager, contextmanager
from pathlib import Path
from fastapi import FastAPI, Request, WebSocket
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.exceptions import RequestValidationError
from starlette.middleware.gzip import GZipMiddleware
from devplacepy.config import (
    STATIC_DIR,
    STATIC_VERSION,
    UPLOADS_DIR,
    PORT,
    SERVICE_LOCK_FILE,
    INIT_LOCK_FILE,
    ensure_data_dirs,
)
from devplacepy.database import (
    init_db,
    get_table,
    db,
    refresh_snapshot,
    get_users_by_uids,
    get_comment_counts_by_post_uids,
    get_vote_counts,
    get_news_images_by_uids,
    get_setting,
    get_int_setting,
    interleave_by_author,
    get_user_post_count,
    get_user_stars,
    get_blocked_uids,
    get_top_authors,
    get_trending_topics,
)
from devplacepy.templating import templates, jinja_unread_count
from devplacepy.cache import TTLCache
from devplacepy.responses import respond, wants_json, json_error
from devplacepy.schemas import LandingOut, ValidationErrorOut
from fastapi.responses import JSONResponse
from devplacepy.utils import get_current_user, time_ago, safe_next, client_ip
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.routers import (
    auth,
    battles,
    feed,
    posts,
    topics,
    comments,
    projects,
    profile,
    messages,
    notifications,
    votes,
    avatar,
    awards,
    follow,
    relations,
    admin,
    seo,
    issues,
    news,
    gists,
    quizzes,
    uploads,
    media,
    push,
    leaderboard,
    reactions,
    reports,
    bookmarks,
    polls,
    docs,
    openai_gateway,
    devii,
    zips,
    forks,
    proxy,
    tools,
    xmlrpc,
    devrant,
    dbapi,
    pubsub,
    game,
    workspaces,
)
from devplacepy.services.manager import service_manager
from devplacepy.services.background import background
from devplacepy.services.news import NewsService
from devplacepy.services.bot import BotsService
from devplacepy.services.openai_gateway import GatewayService
from devplacepy.services.devii import DeviiService
from devplacepy.services.jobs.zip_service import ZipService
from devplacepy.services.jobs.fork_service import ForkService
from devplacepy.services.jobs.issue_create_service import IssueCreateService
from devplacepy.services.jobs.planning_service import PlanningReportService
from devplacepy.services.jobs.seo.service import SeoService
from devplacepy.services.jobs.seo_meta_service import SeoMetaService
from devplacepy.services.jobs.award_service import AwardService
from devplacepy.services.backup import BackupService
from devplacepy.services.dbapi.service import DbApiJobService
from devplacepy.services.pubsub import PubSubService
from devplacepy.services.notification_relay import NotificationRelayService
from devplacepy.services.opinionwar.service import OpinionWarService
from devplacepy.services.live_view_relay import LiveViewRelayService
from devplacepy.services.presence_relay import PresenceRelayService
from devplacepy.services import presence
from devplacepy.services.correction import PENDING_SCOPE_KEY
from devplacepy.services.jobs.deepsearch.service import DeepsearchService
from devplacepy.services.jobs.isslop.service import IsslopService
from devplacepy.services.gitea.service import IssueTrackerService
from devplacepy.services.containers.service import ContainerService
from devplacepy.services.containers.workspace_service import WorkspaceService
from devplacepy.services.xmlrpc import XmlrpcService
from devplacepy.services.audit import AuditService
from devplacepy.services.moderation.service import ModerationService
from devplacepy.services.moderation.screening import ContentRefused
from devplacepy.services.acceptance.service import AcceptanceService
from devplacepy.services.audit import record as audit
from devplacepy.services.push import PushService
from devplacepy.services.telegram import TelegramService
from devplacepy.services.telegram.outbox_service import TelegramOutboxService

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)

_rate_limit_store = defaultdict(list)
RATE_LIMIT = int(os.environ.get("DEVPLACE_RATE_LIMIT", "60"))
RATE_WINDOW = 60
WEB_WORKERS = max(1, int(os.environ.get("DEVPLACE_WEB_WORKERS", "1")))
RATE_LIMIT_DISABLED = os.environ.get("DEVPLACE_DISABLE_RATE_LIMIT") == "1"

HOT_SETTINGS_TTL = 2.0
_hot_settings_value: dict = {}
_hot_settings_at = 0.0
_last_rate_sweep = 0.0
RATE_SWEEP_INTERVAL = 60.0


def _hot_settings() -> dict:
    global _hot_settings_value, _hot_settings_at
    now = time.monotonic()
    if not _hot_settings_value or now - _hot_settings_at >= HOT_SETTINGS_TTL:
        _hot_settings_value = {
            "maintenance_mode": get_setting("maintenance_mode", "0"),
            "rate_limit_per_minute": max(
                1, get_int_setting("rate_limit_per_minute", RATE_LIMIT)
            ),
            "rate_limit_window_seconds": max(
                1, get_int_setting("rate_limit_window_seconds", RATE_WINDOW)
            ),
        }
        _hot_settings_at = now
    return _hot_settings_value


def _sweep_rate_limit_store(window_start: float) -> None:
    stale = [
        ip
        for ip, timestamps in _rate_limit_store.items()
        if not timestamps or timestamps[-1] <= window_start
    ]
    for ip in stale:
        del _rate_limit_store[ip]


def _worker_rate_limit(limit: int) -> int:
    return max(1, -(-limit // WEB_WORKERS))


_service_lock_handle = None


@contextmanager
def init_lock():
    handle = open(INIT_LOCK_FILE, "w")
    try:
        fcntl.flock(handle, fcntl.LOCK_EX)
        yield
    finally:
        fcntl.flock(handle, fcntl.LOCK_UN)
        handle.close()


def acquire_service_lock() -> bool:
    global _service_lock_handle
    handle = open(SERVICE_LOCK_FILE, "w")
    try:
        fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except OSError:
        handle.close()
        return False
    _service_lock_handle = handle
    return True


INLINE_MEDIA_EXTENSIONS = {
    ".jpg",
    ".jpeg",
    ".png",
    ".gif",
    ".webp",
    ".bmp",
    ".tiff",
    ".mp4",
    ".webm",
    ".ogv",
    ".mov",
    ".m4v",
    ".mp3",
}


class UploadStaticFiles(StaticFiles):
    async def get_response(self, path, scope):
        response = await super().get_response(path, scope)
        disposition = (
            "inline"
            if Path(path).suffix.lower() in INLINE_MEDIA_EXTENSIONS
            else "attachment"
        )
        response.headers["Content-Disposition"] = disposition
        response.headers["Cache-Control"] = "public, max-age=604800"
        return response


class CachedStaticFiles(StaticFiles):
    async def get_response(self, path, scope):
        response = await super().get_response(path, scope)
        if Path(path).name == "service-worker.js":
            response.headers["Cache-Control"] = "no-cache"
        else:
            response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
        return response


class FallbackStaticFiles(StaticFiles):
    async def get_response(self, path, scope):
        response = await super().get_response(path, scope)
        if Path(path).name == "service-worker.js":
            response.headers["Cache-Control"] = "no-cache"
        else:
            response.headers["Cache-Control"] = "public, max-age=3600"
        return response


@asynccontextmanager
async def lifespan(app: FastAPI):
    ensure_data_dirs()
    with init_lock():
        init_db()
        from devplacepy.push import ensure_certificates

        ensure_certificates()
    service_manager.register(NewsService())
    service_manager.register(BotsService())
    service_manager.register(GatewayService())
    service_manager.register(DeviiService())
    service_manager.register(ZipService())
    service_manager.register(ForkService())
    service_manager.register(SeoService())
    service_manager.register(SeoMetaService())
    service_manager.register(AwardService())
    service_manager.register(BackupService())
    service_manager.register(DbApiJobService())
    service_manager.register(PubSubService())
    service_manager.register(NotificationRelayService())
    service_manager.register(OpinionWarService())
    service_manager.register(LiveViewRelayService())
    service_manager.register(PresenceRelayService())
    service_manager.register(DeepsearchService())
    service_manager.register(IsslopService())
    service_manager.register(IssueCreateService())
    service_manager.register(PlanningReportService())
    service_manager.register(IssueTrackerService())
    service_manager.register(ContainerService())
    service_manager.register(WorkspaceService())
    service_manager.register(XmlrpcService())
    service_manager.register(AuditService())
    service_manager.register(ModerationService())
    service_manager.register(AcceptanceService())
    service_manager.register(PushService())
    service_manager.register(TelegramService())
    service_manager.register(TelegramOutboxService())
    if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
        await background.start()
        if acquire_service_lock():
            service_manager.set_lock_owner(True)
            service_manager.supervise()
            logger.info(f"Background services started in worker pid {os.getpid()}")
        else:
            logger.info(
                f"Worker pid {os.getpid()} declined service lock; another worker owns background services"
            )
    from devplacepy.services.statistics.tracking import start_visit_flusher

    start_visit_flusher()
    logger.info(f"DevPlace started on port {PORT}")
    yield
    logger.info("Shutting down services...")
    from devplacepy.services.statistics.tracking import flush_visits

    flush_visits()
    await service_manager.shutdown_all()
    await background.stop()
    from devplacepy.services.containers import forward

    await forward.close_client()
    from devplacepy import push

    await push.shutdown_providers()


app = FastAPI(
    title="DevPlace",
    docs_url="/swagger",
    redoc_url=None,
    openapi_url="/openapi.json",
    lifespan=lifespan,
)
app.mount(
    "/static/uploads",
    UploadStaticFiles(directory=str(UPLOADS_DIR), check_dir=False),
    name="uploads",
)
app.mount(
    f"/static/v{STATIC_VERSION}",
    CachedStaticFiles(directory=str(STATIC_DIR)),
    name="static_versioned",
)
app.mount("/static", FallbackStaticFiles(directory=str(STATIC_DIR)), name="static")


@app.exception_handler(404)
async def not_found(request: Request, exc):
    if wants_json(request):
        return json_error(404, "Not found")
    seo_ctx = base_seo_context(
        request,
        title="Not Found - DevPlace",
        description="The page you requested does not exist.",
        robots="noindex",
    )
    return templates.TemplateResponse(
        request,
        "error.html",
        {
            **seo_ctx,
            "request": request,
            "error_code": 404,
            "error_message": "Page not found",
        },
        status_code=404,
    )


@app.exception_handler(500)
async def server_error(request: Request, exc):
    logger.exception("500 error on %s %s", request.method, request.url.path)
    if wants_json(request):
        return json_error(500, "Internal server error")
    seo_ctx = base_seo_context(
        request,
        title="Server Error - DevPlace",
        description="Something went wrong.",
        robots="noindex",
    )
    return templates.TemplateResponse(
        request,
        "error.html",
        {
            **seo_ctx,
            "request": request,
            "error_code": 500,
            "error_message": "Internal server error",
        },
        status_code=500,
    )


@app.exception_handler(ContentRefused)
async def content_refused(request: Request, exc: ContentRefused):
    logger.info("content refused on %s %s: %s", request.method, request.url.path, exc.message)
    if wants_json(request):
        return json_error(400, exc.message, categories=list(exc.categories))
    seo_ctx = base_seo_context(
        request,
        title="Content not published - DevPlace",
        description=exc.message,
        robots="noindex",
    )
    return templates.TemplateResponse(
        request,
        "error.html",
        {
            **seo_ctx,
            "request": request,
            "error_code": 400,
            "error_message": exc.message,
        },
        status_code=400,
    )


_AUTH_FORM_PAGES = {
    "/auth/signup": ("signup.html", "Join DevPlace"),
    "/auth/login": ("login.html", "Sign In"),
    "/auth/forgot-password": ("forgot_password.html", "Reset Password"),
}

_FRIENDLY_ERRORS = {
    ("username", "too_short"): "Username must be between 3 and 32 characters",
    ("username", "too_long"): "Username must be between 3 and 32 characters",
    ("password", "too_short"): "Password must be at least 6 characters",
}


def _friendly_error(err):
    field = err["loc"][-1] if err.get("loc") else ""
    key = (field, err.get("type", "").replace("string_", ""))
    if key in _FRIENDLY_ERRORS:
        return _FRIENDLY_ERRORS[key]
    msg = err.get("msg", "Invalid input")
    prefix = "Value error, "
    return msg[len(prefix) :] if msg.startswith(prefix) else msg


@app.exception_handler(RequestValidationError)
async def on_validation_error(request: Request, exc: RequestValidationError):
    errors = [_friendly_error(e) for e in exc.errors()]
    if wants_json(request):
        fields: dict = {}
        for raw, friendly in zip(exc.errors(), errors):
            name = raw["loc"][-1] if raw.get("loc") else "_"
            fields.setdefault(str(name), []).append(friendly)
        return JSONResponse(
            ValidationErrorOut(fields=fields, messages=errors).model_dump(mode="json"),
            status_code=422,
        )
    path = request.url.path
    page = _AUTH_FORM_PAGES.get(path)
    if page is None and path.startswith("/auth/reset-password/"):
        page = ("reset_password.html", "Set New Password")
    if page:
        template_name, title = page
        context = {
            **base_seo_context(request, title=title, robots="noindex,nofollow"),
            "request": request,
            "errors": errors,
        }
        try:
            form = await request.form()
            context.update({k: v for k, v in form.items() if isinstance(v, str)})
        except Exception:
            pass
        if "token" in request.path_params:
            context["token"] = request.path_params["token"]
        return templates.TemplateResponse(
            request, template_name, context, status_code=400
        )
    referer = safe_next(request.headers.get("referer"), "/feed")
    return RedirectResponse(url=referer, status_code=303)


app.include_router(auth.router, prefix="/auth")
app.include_router(feed.router, prefix="/feed")
app.include_router(posts.router, prefix="/posts")
app.include_router(topics.router, prefix="/topics")
app.include_router(comments.router, prefix="/comments")
app.include_router(projects.router, prefix="/projects")
app.include_router(profile.router, prefix="/profile")
app.include_router(messages.router, prefix="/messages")
app.include_router(notifications.router, prefix="/notifications")
app.include_router(votes.router, prefix="/votes")
app.include_router(reactions.router, prefix="/reactions")
app.include_router(reports.router, prefix="/reports")
app.include_router(bookmarks.router, prefix="/bookmarks")
app.include_router(polls.router, prefix="/polls")
app.include_router(avatar.router, prefix="/avatar")
app.include_router(awards.router, prefix="/awards")
app.include_router(follow.router, prefix="/follow")
app.include_router(relations.router)
app.include_router(leaderboard.router, prefix="/leaderboard")
app.include_router(admin.router, prefix="/admin")
app.include_router(seo.router)
app.include_router(push.router)
app.include_router(docs.router)
app.include_router(openai_gateway.router, prefix="/openai")
app.include_router(devii.router, prefix="/devii")
app.include_router(issues.router, prefix="/issues")
app.include_router(gists.router, prefix="/gists")
app.include_router(news.router, prefix="/news")
app.include_router(uploads.router, prefix="/uploads")
app.include_router(media.router, prefix="/media")
app.include_router(zips.router, prefix="/zips")
app.include_router(forks.router, prefix="/forks")
app.include_router(proxy.router, prefix="/p")
app.include_router(tools.router, prefix="/tools")
app.include_router(xmlrpc.router, prefix="/xmlrpc")
app.include_router(devrant.router, prefix="/api")
app.include_router(dbapi.router, prefix="/dbapi")
app.include_router(pubsub.router, prefix="/pubsub")
app.include_router(game.router, prefix="/game")
app.include_router(quizzes.router, prefix="/quizzes")
app.include_router(battles.router, prefix="/battles")
app.include_router(workspaces.router, prefix="/workspaces")


@app.middleware("http")
async def refresh_db_snapshot(request: Request, call_next):
    if not request.url.path.startswith(("/static", "/avatar")):
        refresh_snapshot()
    return await call_next(request)


@app.middleware("http")
async def await_pending_corrections(request: Request, call_next):
    response = await call_next(request)
    pending = request.scope.get(PENDING_SCOPE_KEY)
    if pending:
        await asyncio.gather(*pending, return_exceptions=True)
    return response


def _frame_ancestors() -> str:
    from devplacepy.services.containers.workspace import naming

    tunnel_domain = naming.domain()
    if not tunnel_domain:
        return "'self'"
    return f"'self' https://*.{tunnel_domain}"


@app.middleware("http")
async def add_security_headers(request: Request, call_next):
    response = await call_next(request)
    if not response.headers.get("X-Robots-Tag"):
        response.headers["X-Robots-Tag"] = "index, follow"
    response.headers["X-Content-Type-Options"] = "nosniff"
    response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
    response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
    if not request.url.path.startswith("/p/"):
        response.headers["Content-Security-Policy"] = (
            "object-src 'none'; base-uri 'self'; "
            f"frame-ancestors {_frame_ancestors()}; form-action 'self'"
        )
    if request.url.path.startswith("/admin"):
        response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
        response.headers["Pragma"] = "no-cache"
        response.headers["Expires"] = "0"
    return response


@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
    if RATE_LIMIT_DISABLED:
        return await call_next(request)
    if request.method in (
        "POST",
        "PUT",
        "DELETE",
        "PATCH",
    ) and not request.url.path.startswith(("/openai", "/xmlrpc")):
        settings = _hot_settings()
        limit = _worker_rate_limit(settings["rate_limit_per_minute"])
        window = settings["rate_limit_window_seconds"]
        ip = client_ip(request, default="unknown")
        now = time.time()
        window_start = now - window
        global _last_rate_sweep
        if now - _last_rate_sweep >= RATE_SWEEP_INTERVAL:
            _sweep_rate_limit_store(window_start)
            _last_rate_sweep = now
        timestamps = [t for t in _rate_limit_store.get(ip, ()) if t > window_start]
        if len(timestamps) >= limit:
            _rate_limit_store[ip] = timestamps
            audit.record(
                request,
                "security.rate_limit.block",
                result="denied",
                summary=f"request from {ip} blocked by rate limit",
                metadata={"ip": ip, "limit": limit, "window_seconds": window},
            )
            retry_after = {"Retry-After": str(window)}
            if wants_json(request):
                response = json_error(429, "Rate limit exceeded. Try again later.")
                response.headers["Retry-After"] = str(window)
                return response
            return HTMLResponse(
                "Rate limit exceeded. Try again later.",
                status_code=429,
                headers=retry_after,
            )
        timestamps.append(now)
        _rate_limit_store[ip] = timestamps
    return await call_next(request)


_MAINTENANCE_ALLOWED_PREFIXES = ("/static", "/avatar", "/auth", "/admin", "/openai")


@app.middleware("http")
async def maintenance_middleware(request: Request, call_next):
    if _hot_settings()["maintenance_mode"] != "1":
        return await call_next(request)
    if request.url.path.startswith(_MAINTENANCE_ALLOWED_PREFIXES):
        return await call_next(request)
    user = get_current_user(request)
    if user and user.get("role") == "Admin":
        return await call_next(request)
    message = get_setting(
        "maintenance_message",
        "DevPlace is undergoing scheduled maintenance. Please check back shortly.",
    )
    audit.record(
        request,
        "security.maintenance.block",
        user=user,
        result="denied",
        summary="non-admin request blocked by maintenance mode",
    )
    if wants_json(request):
        return json_error(503, message)
    seo_ctx = base_seo_context(
        request, title="Maintenance - DevPlace", description=message, robots="noindex"
    )
    return templates.TemplateResponse(
        request,
        "error.html",
        {**seo_ctx, "request": request, "error_code": 503, "error_message": message},
        status_code=503,
    )


_TERMS_ALLOWED_PREFIXES = (
    "/static",
    "/avatar",
    "/auth",
    "/docs",
    "/reports",
    "/block",
    "/mute",
    "/openai",
)

_TERMS_GATED_METHODS = ("POST", "PUT", "DELETE", "PATCH")


def _terms_exempt(path: str) -> bool:
    if path.startswith(_TERMS_ALLOWED_PREFIXES):
        return True
    return path.startswith("/profile/") and (
        path.endswith("/delete") or path.endswith("/consent")
    )


@app.middleware("http")
async def terms_acceptance_gate(request: Request, call_next):
    if request.method not in _TERMS_GATED_METHODS or _terms_exempt(request.url.path):
        return await call_next(request)
    from devplacepy.routers.auth.terms import (
        TERMS_ACCEPTANCE_CODE,
        current_terms_version,
        needs_acceptance,
    )

    user = get_current_user(request)
    if not needs_acceptance(user):
        return await call_next(request)
    message = "Accept the updated Terms of Service to continue."
    if wants_json(request):
        return json_error(
            403,
            message,
            code=TERMS_ACCEPTANCE_CODE,
            redirect="/auth/accept-terms",
            terms_version=current_terms_version(),
        )
    return RedirectResponse(url="/auth/accept-terms", status_code=303)


@app.middleware("http")
async def track_presence(request: Request, call_next):
    path = request.url.path
    if not path.startswith(("/static", "/avatar")):
        user = get_current_user(request)
        if user:
            presence.touch(user["uid"])
    return await call_next(request)


@app.middleware("http")
async def visit_statistics(request: Request, call_next):
    from devplacepy.services.statistics.tracking import track_visit

    response = await call_next(request)
    track_visit(request, response.status_code)
    return response


@app.middleware("http")
async def response_timing(request: Request, call_next):
    start = time.perf_counter()
    request.state.request_start = start
    response = await call_next(request)
    response.headers["X-Response-Time"] = f"{(time.perf_counter() - start) * 1000:.1f}ms"
    return response


class TunnelDispatchMiddleware:
    def __init__(self, app):
        self.app = app

    @staticmethod
    def _host(scope) -> str:
        for key, value in scope.get("headers") or []:
            if key == b"host":
                return value.decode("latin-1")
        return ""

    async def __call__(self, scope, receive, send):
        if scope["type"] not in ("http", "websocket"):
            await self.app(scope, receive, send)
            return
        from devplacepy.routers import tunnel as tunnel_router
        from devplacepy.services.containers.workspace import naming

        try:
            if not naming.is_tunnel_host(self._host(scope)):
                await self.app(scope, receive, send)
                return
        except Exception:
            await self.app(scope, receive, send)
            return
        path = (scope.get("path") or "/").lstrip("/")
        if scope["type"] == "websocket":
            websocket = WebSocket(scope, receive, send)
            await tunnel_router.handle_ws(websocket, path)
            return
        request = Request(scope, receive)
        response = await tunnel_router.handle_http(request, path)
        await response(scope, receive, send)


app.add_middleware(TunnelDispatchMiddleware)
app.add_middleware(GZipMiddleware, minimum_size=512, compresslevel=5)


_home_cache = TTLCache(ttl=int(os.environ.get("DEVPLACE_HOME_CACHE_TTL", "60")), max_size=4)


def _landing_news():
    cached = _home_cache.get("news")
    if cached is not None:
        return cached
    articles = []
    if "news" in db.tables:
        news_table = get_table("news")
        raw = list(
            news_table.find(show_on_landing=1, order_by=["-synced_at"], _limit=6)
        )
        images_by_news = get_news_images_by_uids([a["uid"] for a in raw])
        for a in raw:
            articles.append(
                {
                    "uid": a["uid"],
                    "slug": a.get("slug", ""),
                    "title": a.get("title", ""),
                    "description": (a.get("description", "") or "")[:250],
                    "url": a.get("url", ""),
                    "source_name": a.get("source_name", ""),
                    "grade": a.get("grade", 0),
                    "featured": a.get("featured", 0),
                    "synced_at": a.get("synced_at", "") or "",
                    "time_ago": time_ago(a["synced_at"]),
                    "image_url": a.get("image_url", "") or images_by_news.get(a["uid"], ""),
                }
            )
    _home_cache.set("news", articles)
    return articles


def _landing_recent_posts(blocked):
    if not blocked:
        cached = _home_cache.get("posts")
        if cached is not None:
            return cached
    posts = []
    if "posts" in db.tables:
        posts_table = get_table("posts")
        clauses = []
        if blocked:
            clauses.append(posts_table.table.columns.user_uid.notin_(blocked))
        raw_posts = list(
            posts_table.find(
                *clauses, deleted_at=None, order_by=["-created_at"], _limit=6
            )
        )
        raw_posts = interleave_by_author(raw_posts)
        if raw_posts:
            post_uids = [p["uid"] for p in raw_posts]
            author_uids = [p["user_uid"] for p in raw_posts]
            authors = get_users_by_uids(author_uids)
            comment_counts = get_comment_counts_by_post_uids(post_uids)
            upvotes, downvotes = get_vote_counts(post_uids)
            for p in raw_posts:
                posts.append(
                    {
                        "post": p,
                        "author": authors.get(p["user_uid"]),
                        "time_ago": time_ago(p["created_at"]),
                        "comment_count": comment_counts.get(p["uid"], 0),
                        "stars": upvotes.get(p["uid"], 0) - downvotes.get(p["uid"], 0),
                        "slug": p.get("slug", "") or p["uid"],
                    }
                )
    if not blocked:
        _home_cache.set("posts", posts)
    return posts


@app.get("/")
async def landing(request: Request):
    user = get_current_user(request)

    landing_articles = _landing_news()
    blocked = get_blocked_uids(user["uid"]) if user else frozenset()
    landing_posts = _landing_recent_posts(blocked)

    base = site_url(request)
    seo_ctx = base_seo_context(
        request,
        title="DevPlace - The Developer Social Network",
        description="Track industry shifts. Discover bold releases. Share what you're building in an open environment built by developers, for developers.",
        breadcrumbs=[],
        schemas=[website_schema(base)],
    )
    user_xp = user.get("xp", 0) or 0 if user else 0
    user_level = user.get("level", 1) or 1 if user else 1
    xp_progress_pct = (user_xp % 100) if user_xp else 0
    unread_count = jinja_unread_count(user["uid"]) if user else 0

    top_contributors = get_top_authors(5) if not blocked else []
    trending_topics = get_trending_topics(6) if not blocked else []

    return respond(
        request,
        "landing.html",
        {
            **seo_ctx,
            "request": request,
            "user": user,
            "is_authenticated": bool(user),
            "user_post_count": get_user_post_count(user["uid"]) if user else 0,
            "user_stars": get_user_stars(user["uid"]) if user else 0,
            "user_xp": user_xp,
            "user_level": user_level,
            "xp_progress_pct": xp_progress_pct,
            "unread_count": unread_count,
            "landing_articles": landing_articles,
            "landing_posts": landing_posts,
            "top_contributors": top_contributors,
            "trending_topics": trending_topics,
        },
        model=LandingOut,
    )
