"""bincio serve — multi-user FastAPI application server.

Handles auth, user management, and auth-gated write operations.
nginx serves static files; this server only handles /api/* routes.

Run via `bincio serve` CLI command.
"""

from __future__ import annotations

import json
import logging
import os
import re
import secrets
import shutil
import subprocess
import threading
import time
import uuid
from pathlib import Path
from typing import Any, Optional

log = logging.getLogger("bincio.serve")

from fastapi import Cookie, Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile
from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse

from bincio.serve.db import (
    User,
    authenticate,
    count_activity_users,
    count_wiki_users,
    create_invite,
    create_session,
    count_users,
    create_user,
    delete_session,
    get_invite,
    get_member_tree,
    get_session,
    get_setting,
    get_user,
    get_user_prefs,
    set_user_prefs,
    list_invites,
    list_users,
    open_db,
    use_invite,
)

from pydantic import BaseModel, Field

# ── Pydantic request/response models ─────────────────────────────────────────


class LoginRequest(BaseModel):
    handle: str = Field(..., description="User handle (username)")
    password: str = Field(..., description="User password")


class LoginResponse(BaseModel):
    ok: bool = Field(True, description="Success flag")
    handle: str = Field(..., description="User handle")
    display_name: str = Field(..., description="User's display name")


class ResetPasswordRequest(BaseModel):
    handle: str = Field(..., description="User handle")
    code: str = Field(..., description="Reset code (24 hours valid)")
    password: str = Field(..., description="New password (min 8 chars)")


class RegisterRequest(BaseModel):
    code: str = Field(..., description="Invite code")
    handle: str = Field(..., description="Desired username (lowercase, 1-30 chars)")
    password: str = Field(..., description="Password (min 8 characters)")
    display_name: str = Field(default="", description="Full name (optional, defaults to handle)")


class RegisterResponse(BaseModel):
    ok: bool = Field(True, description="Success flag")
    handle: str = Field(..., description="New user's handle")


class CurrentUserResponse(BaseModel):
    handle: str = Field(..., description="User handle")
    display_name: str = Field(..., description="User's display name")
    is_admin: bool = Field(..., description="Whether user is an admin")
    store_originals_default: bool = Field(
        default=True,
        description="Instance-wide default for storing original files"
    )


class ActivityEditRequest(BaseModel):
    title: str | None = Field(default=None, description="Activity title")
    description: str | None = Field(default=None, description="Activity description (markdown)")
    sport: str | None = Field(default=None, description="Sport type")
    sub_sport: str | None = Field(default=None, description="Sport sub-category (e.g. road, trail, pool)")
    private: bool | None = Field(default=None, description="Hide from public feed")
    highlight: bool | None = Field(default=None, description="Mark as favorite")
    gear: str | None = Field(default=None, description="Gear used (e.g., 'Trek Domane')")


class ActivityEditResponse(BaseModel):
    ok: bool = Field(True, description="Success flag")


class ResetPasswordCodeResponse(BaseModel):
    ok: bool = Field(True, description="Success flag")
    code: str = Field(..., description="One-time reset code")
    expires_in_hours: int = Field(24, description="Code validity period in hours")


class GenericResponse(BaseModel):
    ok: bool = Field(True, description="Success flag")


# ── Active job tracker ───────────────────────────────────────────────────────
# Tracks in-progress upload/processing jobs so admins can see what's running.
# Jobs are added when a streaming upload starts and removed when it finishes.

_jobs_lock = threading.Lock()
_active_jobs: dict[str, dict] = {}


def _job_start(user_handle: str, total_files: int) -> str:
    job_id = uuid.uuid4().hex[:8]
    with _jobs_lock:
        _active_jobs[job_id] = {
            "id": job_id,
            "user": user_handle,
            "started_at": int(time.time()),
            "total": total_files,
            "done": 0,
            "current": "",
        }
    return job_id


def _job_update(job_id: str, done: int, current: str) -> None:
    with _jobs_lock:
        if job_id in _active_jobs:
            _active_jobs[job_id]["done"] = done
            _active_jobs[job_id]["current"] = current


def _job_finish(job_id: str) -> None:
    with _jobs_lock:
        _active_jobs.pop(job_id, None)


# ── Globals (set by CLI before uvicorn starts) ────────────────────────────────

data_dir: Path | None = None
site_dir: Path | None = None   # for post-write rebuild trigger
webroot: Path | None = None    # nginx webroot — when set, trigger full rebuild + rsync
strava_client_id: str = ""
strava_client_secret: str = ""
public_url: str = ""   # e.g. "https://yourdomain.com" — used for OAuth redirect URIs
dem_url: str = "https://api.open-elevation.com"  # Open-Elevation-compatible API base URL
sync_secret: str = ""  # shared secret for /api/internal/rebuild (set via --sync-secret)
_db = None  # sqlite3.Connection, opened lazily
_strava_sync_running = False
_strava_sync_lock = threading.Lock()


def _get_db():
    global _db
    if _db is None:
        _db = open_db(_get_data_dir())
    return _db


_STRAVA_CREDS_FILE = "strava_credentials.json"


def _strava_creds(handle: str) -> tuple[str, str]:
    """Return (client_id, client_secret) for a user.

    Per-user credentials stored in {user_dir}/strava_credentials.json take
    precedence over the global instance-level strava_client_id/secret.
    Returns ("", "") when neither is configured.
    """
    creds_path = _get_data_dir() / handle / _STRAVA_CREDS_FILE
    if creds_path.exists():
        try:
            d = json.loads(creds_path.read_text(encoding="utf-8"))
            cid = str(d.get("client_id", "")).strip()
            csec = str(d.get("client_secret", "")).strip()
            if cid and csec:
                return cid, csec
        except Exception:
            pass
    return strava_client_id, strava_client_secret


def _get_data_dir() -> Path:
    if data_dir is None:
        raise HTTPException(500, "Server not configured")
    return data_dir


# ── App ───────────────────────────────────────────────────────────────────────

app = FastAPI(title="BincioActivity Serve")


@app.on_event("startup")
async def _on_startup() -> None:
    """Startup tasks: clean orphaned tmp zips; launch site-rebuild worker if --webroot set."""
    import glob as _glob
    data_dir = _get_data_dir()
    for p in _glob.glob(str(data_dir / "*" / "tmp*.zip")):
        try:
            Path(p).unlink()
        except Exception:
            pass
    if webroot is not None:
        threading.Thread(target=_site_rebuild_worker, daemon=True, name="site-rebuild").start()


app.add_middleware(GZipMiddleware, minimum_size=1024)
app.add_middleware(
    CORSMiddleware,
    allow_origin_regex=r"https?://localhost(:\d+)?",
    allow_credentials=True,
    allow_methods=["GET", "POST", "DELETE"],
    allow_headers=["Content-Type"],
)

_VALID_HANDLE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,29}$')
from bincio.edit.ops import VALID_ACTIVITY_ID as _VALID_ACTIVITY_ID
_SESSION_COOKIE  = "bincio_session"
_COOKIE_MAX_AGE  = 30 * 86400  # 30 days
_SESSION_DOMAIN  = os.environ.get("SESSION_DOMAIN") or None  # e.g. ".bincio.org" in production


def _check_id(activity_id: str) -> str:
    if not _VALID_ACTIVITY_ID.match(activity_id):
        raise HTTPException(400, "Invalid activity ID")
    return activity_id

# ── Rate limiting (simple in-memory, per IP) ──────────────────────────────────

_login_attempts: dict[str, list[float]] = {}
_register_attempts: dict[str, list[float]] = {}
_RATE_WINDOW = 900   # 15 minutes
_LOGIN_RATE_LIMIT    = 10
_REGISTER_RATE_LIMIT = 5


def _check_rate_limit(
    ip: str,
    store: dict[str, list[float]],
    limit: int,
    msg: str = "Too many attempts. Try again later.",
) -> None:
    now = time.time()
    attempts = [t for t in store.get(ip, []) if now - t < _RATE_WINDOW]
    store[ip] = attempts
    if len(attempts) >= limit:
        raise HTTPException(429, msg)
    attempts.append(now)
    store[ip] = attempts


# ── Auth helpers ──────────────────────────────────────────────────────────────

def _current_user(bincio_session: Optional[str] = Cookie(default=None)) -> Optional[User]:
    if not bincio_session:
        return None
    return get_session(_get_db(), bincio_session)


def _require_user(bincio_session: Optional[str] = Cookie(default=None)) -> User:
    user = _current_user(bincio_session)
    if not user:
        raise HTTPException(401, "Not authenticated")
    return user


def _require_admin(bincio_session: Optional[str] = Cookie(default=None)) -> User:
    user = _require_user(bincio_session)
    if not user.is_admin:
        raise HTTPException(403, "Admin required")
    return user


def _require_auth(
    request: Request,
    bincio_session: Optional[str] = Cookie(default=None),
) -> User:
    """Accept session cookie (web) OR Authorization: Bearer token (mobile)."""
    token = bincio_session
    if not token:
        auth = request.headers.get("Authorization", "")
        if auth.startswith("Bearer "):
            token = auth[7:]
    if not token:
        raise HTTPException(401, "Not authenticated")
    user = get_session(_get_db(), token)
    if not user:
        raise HTTPException(401, "Invalid or expired session")
    return user


def _set_session_cookie(response: Response, token: str) -> None:
    kwargs: dict = dict(
        key=_SESSION_COOKIE,
        value=token,
        max_age=_COOKIE_MAX_AGE,
        httponly=True,
        samesite="lax",
        secure=False,  # nginx/caddy handles TLS termination
    )
    if _SESSION_DOMAIN:
        kwargs["domain"] = _SESSION_DOMAIN
    response.set_cookie(**kwargs)


# ── Image upload constants ────────────────────────────────────────────────────

_ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
_MAX_IMAGE_BYTES = 10 * 1024 * 1024  # 10 MB


def _unique_image_name(directory: Path, filename: str) -> str:
    """Return a filename that does not collide with existing files in directory."""
    stem, suffix = Path(filename).stem, Path(filename).suffix
    candidate = filename
    counter = 1
    while (directory / candidate).exists():
        candidate = f"{stem}_{counter}{suffix}"
        counter += 1
    return candidate


# ── Post-write rebuild ────────────────────────────────────────────────────────

# Serialises per-user merge subprocesses — concurrent merge_all runs on the
# same user dir would corrupt _merged/activities/.
_rebuild_lock = threading.Lock()

# Signals the site-rebuild worker that at least one merge has completed.
# Using an Event as a boolean flag: set() by any merge, cleared by the worker.
_site_rebuild_event = threading.Event()


def _site_rebuild_worker() -> None:
    """Single background thread: debounced Astro build + rsync after uploads.

    Waits for _site_rebuild_event, sleeps 60 s to let upload bursts settle,
    then runs one full build. 271 concurrent uploads → 1 build, not 271.
    Uploads that arrive during the build set the event again, so a follow-up
    build starts after the current one finishes.
    """
    _webroot   = str(webroot)
    _data_dir  = str(data_dir)
    _site_dir  = str(site_dir)
    uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv")
    while True:
        _site_rebuild_event.wait()
        _site_rebuild_event.clear()
        time.sleep(60)               # collect burst uploads
        _site_rebuild_event.clear()  # discard signals from the sleep window
        log.info("site-rebuild: starting full build + rsync to %s", _webroot)
        try:
            result = subprocess.run(
                [uv, "run", "bincio", "render",
                 "--data-dir", _data_dir,
                 "--site-dir", _site_dir],
                capture_output=True,
                text=True,
            )
            if result.returncode != 0:
                log.error("site-rebuild: build failed (rc=%d):\n%s\n%s",
                          result.returncode, result.stdout, result.stderr)
                continue
            dist_data = Path(_site_dir) / "dist" / "data"
            if dist_data.exists():
                shutil.rmtree(dist_data)
            rsync = subprocess.run(
                ["rsync", "-a", "--delete", "--exclude=data/",
                 f"{_site_dir}/dist/", _webroot + "/"],
                capture_output=True,
                text=True,
            )
            if rsync.returncode != 0:
                log.error("site-rebuild: rsync failed (rc=%d):\n%s\n%s",
                          rsync.returncode, rsync.stdout, rsync.stderr)
            else:
                log.info("site-rebuild: done")
        except Exception:
            log.exception("site-rebuild: unexpected error")


def _trigger_rebuild(handle: str) -> None:
    """Merge sidecars for handle asynchronously; signal the site-rebuild worker."""
    if site_dir is None:
        return
    if not _VALID_HANDLE.match(handle):
        return  # safety: never pass untrusted strings to subprocess

    uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv")
    _data_dir = str(data_dir)
    _site_dir = str(site_dir)
    _handle   = handle

    def _run() -> None:
        try:
            log.info("rebuild[%s]: merge-only", _handle)
            with _rebuild_lock:
                result = subprocess.run(
                    [uv, "run", "bincio", "render",
                     "--data-dir", _data_dir,
                     "--site-dir", _site_dir,
                     "--handle", _handle,
                     "--no-build"],
                    capture_output=True,
                    text=True,
                )
                if result.returncode != 0:
                    log.error("rebuild[%s]: merge failed (rc=%d):\n%s\n%s",
                              _handle, result.returncode, result.stdout, result.stderr)
                else:
                    log.info("rebuild[%s]: merge done", _handle)
                    if webroot is not None:
                        _site_rebuild_event.set()
        except Exception:
            log.exception("rebuild[%s]: unexpected error", _handle)

    threading.Thread(target=_run, daemon=True).start()


# ── Auth endpoints ────────────────────────────────────────────────────────────

@app.get("/api/me", response_model=CurrentUserResponse)
async def me(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
    user = _current_user(bincio_session)
    if not user:
        raise HTTPException(401, "Not authenticated")
    store_orig = get_setting(_get_db(), "store_originals")
    return JSONResponse({
        "handle": user.handle,
        "display_name": user.display_name,
        "is_admin": user.is_admin,
        "wiki_access": user.wiki_access,
        "activity_access": user.activity_access,
        "store_originals_default": store_orig != "false",
        "dem_configured": bool(dem_url),
    })


@app.get("/api/stats")
async def stats() -> JSONResponse:
    """Public endpoint: member count, join dates, and invitation tree."""
    import time as _time
    now = int(_time.time())
    members = get_member_tree(_get_db())
    return JSONResponse({
        "user_count": len(members),
        "members": [
            {
                "handle": m["handle"],
                "display_name": m["display_name"],
                "member_since": m["created_at"],
                "member_for_days": (now - m["created_at"]) // 86400,
                "invited_by": m["invited_by"],
            }
            for m in members
        ],
    })


@app.post("/api/internal/rebuild")
async def internal_rebuild(request: Request) -> JSONResponse:
    """Trigger a site rebuild. Authenticated via X-Sync-Secret header.

    Called by the bincio sync-strava systemd timer after syncing new activities.
    Returns 503 if webroot is not configured (rebuild not possible).
    Returns 403 if the secret is missing or wrong.
    """
    if not sync_secret:
        raise HTTPException(503, "Rebuild endpoint not configured (no sync secret set)")
    if request.headers.get("X-Sync-Secret") != sync_secret:
        raise HTTPException(403, "Forbidden")
    if site_dir is None:
        raise HTTPException(503, "No site dir configured")
    _site_rebuild_event.set()
    return JSONResponse({"status": "rebuild queued"})


@app.get("/api/activity/{activity_id}/geojson")
async def get_activity_geojson(
    activity_id: str,
    user: User = Depends(_require_auth),
) -> JSONResponse:
    """Return GeoJSON track for an activity (mobile detail screen)."""
    _check_id(activity_id)
    dd = _get_data_dir()
    user_dir = dd / user.handle
    for base in (user_dir / "_merged" / "activities", user_dir / "activities"):
        p = base / f"{activity_id}.geojson"
        if p.exists():
            return JSONResponse(json.loads(p.read_text()))
    raise HTTPException(404, "GeoJSON not found")


@app.get("/api/activity/{activity_id}/timeseries")
async def get_activity_timeseries(
    activity_id: str,
    user: User = Depends(_require_auth),
) -> JSONResponse:
    """Return timeseries for an activity (mobile detail screen)."""
    _check_id(activity_id)
    dd = _get_data_dir()
    user_dir = dd / user.handle
    for base in (user_dir / "_merged" / "activities", user_dir / "activities"):
        p = base / f"{activity_id}.timeseries.json"
        if p.exists():
            return JSONResponse(json.loads(p.read_text()))
    raise HTTPException(404, "Timeseries not found")


def _upsert_index_summary(user_dir: Path, activity_id: str, activity: dict, geojson: Optional[dict] = None) -> None:
    """Add or update an activity summary in user_dir/index.json.

    Called after writing BAS activity files so that merge_all can include the
    activity in year shards.  Without this, uploaded activities exist on disk
    but never appear in the browser feed.
    """
    # Build preview coords from geojson if available ([lat, lng] order)
    preview: Optional[list] = None
    if geojson:
        try:
            coords = geojson.get("geometry", {}).get("coordinates", [])
            if coords:
                step = max(1, len(coords) // 9)
                preview = [[c[1], c[0]] for c in coords[::step]][:9]
        except Exception:
            pass

    has_track = (user_dir / "activities" / f"{activity_id}.geojson").exists()
    summary = {
        "id": activity_id,
        "title": activity.get("title", activity_id),
        "sport": activity.get("sport"),
        "sub_sport": activity.get("sub_sport"),
        "started_at": activity.get("started_at"),
        "distance_m": activity.get("distance_m"),
        "duration_s": activity.get("duration_s"),
        "moving_time_s": activity.get("moving_time_s"),
        "elevation_gain_m": activity.get("elevation_gain_m"),
        "avg_speed_kmh": activity.get("avg_speed_kmh"),
        "max_speed_kmh": activity.get("max_speed_kmh"),
        "avg_hr_bpm": activity.get("avg_hr_bpm"),
        "max_hr_bpm": activity.get("max_hr_bpm"),
        "avg_cadence_rpm": activity.get("avg_cadence_rpm"),
        "avg_power_w": activity.get("avg_power_w"),
        "mmp": activity.get("mmp"),
        "best_efforts": activity.get("best_efforts"),
        "best_climb_m": activity.get("best_climb_m"),
        "source": activity.get("source"),
        "privacy": activity.get("privacy", "public"),
        "detail_url": f"activities/{activity_id}.json",
        "track_url": f"activities/{activity_id}.geojson" if has_track else None,
        "preview_coords": preview,
    }

    index_path = user_dir / "index.json"
    if index_path.exists():
        index_data = json.loads(index_path.read_text(encoding="utf-8"))
    else:
        index_data = {
            "bas_version": "1.0",
            "owner": {"handle": user_dir.name},
            "generated_at": None,
            "activities": [],
        }
    existing = {a["id"]: a for a in index_data.get("activities", [])}
    existing[activity_id] = summary
    index_data["activities"] = sorted(existing.values(), key=lambda a: a.get("started_at", ""), reverse=True)
    index_path.write_text(json.dumps(index_data, indent=2, ensure_ascii=False), encoding="utf-8")


@app.post("/api/upload/bas")
async def upload_bas_activity(
    request: Request,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    """Accept a pre-extracted BAS activity JSON from the mobile app.

    Body (JSON):
      activity   – full BAS activity dict (required, must have 'id')
      timeseries – timeseries dict (optional)
      geojson    – GeoJSON dict (optional)

    Returns:
      {"ok": true, "id": "...", "status": "imported" | "duplicate"}
    """
    user = _require_auth(request, bincio_session)
    body = await request.json()

    activity = body.get("activity")
    if not activity or not activity.get("id"):
        raise HTTPException(400, "Missing activity.id")

    activity_id = str(activity["id"])
    _check_id(activity_id)

    user_dir = _get_data_dir() / user.handle
    acts_dir = user_dir / "activities"
    acts_dir.mkdir(parents=True, exist_ok=True)

    out = acts_dir / f"{activity_id}.json"
    if out.exists():
        return JSONResponse({"ok": True, "id": activity_id, "status": "duplicate"})

    out.write_text(json.dumps(activity, ensure_ascii=False, indent=2), encoding="utf-8")

    if body.get("timeseries"):
        ts_path = acts_dir / f"{activity_id}.timeseries.json"
        if not ts_path.exists():
            ts_path.write_text(json.dumps(body["timeseries"], ensure_ascii=False), encoding="utf-8")

    geojson_body: Optional[dict] = body.get("geojson") or None
    if geojson_body:
        gj_path = acts_dir / f"{activity_id}.geojson"
        if not gj_path.exists():
            gj_path.write_text(json.dumps(geojson_body, ensure_ascii=False), encoding="utf-8")

    _upsert_index_summary(user_dir, activity_id, activity, geojson_body)

    try:
        from bincio.render.merge import merge_one, write_combined_feed
        merge_one(user_dir, activity_id)
        write_combined_feed(_get_data_dir())
    except Exception as exc:
        log.warning("upload/bas[%s]: merge/feed failed (non-fatal): %s", user.handle, exc)

    log.info("upload/bas[%s]: imported %s", user.handle, activity_id)
    return JSONResponse({"ok": True, "id": activity_id, "status": "imported"})


@app.post("/api/upload/raw")
async def upload_raw_activity(
    request: Request,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    """Accept a raw FIT/GPX file (base64-encoded) from the mobile app, extract it
    server-side, store it in the user's activity library, and return the full
    extracted data so the mobile can cache it locally.

    Used when the device WebView is too old to run Pyodide (e.g. Karoo / Chrome <69).

    Body (JSON):
      filename  – original filename (used only to determine file extension)
      base64    – base64-encoded raw file bytes

    Auth: Authorization: Bearer <token>

    Returns:
      {"ok": true, "id": "...", "detail": {...}, "timeseries": {...}|null,
       "geojson": {...}|null, "source_hash": "<sha256-hex>"}
    """
    import base64 as _b64
    import hashlib

    user = _require_auth(request, bincio_session)

    body = await request.json()
    filename_hint: str = body.get("filename") or "activity.fit"
    b64: str = body.get("base64") or ""
    user_title: Optional[str] = body.get("user_title") or None
    if not b64:
        raise HTTPException(400, "Missing base64 field")

    try:
        raw = _b64.b64decode(b64)
    except Exception:
        raise HTTPException(400, "Invalid base64 encoding")

    source_hash = hashlib.sha256(raw).hexdigest()

    suffix = Path(filename_hint).suffix or ".fit"
    tmp_in = Path(f"/tmp/bincio_raw_{uuid.uuid4()}{suffix}")
    tmp_out = Path(f"/tmp/bincio_out_{uuid.uuid4()}")
    try:
        tmp_in.write_bytes(raw)
        tmp_out.mkdir()

        from bincio.extract.parsers.factory import parse_file
        from bincio.extract.metrics import compute
        from bincio.extract.writer import make_activity_id, write_activity
        from bincio.extract.timeseries import build_timeseries

        activity = parse_file(tmp_in)
        metrics = compute(activity)
        write_activity(activity, metrics, tmp_out, privacy="public", rdp_epsilon=0.0001)
        act_id = make_activity_id(activity)

        acts_tmp = tmp_out / "activities"
        detail_path  = acts_tmp / f"{act_id}.json"
        ts_path      = acts_tmp / f"{act_id}.timeseries.json"
        geojson_path = acts_tmp / f"{act_id}.geojson"

        if not ts_path.exists():
            ts_data = build_timeseries(activity.points, activity.started_at, "public")
            if ts_data.get("t"):
                ts_path.write_text(json.dumps(ts_data))

        detail     = json.loads(detail_path.read_text())
        timeseries = json.loads(ts_path.read_text()) if ts_path.exists() else None
        geojson    = json.loads(geojson_path.read_text()) if geojson_path.exists() else None

        # Also store on the server so the activity appears in the user's feed.
        user_dir = _get_data_dir() / user.handle
        acts_dir = user_dir / "activities"
        acts_dir.mkdir(parents=True, exist_ok=True)
        out = acts_dir / f"{act_id}.json"
        if not out.exists():
            out.write_text(json.dumps(detail, ensure_ascii=False, indent=2), encoding="utf-8")
        if timeseries and not (acts_dir / f"{act_id}.timeseries.json").exists():
            (acts_dir / f"{act_id}.timeseries.json").write_text(json.dumps(timeseries), encoding="utf-8")
        if geojson and not (acts_dir / f"{act_id}.geojson").exists():
            (acts_dir / f"{act_id}.geojson").write_text(json.dumps(geojson), encoding="utf-8")

        _upsert_index_summary(user_dir, act_id, detail, geojson)

        if user_title:
            import yaml as _yaml
            edits_dir = user_dir / "edits"
            edits_dir.mkdir(parents=True, exist_ok=True)
            (edits_dir / f"{act_id}.md").write_text(
                f"---\n{_yaml.dump({'title': user_title}, allow_unicode=True)}---\n",
                encoding="utf-8",
            )

    except Exception as exc:
        log.warning("upload/raw[%s]: extraction failed: %s", user.handle, exc)
        raise HTTPException(422, f"Could not extract activity: {exc}") from exc
    finally:
        tmp_in.unlink(missing_ok=True)
        shutil.rmtree(tmp_out, ignore_errors=True)

    # Merge and update feed — best effort; a race or transient FS error here must
    # not turn a successful extraction into a 422 (the file is on disk; the mobile
    # would retry indefinitely and the activity would never be marked synced).
    try:
        from bincio.render.merge import merge_one, write_combined_feed
        merge_one(user_dir, act_id)
        write_combined_feed(_get_data_dir())
    except Exception as exc:
        log.warning("upload/raw[%s]: merge/feed failed (non-fatal): %s", user.handle, exc)

    log.info("upload/raw[%s]: imported %s", user.handle, act_id)
    return JSONResponse({
        "ok": True,
        "id": act_id,
        "detail": detail,
        "timeseries": timeseries,
        "geojson": geojson,
        "source_hash": source_hash,
    })


@app.get("/api/wheel/version")
async def wheel_version() -> JSONResponse:
    """Public endpoint: current bincio wheel version for mobile app update checks."""
    import importlib.metadata
    try:
        version = importlib.metadata.version("bincio")
    except importlib.metadata.PackageNotFoundError:
        version = "0.1.0"
    return JSONResponse({
        "version": version,
        "url": f"/bincio-{version}-py3-none-any.whl",
        "api_url": f"/api/wheel/download",
    })


@app.get("/api/wheel/download")
async def wheel_download() -> FileResponse:
    """Serve the bincio wheel directly (used locally; in prod nginx serves /bincio-*.whl)."""
    import importlib.metadata
    try:
        version = importlib.metadata.version("bincio")
    except importlib.metadata.PackageNotFoundError:
        version = "0.1.0"
    wheel_name = f"bincio-{version}-py3-none-any.whl"
    # Look in dist/ relative to repo root (two levels up from this file)
    dist_dir = Path(__file__).parent.parent.parent / "dist"
    wheel_path = dist_dir / wheel_name
    if not wheel_path.exists():
        raise HTTPException(status_code=404, detail=f"{wheel_name} not found in dist/")
    return FileResponse(wheel_path, media_type="application/zip", filename=wheel_name)


@app.post("/api/auth/login", response_model=LoginResponse)
async def login(
    login_req: LoginRequest,
    request: Request,
) -> JSONResponse:
    ip = request.client.host if request.client else "unknown"
    _check_rate_limit(ip, _login_attempts, _LOGIN_RATE_LIMIT, "Too many login attempts. Try again later.")

    handle = login_req.handle.strip().lower()
    password = login_req.password

    user = authenticate(_get_db(), handle, password)
    if not user:
        raise HTTPException(401, "Invalid credentials")

    token = create_session(_get_db(), handle)
    resp = JSONResponse({
        "ok": True,
        "handle": user.handle,
        "display_name": user.display_name,
        "wiki_access": user.wiki_access,
        "activity_access": user.activity_access,
    })
    _set_session_cookie(resp, token)
    return resp


@app.post("/api/auth/logout", response_model=GenericResponse)
async def logout(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
    if bincio_session:
        delete_session(_get_db(), bincio_session)
    resp = JSONResponse({"ok": True})
    kwargs: dict = dict(key=_SESSION_COOKIE)
    if _SESSION_DOMAIN:
        kwargs["domain"] = _SESSION_DOMAIN
    resp.delete_cookie(**kwargs)
    return resp


@app.post("/api/auth/token")
async def get_token(login_req: LoginRequest, request: Request) -> JSONResponse:
    """Mobile auth: same as /api/auth/login but returns the token in the body instead of a cookie."""
    ip = request.client.host if request.client else "unknown"
    _check_rate_limit(ip, _login_attempts, _LOGIN_RATE_LIMIT, "Too many login attempts. Try again later.")
    handle = login_req.handle.strip().lower()
    user = authenticate(_get_db(), handle, login_req.password)
    if not user:
        raise HTTPException(401, "Invalid credentials")
    token = create_session(_get_db(), handle)
    return JSONResponse({
        "ok": True,
        "token": token,
        "handle": user.handle,
        "display_name": user.display_name,
    })


@app.get("/api/feed")
async def get_feed(user: User = Depends(_require_auth)) -> JSONResponse:
    """Return the authenticated user's activity summaries (mobile feed sync).

    _merged/index.json is a shard manifest (activities: []) when the user has
    more than FEED_PAGE_SIZE activities.  Collect from all shard files.
    """
    dd = _get_data_dir()
    user_dir = dd / user.handle
    for index_path in (user_dir / "_merged" / "index.json", user_dir / "index.json"):
        if not index_path.exists():
            continue
        index = json.loads(index_path.read_text())
        activities: list[dict] = index.get("activities", [])
        for shard in index.get("shards", []):
            shard_path = index_path.parent / shard["url"]
            if shard_path.exists():
                shard_doc = json.loads(shard_path.read_text())
                activities.extend(shard_doc.get("activities", []))
        return JSONResponse({"activities": activities})
    return JSONResponse({"activities": []})


@app.post("/api/auth/reset-password", response_model=GenericResponse)
async def reset_password(reset_req: ResetPasswordRequest) -> JSONResponse:
    """Validate a reset code and set a new password. Public endpoint."""
    from bincio.serve.db import use_reset_code, change_password
    handle = reset_req.handle.strip().lower()
    code   = reset_req.code.strip().upper()
    new_pw = reset_req.password
    if len(new_pw) < 8:
        raise HTTPException(400, "Password must be at least 8 characters")
    db = _get_db()
    if not use_reset_code(db, code, handle):
        raise HTTPException(400, "Invalid or expired reset code")
    change_password(db, handle, new_pw)
    return JSONResponse({"ok": True})


# ── Registration ──────────────────────────────────────────────────────────────

@app.post("/api/register", response_model=RegisterResponse)
async def register(
    register_req: RegisterRequest,
    request: Request,
) -> JSONResponse:
    ip = request.client.host if request.client else "unknown"
    _check_rate_limit(ip, _register_attempts, _REGISTER_RATE_LIMIT, "Too many registration attempts. Try again later.")

    code     = register_req.code.strip().upper()
    handle   = register_req.handle.strip().lower()
    password = register_req.password
    display  = register_req.display_name.strip() or handle

    if not _VALID_HANDLE.match(handle):
        raise HTTPException(400, "Invalid handle (lowercase letters, numbers, _ - only; max 30 chars)")
    if len(password) < 8:
        raise HTTPException(400, "Password must be at least 8 characters")

    invite = get_invite(_get_db(), code)
    if not invite or invite.used:
        raise HTTPException(400, "Invalid or already-used invite code")
    if get_user(_get_db(), handle):
        raise HTTPException(409, "Handle already taken")

    db = _get_db()
    max_wiki_val = get_setting(db, "max_wiki_users") or get_setting(db, "max_users")
    if max_wiki_val is not None:
        limit = int(max_wiki_val)
        if limit > 0 and count_wiki_users(db) >= limit:
            raise HTTPException(403, f"This instance has reached its wiki user limit ({limit})")

    if invite.grants_activity:
        max_act_val = get_setting(db, "max_activity_users")
        if max_act_val is not None:
            limit = int(max_act_val)
            if limit > 0 and count_activity_users(db) >= limit:
                raise HTTPException(403, f"This instance has reached its activity user limit ({limit})")

    create_user(_get_db(), handle, display, password, is_admin=False,
                wiki_access=True, activity_access=invite.grants_activity)
    use_invite(_get_db(), code, handle)

    # Create per-user directories
    dd = _get_data_dir()
    user_dir = dd / handle
    (user_dir / "activities").mkdir(parents=True, exist_ok=True)
    (user_dir / "edits").mkdir(parents=True, exist_ok=True)

    # Write an empty index.json so the shard URL resolves immediately,
    # even before the user uploads any activities.
    from bincio.extract.writer import write_index
    index_path = user_dir / "index.json"
    if not index_path.exists():
        write_index([], user_dir, {"handle": handle, "display_name": display or handle})

    # Update root manifest so the new user's shard is discoverable immediately
    from bincio.render.cli import _write_root_manifest
    _write_root_manifest(dd)

    # Rebuild site so the new user's profile pages exist immediately
    _trigger_rebuild(handle)

    token = create_session(_get_db(), handle)
    resp = JSONResponse({"ok": True, "handle": handle})
    _set_session_cookie(resp, token)
    return resp


# ── Invites ───────────────────────────────────────────────────────────────────

@app.get("/api/invites")
async def get_invites(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
    user = _require_user(bincio_session)
    invites = list_invites(_get_db(), user.handle)
    return JSONResponse([{
        "code": i.code,
        "used": i.used,
        "used_by": i.used_by,
        "created_at": i.created_at,
        "used_at": i.used_at,
        "grants_activity": i.grants_activity,
    } for i in invites])


class CreateInviteRequest(BaseModel):
    grants_activity: bool = Field(default=False)


@app.post("/api/invites")
async def post_invite(
    body: CreateInviteRequest = CreateInviteRequest(),
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    user = _require_user(bincio_session)
    try:
        code = create_invite(_get_db(), user.handle, grants_activity=body.grants_activity)
    except ValueError as e:
        raise HTTPException(400, str(e))
    return JSONResponse({"ok": True, "code": code, "grants_activity": body.grants_activity})


# ── Admin ─────────────────────────────────────────────────────────────────────

@app.get("/api/admin/users")
async def admin_users(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
    _require_admin(bincio_session)
    users = list_users(_get_db())
    return JSONResponse([{
        "handle": u.handle,
        "display_name": u.display_name,
        "is_admin": u.is_admin,
        "suspended": u.suspended,
        "created_at": u.created_at,
    } for u in users])


@app.get("/api/admin/jobs")
async def admin_jobs(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
    """Return currently active upload/processing jobs. Admin only."""
    _require_admin(bincio_session)
    with _jobs_lock:
        jobs = list(_active_jobs.values())
    return JSONResponse(jobs)


@app.get("/api/admin/disk")
async def admin_disk(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
    """Per-user disk usage breakdown. Admin only."""
    _require_admin(bincio_session)
    import shutil

    data_dir = _get_data_dir()

    def _mb(path: Path) -> float:
        if not path.exists():
            return 0.0
        # Use lstat to count symlink entries (few bytes each) rather than following
        # the link to the target — prevents _merged/ from double-counting activities/.
        total = sum(f.lstat().st_size for f in path.rglob("*") if f.is_file() or f.is_symlink())
        return round(total / 1_048_576, 1)

    def _count(path: Path, pattern: str = "*") -> int:
        if not path.exists():
            return 0
        return sum(1 for f in path.glob(pattern) if f.is_file())

    db = _get_db()
    from bincio.serve.db import get_user as _get_user
    users = []
    for user_dir in sorted(data_dir.iterdir()):
        if not user_dir.is_dir() or user_dir.name.startswith("_"):
            continue
        # leaked tmp zips
        leaked = [f for f in user_dir.glob("tmp*.zip") if f.is_file()]
        db_user = _get_user(db, user_dir.name)
        users.append({
            "handle": user_dir.name,
            "in_db": db_user is not None,
            "suspended": db_user.suspended if db_user else False,
            "total_mb": _mb(user_dir),
            "activities_mb": _mb(user_dir / "activities"),
            "activities_count": _count(user_dir / "activities", "*.json"),
            "merged_mb": _mb(user_dir / "_merged"),
            "originals_mb": _mb(user_dir / "originals"),
            "originals_strava_mb": _mb(user_dir / "originals" / "strava"),
            "images_mb": _mb(user_dir / "edits" / "images"),
            "leaked_zips_mb": round(sum(f.stat().st_size for f in leaked) / 1_048_576, 1),
            "leaked_zips_count": len(leaked),
        })

    disk = shutil.disk_usage("/")
    return JSONResponse({
        "disk": {
            "total_gb": round(disk.total / 1_073_741_824, 1),
            "used_gb": round(disk.used / 1_073_741_824, 1),
            "free_gb": round(disk.free / 1_073_741_824, 1),
            "percent": round(disk.used / disk.total * 100, 1),
        },
        "users": users,
    })


@app.post("/api/admin/users/{handle}/reset-password-code", response_model=ResetPasswordCodeResponse)
async def admin_reset_password_code(
    handle: str,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    """Generate a one-time password reset code for a user. Admin only."""
    from bincio.serve.db import create_reset_code
    admin = _require_admin(bincio_session)
    db = _get_db()
    if not get_user(db, handle):
        raise HTTPException(404, f"User '{handle}' not found")
    code = create_reset_code(db, handle, admin.handle)
    return JSONResponse({"ok": True, "code": code, "expires_in_hours": 24})


@app.post("/api/admin/users/{handle}/suspend")
async def admin_suspend(
    handle: str,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    """Suspend a user account. Blocks login and invalidates existing sessions. Admin only."""
    from bincio.serve.db import set_suspended, purge_expired_sessions
    admin = _require_admin(bincio_session)
    if handle == admin.handle:
        raise HTTPException(400, "Cannot suspend yourself")
    db = _get_db()
    if not get_user(db, handle):
        raise HTTPException(404, "User not found")
    set_suspended(db, handle, True)
    db.execute("DELETE FROM sessions WHERE handle = ?", (handle,))
    db.commit()
    return JSONResponse({"status": "suspended", "handle": handle})


@app.post("/api/admin/users/{handle}/unsuspend")
async def admin_unsuspend(
    handle: str,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    """Re-enable a suspended user account. Admin only."""
    from bincio.serve.db import set_suspended
    _require_admin(bincio_session)
    db = _get_db()
    if not get_user(db, handle):
        raise HTTPException(404, "User not found")
    set_suspended(db, handle, False)
    return JSONResponse({"status": "unsuspended", "handle": handle})


@app.delete("/api/admin/users/{handle}/account")
async def admin_delete_account(
    handle: str,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    """Delete a user account from the database. Data directory is NOT removed. Admin only."""
    from bincio.serve.db import delete_user as _delete_user
    admin = _require_admin(bincio_session)
    if handle == admin.handle:
        raise HTTPException(400, "Cannot delete your own account")
    db = _get_db()
    if not get_user(db, handle):
        raise HTTPException(404, "User not found")
    _delete_user(db, handle)
    return JSONResponse({"status": "deleted", "handle": handle})


@app.post("/api/admin/users/{handle}/rebuild")
async def admin_rebuild(
    handle: str,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    """Trigger a merge_all + site rebuild for a user. Admin only."""
    _require_admin(bincio_session)
    user_dir = _get_data_dir() / handle
    if not user_dir.is_dir():
        raise HTTPException(404, f"No data directory for user '{handle}'")
    _trigger_rebuild(handle)
    return JSONResponse({"ok": True})


@app.post("/api/admin/users/{handle}/rebuild-sync")
async def admin_rebuild_sync(
    handle: str,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    """Run merge+rebuild synchronously and return full output. Admin only.

    Unlike /rebuild (fire-and-forget), this blocks until done and returns stdout/stderr.
    Use for debugging when you need to see what went wrong.
    """
    _require_admin(bincio_session)
    user_dir = _get_data_dir() / handle
    if not user_dir.is_dir():
        raise HTTPException(404, f"No data directory for user '{handle}'")
    if site_dir is None:
        raise HTTPException(503, "Server has no --site-dir configured; rebuild not available")

    uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv")
    cmd = [uv, "run", "bincio", "render",
           "--data-dir", str(data_dir),
           "--site-dir", str(site_dir),
           "--handle", handle,
           "--no-build"]
    if webroot:
        cmd = [uv, "run", "bincio", "render",
               "--data-dir", str(data_dir),
               "--site-dir", str(site_dir),
               "--handle", handle]

    result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
    resp: dict[str, Any] = {
        "ok": result.returncode == 0,
        "returncode": result.returncode,
        "stdout": result.stdout,
        "stderr": result.stderr,
    }

    if result.returncode == 0 and webroot:
        dist_data = site_dir / "dist" / "data"
        if dist_data.exists():
            shutil.rmtree(dist_data)
        rsync = subprocess.run(
            ["rsync", "-a", "--delete", "--exclude=data/",
             f"{site_dir}/dist/", str(webroot) + "/"],
            capture_output=True, text=True, timeout=120,
        )
        resp["rsync_returncode"] = rsync.returncode
        resp["rsync_stdout"] = rsync.stdout
        resp["rsync_stderr"] = rsync.stderr
        resp["ok"] = rsync.returncode == 0

    return JSONResponse(resp)


@app.post("/api/admin/users/{handle}/reextract-originals")
async def admin_reextract_originals(
    handle: str,
    bincio_session: Optional[str] = Cookie(default=None),
) -> StreamingResponse:
    """Re-extract activities from stored Strava originals without hitting the API.

    Spawns `bincio reextract-originals` as a subprocess so heavy memory use
    is isolated from the server process. Streams its JSON-lines output as SSE.
    Triggers a full rebuild on completion.
    """
    import asyncio
    _require_admin(bincio_session)
    user_dir = _get_data_dir() / handle
    originals_dir = user_dir / "originals" / "strava"
    if not originals_dir.exists():
        raise HTTPException(404, f"No Strava originals directory for '{handle}'")

    # Use the bincio script from the same venv bin dir as the running Python.
    # This is reliable in systemd environments where PATH may not include uv.
    import sys as _sys
    bincio_exe = str(Path(_sys.executable).parent / "bincio")
    data_dir = str(_get_data_dir())

    # Count originals so we can split into memory-safe batches.
    total_originals = len(list(originals_dir.glob("*.json")))
    # Each activity can briefly peak at ~10–30 MB; 100 per batch keeps RSS
    # well under 3 GB even on a cheap VPS.
    _BATCH = 100
    log.info("reextract[%s]: %d originals, batch size %d, via %s",
             handle, total_originals, _BATCH, bincio_exe)

    async def event_stream():
        total_imported = total_skipped = total_errors = 0
        offset = 0

        while offset < total_originals:
            limit = min(_BATCH, total_originals - offset)
            proc = await asyncio.create_subprocess_exec(
                bincio_exe, "reextract-originals",
                "--data-dir", data_dir,
                "--handle", handle,
                "--offset", str(offset),
                "--limit", str(limit),
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )
            assert proc.stdout is not None

            async for raw_line in proc.stdout:
                line = raw_line.decode(errors="replace").strip()
                if not line:
                    continue
                yield f"data: {line}\n\n"
                try:
                    evt = json.loads(line)
                    if evt.get("type") == "done":
                        total_imported += evt.get("imported", 0)
                        total_skipped += evt.get("skipped", 0)
                        total_errors += evt.get("errors", 0)
                except Exception:
                    pass

            await proc.wait()
            if proc.returncode != 0:
                stderr_out = await proc.stderr.read() if proc.stderr else b""
                log.error("reextract[%s]: batch offset=%d exited %d — stderr: %s",
                          handle, offset, proc.returncode,
                          stderr_out.decode(errors="replace")[:500])
                yield f"data: {json.dumps({'type': 'error', 'message': f'Batch {offset}–{offset+limit} exited with code {proc.returncode}'})}\n\n"
                return  # stop on batch failure

            offset += limit

        # All batches complete
        log.info("reextract[%s]: all batches done — imported=%d skipped=%d errors=%d; triggering rebuild",
                 handle, total_imported, total_skipped, total_errors)
        _trigger_rebuild(handle)
        yield f"data: {json.dumps({'type': 'done', 'imported': total_imported, 'skipped': total_skipped, 'errors': total_errors})}\n\n"

    return StreamingResponse(
        event_stream(),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )


@app.get("/api/admin/users/{handle}/diag")
async def admin_diag(
    handle: str,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    """Return a diagnostic snapshot of a user's data directory. Admin only."""
    _require_admin(bincio_session)
    user_dir = _get_data_dir() / handle
    if not user_dir.is_dir():
        raise HTTPException(404, f"No data directory for user '{handle}'")

    def _count(path: Path, glob: str = "*") -> int:
        return sum(1 for f in path.glob(glob) if f.is_file()) if path.exists() else 0

    def _size_mb(path: Path) -> float:
        if not path.exists():
            return 0.0
        return sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) / 1_048_576

    activities_dir = user_dir / "activities"
    merged_dir = user_dir / "_merged"
    originals_dir = user_dir / "originals"
    uploads_dir = user_dir / "_uploads"

    merged_index = merged_dir / "index.json"
    root_index = user_dir / "index.json"

    merged_activity_count: int | None = None
    if merged_index.exists():
        try:
            idx = json.loads(merged_index.read_text())
            merged_activity_count = len(idx.get("activities", []))
        except Exception:
            merged_activity_count = -1

    root_activity_count: int | None = None
    if root_index.exists():
        try:
            idx = json.loads(root_index.read_text())
            root_activity_count = len(idx.get("activities", []))
        except Exception:
            root_activity_count = -1

    # Peek at a few filenames in activities/ to understand the actual state
    acts_sample: list[str] = []
    acts_symlinks = 0
    if activities_dir.exists():
        for f in sorted(activities_dir.iterdir())[:10]:
            acts_sample.append(f.name + (" → symlink" if f.is_symlink() else ""))
            if f.is_symlink():
                acts_symlinks += 1

    # Check _merged/activities/ separately
    merged_acts_dir = merged_dir / "activities"
    merged_acts_json = _count(merged_acts_dir, "*.json")
    merged_acts_geojson = _count(merged_acts_dir, "*.geojson")

    # List pending files
    pending_files: list[str] = []
    if uploads_dir.exists():
        pending_files = [f.name for f in uploads_dir.iterdir() if f.is_file()]

    return JSONResponse({
        "handle": handle,
        "user_dir": str(user_dir),
        "activities": {
            "json_files": _count(activities_dir, "*.json"),
            "geojson_files": _count(activities_dir, "*.geojson"),
            "size_mb": round(_size_mb(activities_dir), 2),
            "sample": acts_sample,
            "symlink_count": acts_symlinks,
        },
        "originals": {
            "exists": originals_dir.exists(),
            "size_mb": round(_size_mb(originals_dir), 2),
            "strava_originals": _count(originals_dir / "strava", "*.json") if (originals_dir / "strava").exists() else 0,
        },
        "merged": {
            "exists": merged_dir.exists(),
            "activity_count_in_index": merged_activity_count,
            "size_mb": round(_size_mb(merged_dir), 2),
            "activities_json": merged_acts_json,
            "activities_geojson": merged_acts_geojson,
        },
        "root_index": {
            "exists": root_index.exists(),
            "activity_count": root_activity_count,
        },
        "pending_uploads": len(pending_files),
        "pending_files": pending_files,
        "dedup_cache_exists": (user_dir / ".bincio_cache.json").exists(),
        "athlete_json_exists": (user_dir / "athlete.json").exists(),
    })


def _wipe_user_activities(user_dir: Path) -> int:
    """Delete all extracted activity files and caches for a user.

    Removes activities/ (JSON + GeoJSON + timeseries), edits/, originals/,
    _merged/, index.json, athlete.json, and the dedup cache.
    Leaves the user directory itself intact (account remains in the DB).
    Returns the number of files deleted.
    """
    import shutil
    deleted = 0

    for subdir in ("activities", "edits", "originals"):
        d = user_dir / subdir
        if d.exists():
            for f in d.rglob("*"):
                if f.is_file():
                    deleted += 1
            shutil.rmtree(d)

    for name in ("_merged", ):
        d = user_dir / name
        if d.exists():
            shutil.rmtree(d)

    for name in ("index.json", "athlete.json", ".bincio_cache.json"):
        f = user_dir / name
        if f.exists():
            f.unlink()
            deleted += 1

    return deleted


@app.delete("/api/admin/users/{handle}/activities")
async def admin_delete_activities(
    handle: str,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    """Delete all activity data for a user and wipe the merged cache."""
    _require_admin(bincio_session)
    user_dir = _get_data_dir() / handle
    if not user_dir.is_dir():
        raise HTTPException(404, f"No data directory for user '{handle}'")

    deleted = _wipe_user_activities(user_dir)
    _trigger_rebuild(handle)
    return JSONResponse({"ok": True, "deleted": deleted})


@app.delete("/api/admin/users/{handle}/directory")
async def admin_delete_user_directory(
    handle: str,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    """Delete the entire user directory from disk (for ghost users not in the DB).

    Refuses if the handle exists as an account in the database — use
    DELETE /api/admin/users/{handle}/activities for registered users.
    """
    import shutil
    _require_admin(bincio_session)
    db = _get_db()
    from bincio.serve.db import get_user as _get_user
    if _get_user(db, handle) is not None:
        raise HTTPException(
            400,
            f"User '{handle}' is still in the database. Remove the account first, "
            "or use 'Reset data' to wipe only activity files.",
        )
    user_dir = _get_data_dir() / handle
    if not user_dir.is_dir():
        raise HTTPException(404, f"No directory for '{handle}'")
    shutil.rmtree(user_dir)
    # Rebuild root manifest so the ghost shard disappears from the site
    from bincio.render.cli import _write_root_manifest
    try:
        _write_root_manifest(_get_data_dir())
    except Exception:
        pass
    return JSONResponse({"ok": True})


@app.get("/api/admin/strava-sync")
async def admin_strava_sync_status(
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    """Return per-user Strava sync status for the admin panel."""
    _require_admin(bincio_session)
    root = _get_data_dir()
    users = []
    for tf in sorted(root.glob("*/strava_token.json")):
        user_dir = tf.parent
        handle = user_dir.name
        has_creds = (user_dir / "strava_credentials.json").exists()

        last_sync: str | None = None
        total_imported = 0
        sync_path = user_dir / "_strava_sync.json"
        if sync_path.exists():
            try:
                sc = json.loads(sync_path.read_text(encoding="utf-8"))
                last_sync = sc.get("last_sync")
                total_imported = len(sc.get("imported_ids", []))
            except Exception:
                pass

        run_status: str | None = None
        run_imported = 0
        run_errors = 0
        run_error_message: str | None = None
        last_run: str | None = None
        status_path = user_dir / "_strava_sync_status.json"
        if status_path.exists():
            try:
                ss = json.loads(status_path.read_text(encoding="utf-8"))
                run_status = ss.get("status")
                run_imported = ss.get("imported", 0)
                run_errors = ss.get("errors", 0)
                run_error_message = ss.get("error_message")
                last_run = ss.get("last_run")
            except Exception:
                pass

        users.append({
            "handle": handle,
            "has_credentials": has_creds,
            "last_sync": last_sync,
            "total_imported": total_imported,
            "run_status": run_status,
            "run_imported": run_imported,
            "run_errors": run_errors,
            "run_error_message": run_error_message,
            "last_run": last_run,
        })

    return JSONResponse({"running": _strava_sync_running, "users": users})


@app.post("/api/admin/strava-sync/run")
async def admin_strava_sync_run(
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    """Trigger an immediate Strava sync for all users (admin only)."""
    global _strava_sync_running
    _require_admin(bincio_session)
    with _strava_sync_lock:
        if _strava_sync_running:
            raise HTTPException(409, "Sync already running")
        _strava_sync_running = True

    def _run() -> None:
        global _strava_sync_running
        try:
            from bincio.sync_strava import sync_all
            results = sync_all(_get_data_dir())
            total_new = sum(n for n, _ in results.values())
            if total_new > 0:
                _site_rebuild_event.set()
        except Exception:
            log.exception("admin_strava_sync_run: unexpected error")
        finally:
            _strava_sync_running = False

    threading.Thread(target=_run, daemon=True, name="admin-strava-sync").start()
    return JSONResponse({"ok": True}, status_code=202)



# ── Self-service user settings ────────────────────────────────────────────────

@app.get("/api/me/storage")
async def me_storage(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
    """Return per-category disk usage for the logged-in user."""
    user = _require_user(bincio_session)
    dd = _get_data_dir() / user.handle

    def _mb(path: Path) -> float:
        if not path.exists():
            return 0.0
        total = sum(f.lstat().st_size for f in path.rglob("*") if f.is_file() or f.is_symlink())
        return round(total / 1_048_576, 2)

    def _count(path: Path, pattern: str = "*") -> int:
        if not path.exists():
            return 0
        return sum(1 for f in path.glob(pattern) if f.is_file())

    activities_mb   = _mb(dd / "activities")
    originals_mb    = _mb(dd / "originals")
    strava_mb       = _mb(dd / "originals" / "strava")
    images_mb       = _mb(dd / "edits" / "images")
    total_mb        = _mb(dd)

    return JSONResponse({
        "total_mb":          total_mb,
        "activities_mb":     activities_mb,
        "activities_count":  _count(dd / "activities", "*.json"),
        "originals_mb":      originals_mb,
        "strava_originals_mb": strava_mb,
        "strava_originals_count": _count(dd / "originals" / "strava", "*.json"),
        "images_mb":         images_mb,
    })


@app.delete("/api/me/originals")
async def me_delete_originals(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
    """Delete the user's originals/ directory (frees space after re-extraction)."""
    user = _require_user(bincio_session)
    originals = _get_data_dir() / user.handle / "originals"
    if not originals.exists():
        return JSONResponse({"ok": True, "freed_mb": 0.0})

    freed = round(
        sum(f.stat().st_size for f in originals.rglob("*") if f.is_file()) / 1_048_576, 2
    )
    shutil.rmtree(originals)
    return JSONResponse({"ok": True, "freed_mb": freed})


@app.delete("/api/me/activities")
async def me_delete_activities(
    request: Request,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    """Wipe all extracted activity data (activities/, edits/, _merged/, index/athlete JSON).

    Requires the user's current password in the request body for confirmation.
    """
    user = _require_user(bincio_session)
    body = await request.json()
    password = body.get("password", "")
    if not authenticate(_get_db(), user.handle, password):
        raise HTTPException(401, "Wrong password")

    user_dir = _get_data_dir() / user.handle
    deleted = _wipe_user_activities(user_dir)
    _trigger_rebuild(user.handle)
    return JSONResponse({"ok": True, "deleted": deleted})


@app.delete("/api/me")
async def me_delete_account(
    request: Request,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    """Delete the account and all data permanently.

    Requires the user's current password. Deletes the DB row, all sessions,
    and the entire user data directory. The root shard manifest is updated.
    """
    user = _require_user(bincio_session)
    body = await request.json()
    password = body.get("password", "")
    if not authenticate(_get_db(), user.handle, password):
        raise HTTPException(401, "Wrong password")

    # Wipe data directory
    user_dir = _get_data_dir() / user.handle
    if user_dir.is_dir():
        shutil.rmtree(user_dir)

    # Remove from DB (cascades to sessions, invites, reset_codes)
    from bincio.serve.db import delete_user as _delete_user
    _delete_user(_get_db(), user.handle)

    # Update root manifest so the shard disappears
    from bincio.render.cli import _write_root_manifest
    try:
        _write_root_manifest(_get_data_dir())
    except Exception:
        pass

    resp = JSONResponse({"ok": True})
    resp.delete_cookie(_SESSION_COOKIE)
    return resp


@app.put("/api/me/display-name")
async def me_update_display_name(
    request: Request,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    """Update the logged-in user's display name."""
    user = _require_user(bincio_session)
    body = await request.json()
    display_name = str(body.get("display_name", "")).strip()
    if len(display_name) > 60:
        raise HTTPException(400, "Display name too long (max 60 characters)")
    db = _get_db()
    db.execute("UPDATE users SET display_name = ? WHERE handle = ?", (display_name, user.handle))
    db.commit()
    return JSONResponse({"ok": True, "display_name": display_name})


@app.get("/api/me/prefs")
async def me_get_prefs(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
    """Return all user preferences as a key→value dict."""
    user = _require_user(bincio_session)
    return JSONResponse(get_user_prefs(_get_db(), user.handle))


@app.put("/api/me/prefs")
async def me_set_prefs(
    request: Request,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    """Upsert one or more user preferences. Body: {key: value, ...} (all strings)."""
    user = _require_user(bincio_session)
    body = await request.json()
    if not isinstance(body, dict):
        raise HTTPException(400, "Body must be a JSON object")
    # Coerce all values to strings; ignore unknown keys silently
    prefs = {str(k): str(v) for k, v in body.items()}
    set_user_prefs(_get_db(), user.handle, prefs)
    return JSONResponse({"ok": True})


@app.get("/api/me/strava-credentials")
async def me_get_strava_credentials(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
    """Return whether per-user Strava credentials are configured (never returns the secret)."""
    user = _require_user(bincio_session)
    creds_path = _get_data_dir() / user.handle / _STRAVA_CREDS_FILE
    has_user_creds = False
    client_id_hint = ""
    if creds_path.exists():
        try:
            d = json.loads(creds_path.read_text(encoding="utf-8"))
            cid = str(d.get("client_id", "")).strip()
            csec = str(d.get("client_secret", "")).strip()
            if cid and csec:
                has_user_creds = True
                client_id_hint = cid
        except Exception:
            pass
    return JSONResponse({
        "has_user_creds": has_user_creds,
        "client_id": client_id_hint,
        "instance_configured": bool(strava_client_id),
    })


@app.put("/api/me/strava-credentials")
async def me_set_strava_credentials(
    request: Request,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    """Save per-user Strava credentials. Body: {client_id, client_secret}."""
    user = _require_user(bincio_session)
    body = await request.json()
    cid  = str(body.get("client_id",     "")).strip()
    csec = str(body.get("client_secret", "")).strip()
    if not cid:
        raise HTTPException(400, "client_id is required")
    creds_path = _get_data_dir() / user.handle / _STRAVA_CREDS_FILE
    # If client_secret is omitted, preserve existing secret (if any)
    if not csec:
        if creds_path.exists():
            try:
                existing = json.loads(creds_path.read_text(encoding="utf-8"))
                csec = str(existing.get("client_secret", "")).strip()
            except Exception:
                pass
    if not csec:
        raise HTTPException(400, "client_secret is required (no existing secret to preserve)")

    # If the client_id changed, the existing token belongs to a different OAuth
    # app and will fail on refresh — delete it so the user must re-authenticate.
    token_path = _get_data_dir() / user.handle / "strava_token.json"
    if creds_path.exists() and token_path.exists():
        try:
            old_cid = str(json.loads(creds_path.read_text(encoding="utf-8")).get("client_id", "")).strip()
            if old_cid and old_cid != cid:
                token_path.unlink(missing_ok=True)
        except Exception:
            pass

    creds_path.write_text(
        json.dumps({"client_id": cid, "client_secret": csec}, indent=2),
        encoding="utf-8",
    )
    return JSONResponse({"ok": True})


@app.delete("/api/me/strava-credentials")
async def me_delete_strava_credentials(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
    """Remove per-user Strava credentials (falls back to instance credentials)."""
    user = _require_user(bincio_session)
    creds_path = _get_data_dir() / user.handle / _STRAVA_CREDS_FILE
    creds_path.unlink(missing_ok=True)
    return JSONResponse({"ok": True})


@app.put("/api/me/password")
async def me_change_password(
    request: Request,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    """Change the logged-in user's password. Requires current password."""
    from bincio.serve.db import change_password as _change_password
    user = _require_user(bincio_session)
    body = await request.json()
    current  = body.get("current_password", "")
    new_pw   = body.get("new_password", "")
    if not authenticate(_get_db(), user.handle, current):
        raise HTTPException(401, "Current password is wrong")
    if len(new_pw) < 8:
        raise HTTPException(400, "New password must be at least 8 characters")
    _change_password(_get_db(), user.handle, new_pw)
    return JSONResponse({"ok": True})


# ── Write API (ported from bincio edit, auth-gated) ───────────────────────────

def _user_data_dir(handle: str) -> Path:
    """Return the merged data dir for a user, for reading activity files."""
    dd = _get_data_dir()
    merged = dd / handle / "_merged"
    return merged if merged.exists() else dd / handle


def _require_owns(activity_id: str, user: User) -> Path:
    """Verify the user owns this activity (it lives in their data dir)."""
    activity_path = _user_data_dir(user.handle) / "activities" / f"{activity_id}.json"
    if not activity_path.exists():
        raise HTTPException(404, "Activity not found")
    return activity_path


@app.get("/api/activity/{activity_id}")
async def get_activity(
    activity_id: str,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    user = _require_user(bincio_session)
    _check_id(activity_id)
    path = _require_owns(activity_id, user)
    detail = json.loads(path.read_text())
    # Normalise for EditDrawer: add `private` bool so the drawer works regardless
    # of whether the raw JSON uses the old "private" or the new "unlisted" value.
    detail["private"] = detail.get("privacy") in ("private", "unlisted")
    return JSONResponse(detail)


@app.post("/api/activity/{activity_id}", response_model=ActivityEditResponse)
async def post_activity(
    activity_id: str,
    edit_req: ActivityEditRequest,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    user = _require_user(bincio_session)
    _check_id(activity_id)
    dd = _get_data_dir() / user.handle
    # Verify the activity belongs to this user before writing
    if not (dd / "activities" / f"{activity_id}.json").exists():
        raise HTTPException(404, "Activity not found")

    from bincio.edit.ops import apply_sidecar_edit
    body = edit_req.model_dump(exclude_none=True)
    # apply_sidecar_edit already calls merge_one internally — no full rebuild needed.
    apply_sidecar_edit(activity_id, body, dd)
    return JSONResponse({"ok": True})


@app.post("/api/activity/{activity_id}/recalculate-elevation/dem")
async def recalculate_elevation_dem_endpoint(
    activity_id: str,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    """Replace GPS altitude with DEM terrain elevation and recompute gain/loss.

    Requires --dem-url to be set when starting bincio serve.
    """
    user = _require_user(bincio_session)
    _check_id(activity_id)
    if not dem_url:
        raise HTTPException(503, "DEM URL not configured.")
    dd = _get_data_dir() / user.handle
    if not (dd / "activities" / f"{activity_id}.json").exists():
        raise HTTPException(404, "Activity not found")
    try:
        from bincio.extract.dem import recalculate_elevation
        from bincio.render.merge import merge_one
        result = recalculate_elevation(dd, activity_id, dem_url)
        merge_one(dd, activity_id)
        _trigger_rebuild(user.handle)
        return JSONResponse(result)
    except FileNotFoundError as e:
        raise HTTPException(404, str(e))
    except ValueError as e:
        raise HTTPException(422, str(e))


@app.post("/api/activity/{activity_id}/recalculate-elevation/hysteresis")
async def recalculate_elevation_hysteresis_endpoint(
    activity_id: str,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    """Recompute gain/loss from original recorded elevation using source-aware hysteresis."""
    user = _require_user(bincio_session)
    _check_id(activity_id)
    dd = _get_data_dir() / user.handle
    if not (dd / "activities" / f"{activity_id}.json").exists():
        raise HTTPException(404, "Activity not found")
    try:
        from bincio.extract.dem import recalculate_elevation_hysteresis
        from bincio.render.merge import merge_one
        result = recalculate_elevation_hysteresis(dd, activity_id)
        merge_one(dd, activity_id)
        _trigger_rebuild(user.handle)
        return JSONResponse(result)
    except FileNotFoundError as e:
        raise HTTPException(404, str(e))
    except ValueError as e:
        raise HTTPException(422, str(e))


@app.post("/api/admin/users/{handle}/recompute-elevation")
async def admin_recompute_elevation(
    handle: str,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    """Recompute elevation gain/loss for all activities of a user from stored timeseries.

    Skips activities with altitude_source == 'dem' (already DEM-corrected).
    Applies the leading-zero no-fix fix and source-aware hysteresis.
    Returns patched/skipped/error counts.
    """
    _require_admin(bincio_session)
    user_dir = _get_data_dir() / handle
    if not user_dir.is_dir():
        raise HTTPException(404, f"No data directory for '{handle}'")

    from bincio.extract.dem import recalculate_elevation_hysteresis
    from bincio.render.merge import merge_one

    patched = skipped = errors = 0
    acts_dir = user_dir / "activities"
    for json_path in sorted(acts_dir.glob("*.json")):
        if json_path.name.endswith(".timeseries.json"):
            continue
        activity_id = json_path.stem
        try:
            detail = json.loads(json_path.read_text(encoding="utf-8"))
            if detail.get("altitude_source") == "dem":
                skipped += 1
                continue
            ts_path = acts_dir / f"{activity_id}.timeseries.json"
            if not ts_path.exists():
                skipped += 1
                continue
            ts = json.loads(ts_path.read_text(encoding="utf-8"))
            ele_arr = ts.get("elevation_m") or []
            if not any(e for e in ele_arr if e is not None):
                skipped += 1
                continue
            recalculate_elevation_hysteresis(user_dir, activity_id)
            merge_one(user_dir, activity_id)
            patched += 1
        except Exception as exc:
            log.warning("recompute-elevation[%s/%s]: %s", handle, activity_id, exc)
            errors += 1

    if patched > 0:
        _trigger_rebuild(handle)

    return JSONResponse({"ok": True, "patched": patched, "skipped": skipped, "errors": errors})


@app.delete("/api/activity/{activity_id}", response_model=GenericResponse)
async def delete_activity(
    activity_id: str,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    """Delete a single activity and all associated files for the logged-in user."""
    user = _require_user(bincio_session)
    _check_id(activity_id)
    dd = _get_data_dir() / user.handle
    acts_dir = dd / "activities"

    json_path = acts_dir / f"{activity_id}.json"
    if not json_path.exists():
        raise HTTPException(404, "Activity not found")

    import shutil

    # Remove the source files (activities dir)
    for suffix in (".json", ".geojson", ".timeseries.json"):
        p = acts_dir / f"{activity_id}{suffix}"
        p.unlink(missing_ok=True)

    # Remove sidecar edit and images
    sidecar = dd / "edits" / f"{activity_id}.md"
    sidecar.unlink(missing_ok=True)
    images_dir = dd / "edits" / "images" / activity_id
    if images_dir.exists():
        shutil.rmtree(images_dir)

    # Remove from the extract-level flat index so merge_all doesn't re-add
    # the summary even though the detail file is gone.
    index_path = dd / "index.json"
    if index_path.exists():
        try:
            idx = json.loads(index_path.read_text(encoding="utf-8"))
            idx["activities"] = [a for a in idx.get("activities", []) if a.get("id") != activity_id]
            index_path.write_text(json.dumps(idx, indent=2, ensure_ascii=False))
        except Exception:
            pass  # corrupt index — merge_all will clean up on next run

    # Remove from dedup cache so the file can be re-uploaded if needed
    cache_path = dd / ".bincio_cache.json"
    if cache_path.exists():
        try:
            cache = json.loads(cache_path.read_text(encoding="utf-8"))
            if isinstance(cache, dict) and "activities" in cache:
                cache["activities"] = [
                    a for a in cache["activities"] if a.get("id") != activity_id
                ]
                cache_path.write_text(json.dumps(cache, indent=2, ensure_ascii=False))
        except Exception:
            pass  # corrupt cache — leave it; next extract will rebuild

    # Full merge needed: activity removed from index
    from bincio.render.merge import merge_all
    merge_all(dd)
    _trigger_rebuild(user.handle)

    return JSONResponse({"ok": True})


@app.get("/api/activity/{activity_id}/images")
async def list_images(
    activity_id: str,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    user = _require_user(bincio_session)
    _check_id(activity_id)
    dd = _get_data_dir() / user.handle
    images_dir = dd / "edits" / "images" / activity_id
    images = sorted(p.name for p in images_dir.iterdir() if p.is_file()) if images_dir.exists() else []
    return JSONResponse({"images": images})


@app.post("/api/activity/{activity_id}/images")
async def upload_image(
    activity_id: str,
    file: UploadFile = File(...),
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    user = _require_user(bincio_session)
    _check_id(activity_id)
    dd = _get_data_dir() / user.handle
    if not (dd / "activities" / f"{activity_id}.json").exists():
        raise HTTPException(404, "Activity not found")
    if not file.filename:
        raise HTTPException(400, "No filename")
    ct = file.content_type or ""
    if ct not in _ALLOWED_IMAGE_TYPES:
        raise HTTPException(400, "Only JPEG, PNG, WebP, or GIF images are accepted")
    contents = await file.read()
    if len(contents) > _MAX_IMAGE_BYTES:
        raise HTTPException(413, f"Image too large (max {_MAX_IMAGE_BYTES // (1024*1024)} MB)")
    images_dir = dd / "edits" / "images" / activity_id
    images_dir.mkdir(parents=True, exist_ok=True)
    safe_name = _unique_image_name(images_dir, Path(file.filename).name)
    (images_dir / safe_name).write_bytes(contents)
    from bincio.render.merge import merge_one
    merge_one(dd, activity_id)
    return JSONResponse({"ok": True, "filename": safe_name})


@app.delete("/api/activity/{activity_id}/images/{filename}")
async def delete_image(
    activity_id: str,
    filename: str,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    user = _require_user(bincio_session)
    _check_id(activity_id)
    dd = _get_data_dir() / user.handle
    import shutil
    safe_name = Path(filename).name
    target = dd / "edits" / "images" / activity_id / safe_name
    if target.exists() and target.is_file():
        target.unlink()
        if target.parent.exists() and not any(target.parent.iterdir()):
            shutil.rmtree(target.parent)
    from bincio.render.merge import merge_one
    merge_one(dd, activity_id)
    return JSONResponse({"ok": True})


@app.get("/api/athlete")
async def get_athlete(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
    user = _require_user(bincio_session)
    dd = _get_data_dir() / user.handle
    athlete_path = dd / "athlete.json"
    data: dict = {}
    if athlete_path.exists():
        data = json.loads(athlete_path.read_text(encoding="utf-8"))
    # Layer edits/athlete.yaml on top
    edits_path = dd / "edits" / "athlete.yaml"
    if edits_path.exists():
        try:
            import yaml
            edits = yaml.safe_load(edits_path.read_text(encoding="utf-8")) or {}
            for k in ("max_hr", "ftp_w", "hr_zones", "power_zones", "seasons", "gear"):
                if k in edits:
                    data[k] = edits[k]
        except Exception:
            pass
    return JSONResponse(data)


@app.post("/api/athlete")
async def save_athlete(
    request: Request,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    user = _require_user(bincio_session)
    dd = _get_data_dir() / user.handle
    athlete_path = dd / "athlete.json"
    if not athlete_path.exists():
        from datetime import datetime, timezone
        athlete_path.write_text(json.dumps({
            "bas_version": "1.0",
            "generated_at": datetime.now(timezone.utc).isoformat(),
            "power_curve": {},
        }), encoding="utf-8")
    payload = await request.json()
    edits_dir = dd / "edits"
    edits_dir.mkdir(exist_ok=True)
    overrides: dict[str, Any] = {}
    if payload.get("max_hr") is not None:
        overrides["max_hr"] = int(payload["max_hr"])
    if payload.get("ftp_w") is not None:
        overrides["ftp_w"] = int(payload["ftp_w"])
    if payload.get("hr_zones") is not None:
        overrides["hr_zones"] = [[int(lo), int(hi)] for lo, hi in payload["hr_zones"]]
    if payload.get("power_zones") is not None:
        overrides["power_zones"] = [[int(lo), int(hi)] for lo, hi in payload["power_zones"]]
    if payload.get("seasons") is not None:
        overrides["seasons"] = [
            {"name": str(s["name"]), "start": str(s["start"]), "end": str(s["end"])}
            for s in payload["seasons"]
        ]
    if payload.get("gear") is not None:
        overrides["gear"] = payload["gear"]
    import yaml
    (edits_dir / "athlete.yaml").write_text(
        yaml.dump(overrides, allow_unicode=True, default_flow_style=False),
        encoding="utf-8",
    )
    from bincio.render.merge import merge_all
    merge_all(dd)
    _trigger_rebuild(user.handle)
    return JSONResponse({"ok": True})


_SUPPORTED_SUFFIXES = {".fit", ".gpx", ".tcx", ".fit.gz", ".gpx.gz", ".tcx.gz"}


def _file_suffix(name: str) -> str:
    """Return the effective suffix, including .gz double-extension."""
    p = Path(name.lower())
    if p.suffix == ".gz":
        return p.stem.rsplit(".", 1)[-1].join([".", ".gz"]) if "." in p.stem else ".gz"
    return p.suffix


@app.post("/api/upload")
async def upload_activity(
    files: list[UploadFile] = File(...),
    store_original: bool = Form(False),
    overwrite: bool = Form(False),
    bincio_session: Optional[str] = Cookie(default=None),
) -> StreamingResponse:
    """Accept FIT/GPX/TCX files and/or activities.csv; stream SSE progress while processing.

    activities.csv (Strava export format) can be included in the batch to:
      - Enrich activity files in the same batch (matched by filename)
      - Retroactively update sidecars for existing activities (matched by strava_id)

    SSE events:
      {"type": "progress", "n": N, "total": T, "name": "...", "status": "imported"|"overwritten"|"duplicate"|"error"}
      {"type": "csv", "updates": N}   -- only when CSV was included
      {"type": "done", "added": N, "csv_updates": N, "duplicates": N, "overwritten": N, "errors": N}
    """
    from bincio.extract.ingest import ingest_parsed
    from bincio.extract.parsers.factory import parse_file
    from bincio.extract.writer import make_activity_id
    from bincio.render.merge import merge_all

    user = _require_user(bincio_session)
    dd = _get_data_dir() / user.handle
    staging = dd / "_uploads"
    staging.mkdir(exist_ok=True)

    # Read all files into memory now (async), then process synchronously in the generator
    csv_bytes_list: list[bytes] = []
    activity_items: list[tuple[str, bytes]] = []   # (original_filename, bytes)

    for f in files:
        fname = Path(f.filename or "").name
        raw = await f.read()
        if fname.lower().endswith(".csv"):
            csv_bytes_list.append(raw)
        else:
            activity_items.append((fname, raw))

    # Build metadata from the first CSV
    metadata = None
    if csv_bytes_list:
        from bincio.extract.strava_csv import StravaMetadata
        import tempfile
        with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as tmp:
            tmp.write(csv_bytes_list[0])
            tmp_path = Path(tmp.name)
        try:
            metadata = StravaMetadata(tmp_path)
        finally:
            tmp_path.unlink(missing_ok=True)

    total_files = len(activity_items)
    job_id = _job_start(user.handle, total_files) if total_files > 0 else None

    def event_stream():
        added = 0
        overwritten = 0
        duplicates = 0
        errors = 0
        any_added = False

        for n, (name, contents) in enumerate(activity_items, 1):
            if job_id:
                _job_update(job_id, n - 1, name)

            suffix = _file_suffix(name)
            if suffix not in _SUPPORTED_SUFFIXES:
                errors += 1
                yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error', 'detail': 'unsupported type'})}\n\n"
                continue

            if len(contents) > 50 * 1024 * 1024:
                errors += 1
                yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error', 'detail': 'file too large'})}\n\n"
                continue

            staged = staging / name
            staged.write_bytes(contents)
            kept = False
            try:
                activity = parse_file(staged)
                if metadata is not None:
                    metadata.enrich(name, activity)
                activity_id = make_activity_id(activity)
                was_overwrite = False
                if (dd / "activities" / f"{activity_id}.json").exists():
                    if not overwrite:
                        duplicates += 1
                        yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'duplicate'})}\n\n"
                        continue
                    # Overwrite: delete existing files before re-ingesting.
                    for ext in (".json", ".geojson", ".timeseries.json"):
                        (dd / "activities" / f"{activity_id}{ext}").unlink(missing_ok=True)
                    # Remove stale summary from index so ingest_parsed writes a clean one
                    index_path = dd / "index.json"
                    if index_path.exists():
                        idx = json.loads(index_path.read_text(encoding="utf-8"))
                        idx["activities"] = [a for a in idx.get("activities", []) if a.get("id") != activity_id]
                        index_path.write_text(json.dumps(idx, indent=2, ensure_ascii=False))
                    # Remove from dedup hash cache so the new file isn't blocked
                    cache_path = dd / ".bincio_cache.json"
                    if cache_path.exists():
                        try:
                            cache = json.loads(cache_path.read_text(encoding="utf-8"))
                            cache.pop(activity_id, None)
                            cache_path.write_text(json.dumps(cache, ensure_ascii=False))
                        except Exception:
                            pass
                    # Remove merged copies (merge_all will regenerate them after ingest)
                    merged_acts = dd / "_merged" / "activities"
                    if merged_acts.exists():
                        for ext in (".json", ".geojson", ".timeseries.json"):
                            p = merged_acts / f"{activity_id}{ext}"
                            if p.exists() or p.is_symlink():
                                p.unlink(missing_ok=True)
                    was_overwrite = True
                ingest_parsed(activity, dd, privacy="public")
                if store_original:
                    originals_dir = dd / "originals"
                    originals_dir.mkdir(exist_ok=True)
                    staged.rename(originals_dir / name)
                    kept = True
                if was_overwrite:
                    overwritten += 1
                else:
                    added += 1
                any_added = True
                status = 'overwritten' if was_overwrite else 'imported'
                yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': status})}\n\n"
            except Exception as exc:
                errors += 1
                log.error("upload[%s]: failed to process %s: %s", user.handle, name, exc, exc_info=True)
                yield f"data: {json.dumps({'type': 'progress', 'n': n, 'total': total_files, 'name': name, 'status': 'error', 'detail': str(exc)})}\n\n"
            finally:
                if not kept:
                    staged.unlink(missing_ok=True)

        # Retroactively apply CSV metadata to existing activities
        csv_updates = 0
        if metadata is not None:
            from bincio.extract.strava_csv import apply_csv_to_data_dir
            csv_updates = apply_csv_to_data_dir(dd, metadata)
            if csv_updates:
                yield f"data: {json.dumps({'type': 'csv', 'updates': csv_updates})}\n\n"

        if any_added or csv_updates:
            merge_all(dd)
            if any_added:
                _trigger_rebuild(user.handle)

        yield f"data: {json.dumps({'type': 'done', 'added': added, 'overwritten': overwritten, 'csv_updates': csv_updates, 'duplicates': duplicates, 'errors': errors})}\n\n"

        if job_id:
            _job_finish(job_id)

    return StreamingResponse(
        event_stream(),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )


@app.post("/api/upload/strava-zip")
async def upload_strava_zip(
    file: UploadFile = File(...),
    private: str = Form(default="false"),
    bincio_session: Optional[str] = Cookie(default=None),
) -> StreamingResponse:
    """Accept a Strava bulk export ZIP and stream SSE progress while processing.

    The ZIP is written to a temp file, processed activity-by-activity, then deleted.
    Originals are never kept — the UI informs the user of this upfront.
    """
    user = _require_user(bincio_session)
    if not file.filename or not file.filename.lower().endswith(".zip"):
        raise HTTPException(400, "Please upload a .zip file")

    privacy = "unlisted" if private.lower() in ("true", "1", "yes") else "public"

    dd = _get_data_dir() / user.handle
    import tempfile
    tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
    zip_path = Path(tmp.name)
    try:
        while chunk := await file.read(1024 * 1024):  # 1 MB chunks
            tmp.write(chunk)
    finally:
        tmp.close()

    from bincio.extract.strava_zip import strava_zip_iter
    from bincio.render.merge import merge_all

    log.info("strava-zip[%s]: received %s, privacy=%s", user.handle, file.filename, privacy)

    def event_stream():
        any_imported = False
        imported_count = 0
        error_count = 0
        try:
            for event in strava_zip_iter(zip_path, dd, privacy=privacy):
                yield f"data: {json.dumps(event)}\n\n"
                if event.get("type") == "progress":
                    status = event.get("status")
                    if status == "imported":
                        any_imported = True
                        imported_count += 1
                    elif status == "error":
                        error_count += 1
                        log.warning("strava-zip[%s]: error on %s: %s",
                                    user.handle, event.get("name"), event.get("detail", ""))
                if event.get("type") == "done":
                    log.info("strava-zip[%s]: done — imported=%d errors=%d",
                             user.handle, imported_count, error_count)
                    if any_imported:
                        merge_all(dd)
                        _trigger_rebuild(user.handle)
        except Exception as exc:
            log.error("strava-zip[%s]: fatal error: %s", user.handle, exc, exc_info=True)
            yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n"
        finally:
            zip_path.unlink(missing_ok=True)

    return StreamingResponse(
        event_stream(),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )


# ── Feedback ──────────────────────────────────────────────────────────────────

_FEEDBACK_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"}
_FEEDBACK_MAX_IMAGES = 3
_FEEDBACK_MAX_IMAGE_BYTES = 2 * 1024 * 1024  # 2 MB


@app.post("/api/feedback")
async def submit_feedback(
    text: str = Form(""),
    images: list[UploadFile] = File(default=[]),
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    user = _require_user(bincio_session)

    text = text.strip()
    if not text and not any(f.filename for f in images):
        raise HTTPException(400, "Feedback must include text or at least one image")
    if len(images) > _FEEDBACK_MAX_IMAGES:
        raise HTTPException(400, f"Maximum {_FEEDBACK_MAX_IMAGES} images per submission")

    feedback_dir = _get_data_dir() / "_feedback"
    feedback_dir.mkdir(exist_ok=True)
    images_dir = feedback_dir / user.handle
    images_dir.mkdir(exist_ok=True)

    now = int(time.time())
    submission_id = f"{now}_{secrets.token_hex(4)}"
    saved_images: list[str] = []

    for img in images:
        if not img.filename:
            continue
        suffix = Path(img.filename).suffix.lower()
        if suffix not in _FEEDBACK_IMAGE_SUFFIXES:
            raise HTTPException(400, f"Unsupported image type '{suffix}'")
        contents = await img.read()
        if len(contents) > _FEEDBACK_MAX_IMAGE_BYTES:
            raise HTTPException(413, f"Image '{img.filename}' exceeds 2 MB limit")
        safe_name = f"{submission_id}_{Path(img.filename).name}"
        (images_dir / safe_name).write_bytes(contents)
        saved_images.append(safe_name)

    from datetime import datetime, timezone
    entry = {
        "id": submission_id,
        "handle": user.handle,
        "submitted_at": datetime.now(timezone.utc).isoformat(),
        "text": text,
        "images": saved_images,
    }

    log_file = feedback_dir / f"{user.handle}.json"
    existing: list[dict] = []
    if log_file.exists():
        try:
            existing = json.loads(log_file.read_text())
        except Exception:
            existing = []
    existing.append(entry)
    log_file.write_text(json.dumps(existing, indent=2))

    return JSONResponse({"ok": True, "id": submission_id})


# ── Strava ────────────────────────────────────────────────────────────────────

_strava_oauth_states: set[str] = set()


@app.get("/api/strava/status")
async def strava_status(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
    user = _require_user(bincio_session)
    cid, _ = _strava_creds(user.handle)
    if not cid:
        return JSONResponse({"configured": False, "connected": False, "last_sync": None})
    dd = _get_data_dir() / user.handle
    from bincio.extract.strava_api import load_token
    token = load_token(dd)
    return JSONResponse({
        "configured": True,
        "connected": token is not None,
        "last_sync": token.get("last_sync_at") if token else None,
    })


@app.post("/api/strava/disconnect")
async def strava_disconnect(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
    """Remove the stored Strava token, forcing a fresh OAuth on next connect."""
    user = _require_user(bincio_session)
    token_path = _get_data_dir() / user.handle / "strava_token.json"
    token_path.unlink(missing_ok=True)
    return JSONResponse({"ok": True})


@app.post("/api/strava/reset")
async def strava_reset(request: Request, bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
    """Reset last_sync_at so the next sync re-fetches from a chosen point.

    mode=soft  — set to the started_at of the most recent activity on disk
                 (next sync only fetches activities newer than the last known one)
    mode=hard  — clear last_sync_at entirely
                 (next sync re-downloads full Strava history, skipping existing files)
    """
    user = _require_user(bincio_session)
    dd = _get_data_dir() / user.handle
    from bincio.extract.strava_api import load_token, save_token
    token = load_token(dd)
    if token is None:
        raise HTTPException(400, "Not connected to Strava")

    body = await request.json()
    mode = body.get("mode", "soft")

    if mode == "hard":
        token.pop("last_sync_at", None)
        save_token(dd, token)
        return JSONResponse({"ok": True, "mode": "hard", "last_sync_at": None})

    # soft: find the most recent started_at across the user's merged index
    from datetime import datetime, timezone
    last_ts: int | None = None
    for index_path in [dd / "_merged" / "index.json", dd / "index.json"]:
        if not index_path.exists():
            continue
        try:
            index_data = json.loads(index_path.read_text(encoding="utf-8"))
            started_ats = [
                a.get("started_at") for a in index_data.get("activities", [])
                if a.get("started_at")
            ]
            if started_ats:
                latest = max(started_ats)
                dt = datetime.fromisoformat(latest.replace("Z", "+00:00"))
                last_ts = int(dt.astimezone(timezone.utc).timestamp())
                break
        except Exception:
            continue

    if last_ts is None:
        token.pop("last_sync_at", None)
    else:
        token["last_sync_at"] = last_ts
    save_token(dd, token)
    return JSONResponse({"ok": True, "mode": "soft", "last_sync_at": last_ts})


@app.get("/api/strava/auth-url")
async def strava_auth_url(request: Request, bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
    user = _require_user(bincio_session)
    cid, _ = _strava_creds(user.handle)
    if not cid:
        raise HTTPException(400, "Strava client ID not configured on this server")
    state = secrets.token_urlsafe(16)
    _strava_oauth_states.add(state)
    if public_url:
        redirect_uri = public_url.rstrip("/") + "/api/strava/callback"
    else:
        redirect_uri = str(request.url_for("strava_callback"))
    from bincio.extract.strava_api import auth_url
    return JSONResponse({"url": auth_url(cid, redirect_uri, state=state)})


@app.get("/api/strava/callback", name="strava_callback")
async def strava_callback(
    request: Request,
    code: str = "",
    error: str = "",
    state: str = "",
    bincio_session: Optional[str] = Cookie(default=None),
) -> RedirectResponse:
    site_origin = public_url.rstrip("/") if public_url else str(request.base_url).rstrip("/")
    if error or not code:
        return RedirectResponse(f"{site_origin}/?strava=error")
    if state not in _strava_oauth_states:
        return RedirectResponse(f"{site_origin}/?strava=error")
    _strava_oauth_states.discard(state)
    user = _current_user(bincio_session)
    if not user:
        return RedirectResponse(f"{site_origin}/?strava=error")
    cid, csec = _strava_creds(user.handle)
    if not cid or not csec:
        return RedirectResponse(f"{site_origin}/?strava=error")
    dd = _get_data_dir() / user.handle
    from bincio.extract.strava_api import StravaError, exchange_code, save_token
    try:
        token = exchange_code(cid, csec, code)
    except StravaError:
        return RedirectResponse(f"{site_origin}/?strava=error")
    save_token(dd, token)
    return RedirectResponse(f"{site_origin}/?strava=connected")


@app.get("/api/strava/sync/stream")
async def serve_strava_sync_stream(bincio_session: Optional[str] = Cookie(default=None)) -> StreamingResponse:
    """SSE endpoint — streams per-activity progress then a final summary event."""
    user = _require_user(bincio_session)
    cid, csec = _strava_creds(user.handle)
    if not cid or not csec:
        raise HTTPException(400, "Strava not configured on this server")
    dd = _get_data_dir() / user.handle
    store_orig_setting = get_setting(_get_db(), "store_originals")
    store_orig = store_orig_setting == "true"
    originals_dir = (dd / "originals" / "strava") if store_orig else None
    if originals_dir:
        originals_dir.mkdir(parents=True, exist_ok=True)

    from bincio.extract.ingest import strava_sync_iter

    def event_stream():
        try:
            for event in strava_sync_iter(dd, cid, csec, originals_dir):
                if event["type"] == "done":
                    _trigger_rebuild(user.handle)  # start before client closes connection
                yield f"data: {json.dumps(event)}\n\n"
        except Exception as exc:
            yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n"

    return StreamingResponse(
        event_stream(),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )


@app.post("/api/strava/sync")
async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
    user = _require_user(bincio_session)
    cid, csec = _strava_creds(user.handle)
    if not cid or not csec:
        raise HTTPException(400, "Strava not configured on this server")
    dd = _get_data_dir() / user.handle
    store_orig_setting = get_setting(_get_db(), "store_originals")
    store_orig = store_orig_setting == "true"
    originals_dir = (dd / "originals" / "strava") if store_orig else None
    if originals_dir:
        originals_dir.mkdir(parents=True, exist_ok=True)
    from bincio.edit.ops import run_strava_sync
    try:
        result = run_strava_sync(dd, cid, csec, originals_dir=originals_dir)
    except RuntimeError as e:
        raise HTTPException(502, str(e))
    _trigger_rebuild(user.handle)
    return JSONResponse(result)


# ── Garmin Connect endpoints ──────────────────────────────────────────────────

def _garmin_user_message(exc: Exception) -> str:
    """Return a human-friendly error message for common Garmin login failures."""
    msg = str(exc)
    fallback = (
        " In the meantime, you can export your activities from Garmin Connect "
        "(garmin.com → Activities → Export) or Garmin Express as FIT files "
        "and upload them directly."
    )
    if "429" in msg or "rate limit" in msg.lower():
        return (
            "Garmin is rate-limiting this server's IP address (HTTP 429). "
            "Wait a few hours and try again." + fallback
        )
    if "403" in msg:
        return (
            "Cloudflare is blocking the login request (HTTP 403). "
            "This is a known upstream issue — try again later or update garminconnect "
            "(uv sync --extra garmin)." + fallback
        )
    if "GARMIN Authentication Application" in msg or "unexpected title" in msg.lower():
        return (
            "Garmin's login page returned a CAPTCHA or MFA challenge that "
            "cannot be completed automatically. Try again later, or disable "
            "two-factor authentication on your Garmin account." + fallback
        )
    return f"Login failed: {exc}" + fallback

@app.get("/api/garmin/status")
async def garmin_status(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
    """Return whether Garmin credentials are stored for the current user."""
    user = _require_user(bincio_session)
    dd = _get_data_dir() / user.handle
    from bincio.extract.garmin_api import has_credentials
    from bincio.extract.garmin_sync import _load_sync_state
    connected = has_credentials(dd)
    last_sync = None
    if connected:
        state = _load_sync_state(dd)
        last_sync = state.get("last_sync_at")
    return JSONResponse({"connected": connected, "last_sync": last_sync})


@app.post("/api/garmin/connect")
async def garmin_connect(
    request: Request,
    bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse:
    """Test Garmin login with the supplied credentials and save them on success."""
    user = _require_user(bincio_session)
    body = await request.json()
    email    = (body.get("email") or "").strip()
    password = body.get("password") or ""
    if not email or not password:
        raise HTTPException(400, "email and password are required")

    data_dir = _get_data_dir()
    user_dir = data_dir / user.handle
    from bincio.extract.garmin_api import GarminError, test_login
    try:
        info = test_login(data_dir, user_dir, email, password)
    except GarminError as exc:
        raise HTTPException(400, _garmin_user_message(exc))
    return JSONResponse({"ok": True, **info})


@app.post("/api/garmin/disconnect")
async def garmin_disconnect(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
    """Remove stored Garmin credentials and session for the current user."""
    user = _require_user(bincio_session)
    dd = _get_data_dir() / user.handle
    from bincio.extract.garmin_api import delete_credentials
    delete_credentials(dd)
    return JSONResponse({"ok": True})


@app.get("/api/garmin/sync/stream")
async def garmin_sync_stream(bincio_session: Optional[str] = Cookie(default=None)) -> StreamingResponse:
    """SSE endpoint — streams per-activity Garmin sync progress."""
    user = _require_user(bincio_session)
    data_dir = _get_data_dir()
    user_dir = data_dir / user.handle

    from bincio.extract.garmin_api import GarminError, has_credentials
    if not has_credentials(user_dir):
        raise HTTPException(400, "No Garmin credentials stored — connect first")

    from bincio.extract.garmin_sync import garmin_sync_iter

    def event_stream():
        try:
            for event in garmin_sync_iter(data_dir, user_dir):
                if event["type"] == "done":
                    _trigger_rebuild(user.handle)
                yield f"data: {json.dumps(event)}\n\n"
        except GarminError as exc:
            yield f"data: {json.dumps({'type': 'error', 'message': _garmin_user_message(exc)})}\n\n"
        except Exception as exc:
            yield f"data: {json.dumps({'type': 'error', 'message': _garmin_user_message(exc)})}\n\n"

    return StreamingResponse(
        event_stream(),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )
