#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
New Talk — cam görünümlü, gerçek zamanlı sohbet sunucusu.

Tek dosyalık FastAPI uygulaması:
  * E-posta + şifre girişi
  * E-posta doğrulama kodu (SMTP veya Resend)
  * Profil fotoğrafı ve sohbet fotoğrafı yükleme
  * Mesajların sunucuda kalıcı saklanması (JSON)
  * WebSocket ile canlı mesajlaşma, yazıyor göstergesi ve çevrimiçi durum
  * Kullanıcı / depolama kapasitesi dolduğunda "Üzgünüz, sunucu doldu." yanıtı
"""

from __future__ import annotations

import asyncio
import base64
import hashlib
import hmac
import io
import json
import logging
import os
import re
import secrets
import shutil
import smtplib
import socket
import ssl
import sys
import time
import urllib.error
import urllib.request
import uuid
from collections import defaultdict, deque
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from email.message import EmailMessage
from email.utils import formataddr, formatdate, make_msgid
from pathlib import Path
from typing import Any
from urllib.parse import urlparse

import bcrypt
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.exceptions import RequestValidationError
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from PIL import Image, ImageOps, UnidentifiedImageError
from pydantic import BaseModel
from starlette.exceptions import HTTPException as StarletteHTTPException

# ---------------------------------------------------------------------------
# Yapılandırma
# ---------------------------------------------------------------------------

BASE_DIR = Path(__file__).resolve().parent
STATIC_DIR = BASE_DIR / "static"
DATA_DIR = BASE_DIR / "data"
MESSAGE_DIR = DATA_DIR / "messages"
DM_MESSAGE_DIR = DATA_DIR / "dm_messages"
UPLOAD_DIR = BASE_DIR / "uploads"
AVATAR_DIR = UPLOAD_DIR / "avatars"
CHAT_IMAGE_DIR = UPLOAD_DIR / "chat"

for _folder in (DATA_DIR, MESSAGE_DIR, DM_MESSAGE_DIR, UPLOAD_DIR, AVATAR_DIR, CHAT_IMAGE_DIR):
    _folder.mkdir(parents=True, exist_ok=True)


def load_env_file() -> None:
    """Küçük bir .env okuyucu (harici bağımlılık gerektirmez)."""
    env_path = BASE_DIR / ".env"
    if not env_path.exists():
        return
    try:
        for raw_line in env_path.read_text(encoding="utf-8-sig").splitlines():
            line = raw_line.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue
            key, value = line.split("=", 1)
            key = key.strip()
            value = value.strip()
            if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
                value = value[1:-1]
            if key and key not in os.environ:
                os.environ[key] = value
    except OSError:
        pass


load_env_file()


def env(name: str, default: str = "") -> str:
    return os.environ.get(name, default).strip()


def env_int(name: str, default: int) -> int:
    try:
        return int(env(name, str(default)))
    except ValueError:
        return default


def env_float(name: str, default: float) -> float:
    try:
        return float(env(name, str(default)))
    except ValueError:
        return default


APP_NAME = env("NEWTALK_NAME", "New Talk") or "New Talk"
HOST = env("NEWTALK_HOST", "0.0.0.0") or "0.0.0.0"
PORT = env_int("NEWTALK_PORT", 7788)
MAX_USERS = max(1, env_int("NEWTALK_MAX_USERS", 100))
STORAGE_LIMIT_MB = max(8.0, env_float("NEWTALK_STORAGE_LIMIT_MB", 512))
MIN_FREE_DISK_MB = max(10.0, env_float("NEWTALK_MIN_FREE_DISK_MB", 80))
MAX_IMAGE_MB = max(1.0, env_float("NEWTALK_MAX_IMAGE_MB", 10))
MAX_BODY_BYTES = int(env_float("NEWTALK_MAX_BODY_MB", 36) * 1024 * 1024)
SESSION_DAYS = max(1, env_int("NEWTALK_SESSION_DAYS", 30))
CODE_TTL_SECONDS = 10 * 60
CODE_RESEND_SECONDS = 45
CODE_MAX_ATTEMPTS = 5
MAX_ROOM_NAME = 28
MAX_MESSAGE_CHARS = 4000
MAX_IMAGES_PER_MESSAGE = 4
MIN_PASSWORD_LENGTH = 6
STORAGE_BYTES = int(STORAGE_LIMIT_MB * 1024 * 1024)
START_TIME = time.time()
SESSION_COOKIE = "newtalk_session"

SMTP_HOST = env("NEWTALK_SMTP_HOST")
SMTP_PORT = env_int("NEWTALK_SMTP_PORT", 587)
SMTP_USER = env("NEWTALK_SMTP_USER")
SMTP_PASSWORD = env("NEWTALK_SMTP_PASSWORD")
SMTP_TLS = env("NEWTALK_SMTP_TLS", "auto").lower()
SMTP_FROM = env("NEWTALK_SMTP_FROM") or (
    formataddr((APP_NAME, SMTP_USER)) if SMTP_USER else ""
)
RESEND_API_KEY = env("NEWTALK_RESEND_API_KEY")
EMAIL_READY = bool(RESEND_API_KEY or (SMTP_HOST and SMTP_FROM))
SHOW_DEV_CODE = env("NEWTALK_EXPOSE_DEV_CODE", "auto").lower() not in ("0", "false", "no", "off")
SHOW_DEV_CODE = SHOW_DEV_CODE and not EMAIL_READY

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s  %(levelname)-7s %(message)s",
    datefmt="%H:%M:%S",
)
log = logging.getLogger("newtalk")

try:
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")  # type: ignore[attr-defined]
    sys.stderr.reconfigure(encoding="utf-8", errors="replace")  # type: ignore[attr-defined]
except Exception:
    pass


# ---------------------------------------------------------------------------
# Basit JSON deposu
# ---------------------------------------------------------------------------

USERS_FILE = DATA_DIR / "users.json"
SESSIONS_FILE = DATA_DIR / "sessions.json"
ROOMS_FILE = DATA_DIR / "rooms.json"
DMS_FILE = DATA_DIR / "dms.json"
DM_READS_FILE = DATA_DIR / "dm_reads.json"

SYSTEM_USER_ID = "newtalk"
SYSTEM_USER: dict[str, Any] = {
    "id": SYSTEM_USER_ID,
    "name": APP_NAME,
    "avatar": "/static/img/newtalk-avatar.svg",
    "bio": "Sohbetin yeni boyutu",
    "system": True,
}


def read_json(path: Path, default: Any) -> Any:
    try:
        with path.open("r", encoding="utf-8") as fh:
            return json.load(fh)
    except FileNotFoundError:
        return default
    except (json.JSONDecodeError, OSError) as exc:
        log.error("%s okunamadı: %s", path.name, exc)
        return default


def write_json(path: Path, data: Any) -> None:
    tmp = path.with_name(path.name + ".tmp")
    with tmp.open("w", encoding="utf-8", newline="\n") as fh:
        json.dump(data, fh, ensure_ascii=False, indent=2)
    os.replace(tmp, path)


def now_iso() -> str:
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


def iso_after(seconds: int) -> str:
    return (datetime.now(timezone.utc) + timedelta(seconds=seconds)).isoformat(timespec="seconds")


def parse_iso(value: str) -> datetime | None:
    try:
        return datetime.fromisoformat(value)
    except (TypeError, ValueError):
        return None


def hash_token(token: str) -> str:
    return hashlib.sha256(token.encode("utf-8")).hexdigest()


store_lock = asyncio.Lock()
users: dict[str, dict[str, Any]] = read_json(USERS_FILE, {})
sessions: dict[str, dict[str, Any]] = read_json(SESSIONS_FILE, {})
rooms: dict[str, dict[str, Any]] = read_json(ROOMS_FILE, {})
messages_by_room: dict[str, list[dict[str, Any]]] = {}
dms: dict[str, dict[str, Any]] = read_json(DMS_FILE, {})
dm_messages: dict[str, list[dict[str, Any]]] = {}
dm_reads: dict[str, dict[str, str]] = read_json(DM_READS_FILE, {})

# ---------------------------------------------------------------------------
# İlk kurulum / örnek kanallar
# ---------------------------------------------------------------------------

DEFAULT_ROOMS = [
    {
        "id": "genel",
        "name": "genel",
        "emoji": "🌍",
        "description": "Herkese açık tek ortak alan",
    },
]


def seed_rooms() -> None:
    if rooms:
        return
    created = now_iso()
    for item in DEFAULT_ROOMS:
        rooms[item["id"]] = {
            **item,
            "created_by": SYSTEM_USER_ID,
            "created_at": created,
        }
    write_json(ROOMS_FILE, rooms)


def load_messages() -> None:
    for room_id in rooms:
        messages_by_room.setdefault(
            room_id, read_json(MESSAGE_DIR / f"{room_id}.json", [])
        )
    # Yeni açılmış kanallar için boş dosyaları oluştur.
    for room_id in rooms:
        messages_by_room.setdefault(room_id, [])


def load_dms() -> None:
    for dm_id in dms:
        dm_messages.setdefault(
            dm_id, read_json(DM_MESSAGE_DIR / f"{dm_id}.json", [])
        )
    for dm_id in dms:
        dm_messages.setdefault(dm_id, [])


def seed_welcome_message() -> None:
    general = rooms.get("genel")
    if not general:
        return
    if messages_by_room.get("genel"):
        return
    welcome = {
        "id": uuid.uuid4().hex,
        "room_id": "genel",
        "user_id": SYSTEM_USER_ID,
        "text": (
            "New Talk'a hoş geldin! ✨\n\n"
            "Buradan herkese merhaba diyebilir, fotoğraf paylaşabilir ve yeni kanallar açabilirsin. "
            "Mesajların bu sunucuda saklanır; sayfayı yenilesen bile yerinde durur."
        ),
        "images": [],
        "created_at": now_iso(),
        "reactions": {},
    }
    messages_by_room["genel"] = [welcome]
    write_json(MESSAGE_DIR / "genel.json", [welcome])


seed_rooms()
load_messages()
load_dms()
seed_welcome_message()


# ---------------------------------------------------------------------------
# Yardımcılar
# ---------------------------------------------------------------------------


def fail(status: int, message: str, code: str | None = None) -> None:
    raise HTTPException(
        status_code=status,
        detail={"error": message, **({"code": code} if code else {})},
    )


def client_ip(request: Request) -> str:
    forwarded = request.headers.get("x-forwarded-for")
    if forwarded:
        return forwarded.split(",")[0].strip()
    return request.client.host if request.client else "unknown"


def secure_request(request: Request) -> bool:
    proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip().lower()
    return request.url.scheme == "https" or proto == "https"


def get_user_from_cookies(cookies: Any) -> dict[str, Any] | None:
    token = cookies.get(SESSION_COOKIE)
    if not token:
        return None
    session = sessions.get(hash_token(token))
    if not session:
        return None
    expires = parse_iso(session.get("expires_at", ""))
    if not expires or expires <= datetime.now(timezone.utc):
        sessions.pop(hash_token(token), None)
        return None
    return users.get(session.get("user_id", ""))


def require_user(request: Request) -> dict[str, Any]:
    user = get_user_from_cookies(request.cookies)
    if not user:
        fail(401, "Bu işlem için giriş yapmalısın.", "unauthorized")
    return user


def public_card(user: dict[str, Any] | None) -> dict[str, Any]:
    if not user:
        return {"id": "deleted", "name": "Silinmiş kullanıcı", "avatar": "", "bio": ""}
    return {
        "id": user.get("id", ""),
        "name": user.get("name", "Kullanıcı"),
        "avatar": user.get("avatar", ""),
        "bio": user.get("bio", ""),
    }


def me_payload(user: dict[str, Any]) -> dict[str, Any]:
    return {
        "id": user["id"],
        "email": user.get("email", ""),
        "name": user.get("name", ""),
        "avatar": user.get("avatar", ""),
        "bio": user.get("bio", ""),
        "created_at": user.get("created_at", ""),
    }


def find_user_by_email(email: str) -> dict[str, Any] | None:
    target = email.casefold()
    for user in users.values():
        if user.get("email", "").casefold() == target:
            return user
    return None


# ---------------------------------------------------------------------------
# Özel sohbetler (DM)
# ---------------------------------------------------------------------------


def dm_id_for(first: str, second: str | None = None) -> str:
    if not second or second == first:
        return f"note_{first}"
    digest = hashlib.sha256("|".join(sorted((first, second))).encode("utf-8")).hexdigest()
    return f"dm_{digest[:24]}"


def dm_peer_id(dm: dict[str, Any], viewer_id: str) -> str | None:
    for participant in dm.get("participants", []):
        if participant != viewer_id:
            return participant
    return None


def dm_last_message(dm_id: str) -> dict[str, Any] | None:
    items = dm_messages.get(dm_id, [])
    return items[-1] if items else None


def serialize_dm(dm: dict[str, Any], viewer_id: str) -> dict[str, Any]:
    dm_id = dm["id"]
    kind = dm.get("kind", "direct")
    messages = dm_messages.get(dm_id, [])
    last = messages[-1] if messages else None
    peer = None
    title = "Kendime Notlar"
    if kind != "self":
        peer_id = dm_peer_id(dm, viewer_id)
        peer = public_card(users.get(peer_id or ""))
        title = peer.get("name") or "Özel sohbet"

    unread = 0
    if kind != "self":
        read_at = dm_reads.get(dm_id, {}).get(viewer_id, "")
        unread = sum(
            1
            for message in messages
            if message.get("user_id") != viewer_id
            and str(message.get("created_at", "")) > read_at
        )

    last_payload = None
    if last:
        last_payload = {
            "id": last.get("id", ""),
            "user_id": last.get("user_id", ""),
            "text": (last.get("text", "") or "")[:180],
            "has_images": bool(last.get("images")),
            "created_at": last.get("created_at", ""),
        }

    return {
        "id": dm_id,
        "kind": kind,
        "participants": list(dm.get("participants", [])),
        "peer": peer,
        "title": title,
        "created_at": dm.get("created_at", ""),
        "last_message_at": last.get("created_at", "") if last else dm.get("created_at", ""),
        "last_message": last_payload,
        "message_count": len(messages),
        "unread_count": unread,
    }


async def ensure_dm(viewer_id: str, peer_id: str | None = None) -> dict[str, Any]:
    """İki kullanıcı arasındaki DM'yi (veya kişisel not alanını) hazırlar."""
    if peer_id and peer_id != viewer_id:
        if peer_id not in users:
            fail(404, "Bu kullanıcı bulunamadı.", "user_not_found")
        dm_id = dm_id_for(viewer_id, peer_id)
        kind = "direct"
        participants = sorted((viewer_id, peer_id))
    else:
        dm_id = dm_id_for(viewer_id)
        kind = "self"
        participants = [viewer_id]

    dm = dms.get(dm_id)
    if dm:
        return dm

    dm = {
        "id": dm_id,
        "kind": kind,
        "participants": participants,
        "created_at": now_iso(),
    }
    async with store_lock:
        if dm_id not in dms:
            dms[dm_id] = dm
            dm_messages.setdefault(dm_id, [])
            write_json(DMS_FILE, dms)
            write_json(DM_MESSAGE_DIR / f"{dm_id}.json", [])
    return dms[dm_id]


def require_dm(dm_id: str, user: dict[str, Any]) -> dict[str, Any]:
    dm = dms.get(dm_id)
    if not dm or user["id"] not in dm.get("participants", []):
        fail(404, "Özel sohbet bulunamadı.", "dm_not_found")
    return dm


async def mark_dm_read(dm_id: str, user_id: str) -> None:
    dm = dms.get(dm_id)
    if not dm or dm.get("kind") == "self":
        return
    async with store_lock:
        dm_reads.setdefault(dm_id, {})[user_id] = now_iso()
        write_json(DM_READS_FILE, dm_reads)


def serialize_dm_message(message: dict[str, Any]) -> dict[str, Any]:
    payload = serialize_message(message)
    payload["dm_id"] = message.get("dm_id", "")
    return payload


def attach_session(response: Response, request: Request, user_id: str) -> None:
    token = secrets.token_urlsafe(40)
    sessions[hash_token(token)] = {
        "user_id": user_id,
        "created_at": now_iso(),
        "expires_at": iso_after(SESSION_DAYS * 86400),
    }
    # Süresi geçmiş oturumları temizle.
    now = datetime.now(timezone.utc)
    for key, session in list(sessions.items()):
        exp = parse_iso(session.get("expires_at", ""))
        if not exp or exp <= now:
            sessions.pop(key, None)
    write_json(SESSIONS_FILE, sessions)
    response.set_cookie(
        SESSION_COOKIE,
        token,
        max_age=SESSION_DAYS * 86400,
        httponly=True,
        samesite="lax",
        secure=secure_request(request),
        path="/",
    )


def clear_session(response: Response, request: Request) -> None:
    token = request.cookies.get(SESSION_COOKIE)
    if token:
        sessions.pop(hash_token(token), None)
        write_json(SESSIONS_FILE, sessions)
    response.delete_cookie(
        SESSION_COOKIE,
        path="/",
        httponly=True,
        samesite="lax",
        secure=secure_request(request),
    )


def calculate_storage_usage() -> tuple[int, int | None]:
    """(kullanılan bayt, boş disk baytı) döndürür."""
    total = 0
    for root in (DATA_DIR, UPLOAD_DIR):
        for path in root.rglob("*"):
            try:
                if path.is_file():
                    total += path.stat().st_size
            except OSError:
                continue
    try:
        free = shutil.disk_usage(BASE_DIR).free
    except OSError:
        free = None
    return total, free


def storage_is_full(used: int | None = None, free: int | None = None, add_bytes: int = 0) -> bool:
    if used is None or free is None:
        used, free = calculate_storage_usage()
    if used + add_bytes >= STORAGE_BYTES:
        return True
    if free is not None and free - add_bytes < MIN_FREE_DISK_MB * 1024 * 1024:
        return True
    return False


def server_status() -> dict[str, Any]:
    used, free = calculate_storage_usage()
    users_full = len(users) >= MAX_USERS
    storage_full = storage_is_full(used, free)
    used_percent = min(100.0, round((used / STORAGE_BYTES) * 100, 1)) if STORAGE_BYTES else 100.0
    message_count = sum(len(items) for items in messages_by_room.values()) + sum(
        len(items) for items in dm_messages.values()
    )
    return {
        "name": APP_NAME,
        "max_users": MAX_USERS,
        "user_count": len(users),
        "online_count": len(hub.online_ids()) if "hub" in globals() else 0,
        "room_count": len(rooms),
        "message_count": message_count,
        "uptime_seconds": int(time.time() - START_TIME),
        "registration_open": not users_full and not storage_full,
        "capacity": {
            "users_full": users_full,
            "storage_full": storage_full,
            "full": users_full or storage_full,
            "writable": not storage_full,
        },
        "storage": {
            "used_bytes": used,
            "quota_bytes": STORAGE_BYTES,
            "free_bytes": free,
            "percent": used_percent,
            "full": storage_full,
        },
        "email": {
            "configured": EMAIL_READY,
            "dev_mode": SHOW_DEV_CODE,
        },
        "code_policy": {
            "ttl_seconds": CODE_TTL_SECONDS,
            "resend_seconds": CODE_RESEND_SECONDS,
        },
        "server_time": now_iso(),
    }


def ensure_capacity(for_registration: bool = False, add_bytes: int = 0) -> None:
    if for_registration and len(users) >= MAX_USERS:
        fail(
            507,
            "Üzgünüz, sunucu doldu. Tüm kullanıcı alanları dolu olduğu için yeni kayıt alınamıyor.",
            "server_full",
        )
    used, free = calculate_storage_usage()
    if storage_is_full(used, free, add_bytes):
        fail(
            507,
            "Üzgünüz, sunucu doldu. Depolama alanı dolduğu için şu anda yeni içerik kaydedilemiyor.",
            "server_full",
        )


def friendly_size(num: int | float) -> str:
    value = float(num)
    for unit in ("B", "KB", "MB", "GB"):
        if value < 1024 or unit == "GB":
            return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}"
        value /= 1024
    return f"{value:.1f} GB"


# ---------------------------------------------------------------------------
# Görsel işleme
# ---------------------------------------------------------------------------

DATA_URL_RE = re.compile(r"^data:image/([a-zA-Z0-9.+-]+);base64,(.+)$", re.DOTALL)
ALLOWED_IMAGE_FORMATS = {"PNG", "JPEG", "WEBP", "GIF", "BMP", "AVIF"}


def decode_image(data_url: Any, max_bytes: int) -> Image.Image:
    if not isinstance(data_url, str) or len(data_url) > max_bytes * 2:
        fail(413, "Fotoğraf çok büyük. Daha küçük bir fotoğraf seç.", "image_too_large")
    match = DATA_URL_RE.match(data_url)
    if not match:
        fail(400, "Fotoğraf biçimi desteklenmiyor.", "bad_image")
    encoded = match.group(2)
    if len(encoded) > int(max_bytes * 4 / 3) + 256:
        fail(413, "Fotoğraf çok büyük. En fazla 10 MB olabilir.", "image_too_large")
    try:
        raw = base64.b64decode(encoded, validate=True)
    except Exception:
        fail(400, "Fotoğraf okunamadı. Lütfen tekrar dene.", "bad_image")
    if len(raw) > max_bytes:
        fail(413, "Fotoğraf çok büyük. En fazla 10 MB olabilir.", "image_too_large")
    try:
        image = Image.open(io.BytesIO(raw))
        image.load()
    except (UnidentifiedImageError, OSError, ValueError):
        fail(415, "Bu dosya geçerli bir fotoğraf değil. JPG, PNG veya WebP dene.", "bad_image")
    if image.format and image.format.upper() not in ALLOWED_IMAGE_FORMATS:
        fail(415, "Bu fotoğraf biçimi desteklenmiyor. JPG, PNG veya WebP kullan.", "bad_image")
    if image.width * image.height > 40_000_000:
        fail(413, "Fotoğrafın çözünürlüğü çok yüksek.", "image_too_large")
    try:
        image = ImageOps.exif_transpose(image)
    except Exception:
        pass
    return image


def save_image(image: Image.Image, kind: str, max_dim: int, square: bool = False) -> dict[str, Any]:
    if square:
        image = ImageOps.fit(
            image, (max_dim, max_dim), method=Image.Resampling.LANCZOS, centering=(0.5, 0.45)
        )
    else:
        image.thumbnail((max_dim, max_dim), Image.Resampling.LANCZOS)

    has_alpha = image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info)
    if has_alpha:
        if image.mode != "RGBA":
            image = image.convert("RGBA")
        background = Image.new("RGBA", image.size, (10, 9, 24, 255))
        background.alpha_composite(image)
        image = background.convert("RGB")
    elif image.mode != "RGB":
        image = image.convert("RGB")

    folder = AVATAR_DIR if kind == "avatars" else CHAT_IMAGE_DIR
    filename = f"{uuid.uuid4().hex}.webp"
    dest = folder / filename
    try:
        image.save(dest, format="WEBP", quality=84, method=6)
    except OSError:
        fail(500, "Fotoğraf kaydedilemedi. Lütfen tekrar dene.", "image_save_failed")
    return {
        "path": f"/media/{kind}/{filename}",
        "width": image.width,
        "height": image.height,
        "bytes": dest.stat().st_size,
    }


def remove_upload_file(relative_path: str) -> None:
    if not isinstance(relative_path, str) or not relative_path.startswith("/media/"):
        return
    try:
        target = (UPLOAD_DIR / relative_path.removeprefix("/media/")).resolve()
        if UPLOAD_DIR.resolve() in target.parents and target.is_file():
            target.unlink()
    except OSError:
        pass


def save_chat_images(data_urls: list[str]) -> list[dict[str, Any]]:
    """Sohbet fotoğraflarını kaydeder; hata olursa yarım kalanları temizler."""
    saved: list[dict[str, Any]] = []
    try:
        for data_url in data_urls:
            image = decode_image(data_url, int(MAX_IMAGE_MB * 1024 * 1024))
            info = save_image(image, "chat", 1600)
            saved.append(info)
            ensure_capacity()
    except Exception:
        for item in saved:
            remove_upload_file(item["path"])
        raise
    return saved


# ---------------------------------------------------------------------------
# E-posta gönderimi
# ---------------------------------------------------------------------------

EMAIL_SUBJECT = f"{APP_NAME} doğrulama kodun"


def verification_email_html(code: str, purpose: str) -> str:
    action = "Hesabını oluşturmak" if purpose == "signup" else "giriş yapmak"
    return f"""\
<!doctype html>
<html lang="tr">
<body style="margin:0;padding:0;background:#080712;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;color:#f5f3ff;">
  <div style="max-width:520px;margin:0 auto;padding:40px 20px;">
    <div style="text-align:center;margin-bottom:26px;">
      <div style="display:inline-block;padding:10px 18px;border-radius:999px;background:rgba(139,92,246,.16);border:1px solid rgba(139,92,246,.35);font-size:14px;letter-spacing:.14em;text-transform:uppercase;color:#c4b5fd;">{APP_NAME}</div>
    </div>
    <div style="background:linear-gradient(160deg,rgba(255,255,255,.10),rgba(255,255,255,.035));border:1px solid rgba(255,255,255,.12);border-radius:24px;padding:36px 28px;text-align:center;box-shadow:0 24px 80px rgba(0,0,0,.45);">
      <h1 style="margin:0 0 12px;font-size:24px;line-height:1.3;">E-postanı doğrula</h1>
      <p style="margin:0 0 26px;color:#a9a4c2;font-size:15px;line-height:1.6;">{action} için aşağıdaki 6 haneli kodu kullan. Kod 10 dakika geçerli.</p>
      <div style="display:inline-block;padding:18px 26px;border-radius:18px;background:rgba(34,211,238,.08);border:1px solid rgba(34,211,238,.28);">
        <span style="font-size:38px;font-weight:700;letter-spacing:12px;color:#e9d5ff;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;padding-left:12px;">{code}</span>
      </div>
      <p style="margin:26px 0 0;color:#8f89a8;font-size:13px;line-height:1.6;">Bu kodu kimseyle paylaşma. Girişi sen başlatmadıysan bu e-postayı yok sayabilirsin.</p>
    </div>
    <p style="text-align:center;color:#615b78;font-size:12px;margin-top:24px;">{APP_NAME} · Sohbetin yeni boyutu</p>
  </div>
</body>
</html>"""


def verification_email_text(code: str, purpose: str) -> str:
    action = "Hesabını oluşturmak" if purpose == "signup" else "Giriş yapmak"
    return (
        f"{APP_NAME} — E-posta doğrulama\n\n"
        f"{action} için doğrulama kodun: {code}\n\n"
        "Kod 10 dakika geçerlidir. Bu kodu kimseyle paylaşma."
    )


def send_via_smtp(to_email: str, subject: str, text: str, html: str) -> None:
    msg = EmailMessage()
    msg["Subject"] = subject
    msg["From"] = SMTP_FROM
    msg["To"] = to_email
    msg["Date"] = formatdate(localtime=True)
    msg["Message-ID"] = make_msgid(domain="newtalk.local")
    msg.set_content(text)
    msg.add_alternative(html, subtype="html")

    tls_mode = SMTP_TLS
    if tls_mode == "auto":
        tls_mode = "ssl" if SMTP_PORT == 465 else "starttls"

    if tls_mode == "ssl":
        server: smtplib.SMTP = smtplib.SMTP_SSL(
            SMTP_HOST, SMTP_PORT, timeout=25, context=ssl.create_default_context()
        )
    else:
        server = smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=25)
        server.ehlo()
        if tls_mode == "starttls":
            server.starttls(context=ssl.create_default_context())
            server.ehlo()
    try:
        if SMTP_USER:
            server.login(SMTP_USER, SMTP_PASSWORD)
        server.send_message(msg)
    finally:
        try:
            server.quit()
        except Exception:
            pass


def send_via_resend(to_email: str, subject: str, text: str, html: str) -> None:
    payload = {
        "from": SMTP_FROM or f"{APP_NAME} <onboarding@resend.dev>",
        "to": [to_email],
        "subject": subject,
        "html": html,
        "text": text,
    }
    request = urllib.request.Request(
        "https://api.resend.com/emails",
        data=json.dumps(payload).encode("utf-8"),
        headers={
            "Authorization": f"Bearer {RESEND_API_KEY}",
            "Content-Type": "application/json",
        },
        method="POST",
    )
    try:
        with urllib.request.urlopen(request, timeout=25) as response:
            if response.status >= 300:
                raise RuntimeError(f"Resend HTTP {response.status}")
    except urllib.error.HTTPError as exc:
        detail = exc.read().decode("utf-8", "replace")[:300]
        raise RuntimeError(f"Resend HTTP {exc.code}: {detail}") from exc


async def deliver_code_email(to_email: str, code: str, purpose: str) -> str:
    if not EMAIL_READY:
        log.warning(" ")
        log.warning("  ┌─────────────────────────────────────────────┐")
        log.warning("  │  GELİŞTİRME MODU — E-posta gönderilmedi     │")
        log.warning("  │  %s için kod: %s                    │", to_email[:32], code)
        log.warning("  └─────────────────────────────────────────────┘")
        log.warning(" ")
        return "dev"

    subject = f"{code} — {EMAIL_SUBJECT}"
    text = verification_email_text(code, purpose)
    html = verification_email_html(code, purpose)
    try:
        if RESEND_API_KEY:
            await asyncio.to_thread(send_via_resend, to_email, subject, text, html)
        else:
            await asyncio.to_thread(send_via_smtp, to_email, subject, text, html)
    except Exception as exc:
        log.error("Doğrulama e-postası gönderilemedi (%s): %s", to_email, exc)
        fail(
            502,
            "Doğrulama e-postası gönderilemedi. Sunucudaki e-posta ayarlarını kontrol et.",
            "email_failed",
        )
    return "email"


# ---------------------------------------------------------------------------
# Hız sınırları ve doğrulama kodları
# ---------------------------------------------------------------------------

rate_events: dict[str, deque[float]] = defaultdict(deque)
last_send_at: dict[str, float] = {}
login_failures: dict[str, deque[float]] = defaultdict(deque)
active_codes: dict[str, dict[str, Any]] = {}


def rate_limit(key: str, limit: int, window: int) -> bool:
    now = time.time()
    bucket = rate_events[key]
    while bucket and now - bucket[0] > window:
        bucket.popleft()
    if len(bucket) >= limit:
        return False
    bucket.append(now)
    return True


def code_key(email: str, purpose: str) -> str:
    return f"{email.casefold()}|{purpose}"


def store_code(email: str, purpose: str, code: str) -> None:
    salt = secrets.token_hex(16)
    active_codes[code_key(email, purpose)] = {
        "hash": hashlib.sha256((salt + code).encode("utf-8")).hexdigest(),
        "salt": salt,
        "purpose": purpose,
        "expires_at": time.time() + CODE_TTL_SECONDS,
        "attempts": 0,
    }


def check_code(email: str, purpose: str, code: str) -> None:
    key = code_key(email, purpose)
    entry = active_codes.get(key)
    if not entry:
        fail(400, "Önce doğrulama kodu isteyin.", "code_required")
    if time.time() > entry["expires_at"]:
        active_codes.pop(key, None)
        fail(400, "Doğrulama kodunun süresi doldu. Yeni bir kod isteyin.", "code_expired")
    if entry["attempts"] >= CODE_MAX_ATTEMPTS:
        active_codes.pop(key, None)
        fail(429, "Çok fazla hatalı deneme yapıldı. Yeni kod isteyin.", "too_many_attempts")
    expected = hashlib.sha256((entry["salt"] + code).encode("utf-8")).hexdigest()
    if not hmac.compare_digest(expected, entry["hash"]):
        entry["attempts"] += 1
        remaining = CODE_MAX_ATTEMPTS - entry["attempts"]
        fail(400, f"Kod hatalı. Kalan deneme hakkı: {remaining}.", "bad_code")
    active_codes.pop(key, None)


EMAIL_RE = re.compile(r"^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$")


def normalize_email(value: str) -> str:
    email = (value or "").strip().lower()
    if len(email) > 254 or not EMAIL_RE.match(email):
        fail(400, "Geçerli bir e-posta adresi yaz.", "bad_email")
    return email


def validate_password(password: str) -> str:
    if not isinstance(password, str) or len(password) < MIN_PASSWORD_LENGTH:
        fail(400, f"Şifren en az {MIN_PASSWORD_LENGTH} karakter olmalı.", "weak_password")
    if len(password) > 100 or len(password.encode("utf-8")) > 72:
        fail(400, "Şifre çok uzun. En fazla 72 bayt (yaklaşık 72 karakter) kullan.", "weak_password")
    return password


def validate_name(name: str) -> str:
    cleaned = re.sub(r"\s+", " ", (name or "").strip())
    if len(cleaned) < 2:
        fail(400, "Görünen adın en az 2 karakter olmalı.", "bad_name")
    if len(cleaned) > 32:
        fail(400, "Görünen adın en fazla 32 karakter olabilir.", "bad_name")
    return cleaned


def validate_code_format(code: str) -> str:
    cleaned = re.sub(r"\s+", "", code or "")
    if not re.fullmatch(r"\d{6}", cleaned):
        fail(400, "6 haneli doğrulama kodunu yaz.", "bad_code")
    return cleaned


# ---------------------------------------------------------------------------
# WebSocket bağlantı merkezi
# ---------------------------------------------------------------------------


class ConnectionHub:
    def __init__(self) -> None:
        self.sockets: dict[WebSocket, str] = {}
        self.by_user: dict[str, set[WebSocket]] = defaultdict(set)

    def online_ids(self) -> list[str]:
        return [uid for uid, sockets in self.by_user.items() if sockets and uid in users]

    def online_cards(self) -> list[dict[str, Any]]:
        cards = [public_card(users[uid]) for uid in self.online_ids()]
        return sorted(cards, key=lambda c: c["name"].casefold())

    async def send(self, ws: WebSocket, payload: dict[str, Any]) -> None:
        await ws.send_text(json.dumps(payload, ensure_ascii=False))

    async def broadcast(self, payload: dict[str, Any], exclude: WebSocket | None = None) -> None:
        dead: list[WebSocket] = []
        text = json.dumps(payload, ensure_ascii=False)
        for ws in list(self.sockets.keys()):
            if ws is exclude:
                continue
            try:
                await ws.send_text(text)
            except Exception:
                dead.append(ws)
        for ws in dead:
            await self.disconnect(ws, announce=False)

    async def broadcast_to_users(
        self, user_ids: list[str] | set[str], payload: dict[str, Any], exclude: WebSocket | None = None
    ) -> None:
        """Yalnızca belirtilen kullanıcıların açık bağlantılarına gönderir."""
        targets: set[WebSocket] = set()
        for user_id in user_ids:
            targets.update(self.by_user.get(user_id, set()))
        dead: list[WebSocket] = []
        text = json.dumps(payload, ensure_ascii=False)
        for ws in targets:
            if ws is exclude:
                continue
            try:
                await ws.send_text(text)
            except Exception:
                dead.append(ws)
        for ws in dead:
            await self.disconnect(ws, announce=False)

    async def connect(self, ws: WebSocket, user: dict[str, Any]) -> None:
        await ws.accept()
        self.sockets[ws] = user["id"]
        self.by_user[user["id"]].add(ws)
        await self.send(
            ws,
            {
                "type": "hello",
                "online": self.online_cards(),
                "server": server_status(),
            },
        )
        await self.broadcast({"type": "presence", "online": self.online_cards()})

    async def disconnect(self, ws: WebSocket, announce: bool = True) -> None:
        uid = self.sockets.pop(ws, None)
        if not uid:
            return
        bucket = self.by_user.get(uid)
        if bucket:
            bucket.discard(ws)
            if not bucket:
                self.by_user.pop(uid, None)
        if announce:
            await self.broadcast({"type": "presence", "online": self.online_cards()})


hub = ConnectionHub()


# ---------------------------------------------------------------------------
# FastAPI uygulaması
# ---------------------------------------------------------------------------


@asynccontextmanager
async def lifespan(app: FastAPI):
    log.info("%s sunucusu başladı", APP_NAME)
    log.info("Yerel adres : http://127.0.0.1:%s", PORT)
    lan = get_lan_ip()
    if lan:
        log.info("Ağ adresi   : http://%s:%s", lan, PORT)
    if EMAIL_READY:
        log.info("E-posta     : aktif (%s)", "Resend" if RESEND_API_KEY else f"SMTP · {SMTP_HOST}")
    else:
        log.warning("E-posta     : tanımlı değil — doğrulama kodları ekranda ve konsolda görünecek")
        log.warning("Gerçek e-posta için .env dosyasına SMTP bilgilerini ekle.")
    yield


app = FastAPI(title=f"{APP_NAME} API", lifespan=lifespan, docs_url=None, redoc_url=None)


@app.middleware("http")
async def security_middleware(request: Request, call_next):
    if request.method in ("POST", "PUT", "PATCH", "DELETE"):
        origin = request.headers.get("origin")
        host = request.headers.get("host", "")
        if origin:
            try:
                if urlparse(origin).netloc != host:
                    return JSONResponse(
                        {"error": "İstek kaynağı doğrulanamadı.", "code": "bad_origin"},
                        status_code=403,
                    )
            except ValueError:
                return JSONResponse(
                    {"error": "İstek kaynağı doğrulanamadı.", "code": "bad_origin"},
                    status_code=403,
                )
        try:
            length = int(request.headers.get("content-length", "0"))
        except ValueError:
            length = 0
        if length > MAX_BODY_BYTES:
            return JSONResponse(
                {"error": "Gönderilen içerik çok büyük.", "code": "body_too_large"},
                status_code=413,
            )

    response = await call_next(request)
    response.headers.setdefault("X-Content-Type-Options", "nosniff")
    response.headers.setdefault("X-Frame-Options", "DENY")
    response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
    response.headers.setdefault(
        "Permissions-Policy", "camera=(), microphone=(), geolocation=()"
    )
    response.headers.setdefault(
        "Content-Security-Policy",
        "default-src 'self'; "
        "img-src 'self' data: blob:; "
        "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
        "font-src 'self' https://fonts.gstatic.com; "
        "script-src 'self'; "
        "connect-src 'self' ws: wss:; "
        "media-src 'self' blob:; "
        "object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
    )
    if request.url.path.startswith("/media/"):
        response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
    elif request.url.path == "/" or request.url.path.startswith("/static/"):
        response.headers["Cache-Control"] = "no-cache"
    return response


@app.exception_handler(HTTPException)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
    detail = exc.detail
    if isinstance(detail, dict):
        return JSONResponse(detail, status_code=exc.status_code)
    if exc.status_code == 404:
        return JSONResponse({"error": "Sayfa bulunamadı."}, status_code=404)
    return JSONResponse({"error": str(detail)}, status_code=exc.status_code)


@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
    return JSONResponse(
        {"error": "Gönderilen bilgiler eksik veya geçersiz.", "code": "validation_error"},
        status_code=422,
    )


@app.exception_handler(Exception)
async def unexpected_exception_handler(request: Request, exc: Exception):
    log.exception("Beklenmeyen hata: %s %s", request.method, request.url.path)
    return JSONResponse(
        {"error": "Sunucuda beklenmeyen bir hata oluştu.", "code": "server_error"},
        status_code=500,
    )


def get_lan_ip() -> str | None:
    try:
        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        sock.settimeout(0.4)
        sock.connect(("8.8.8.8", 80))
        address = sock.getsockname()[0]
        sock.close()
        if address and not address.startswith("127."):
            return address
    except OSError:
        pass
    return None


# ---------------------------------------------------------------------------
# API modelleri
# ---------------------------------------------------------------------------


class SendCodeBody(BaseModel):
    email: str = ""
    purpose: str = "signup"


class VerifySignupBody(BaseModel):
    email: str = ""
    code: str = ""
    name: str = ""
    password: str = ""


class VerifyLoginBody(BaseModel):
    email: str = ""
    code: str = ""


class PasswordLoginBody(BaseModel):
    email: str = ""
    password: str = ""


class ProfileBody(BaseModel):
    name: str | None = None
    bio: str | None = None
    avatar: str | None = None


class MessageBody(BaseModel):
    text: str = ""
    images: list[str] = []


class RoomBody(BaseModel):
    name: str = ""
    emoji: str = "✨"
    description: str = ""


class StartDmBody(BaseModel):
    user_id: str = ""


class ReactionBody(BaseModel):
    emoji: str = ""


# ---------------------------------------------------------------------------
# Genel API
# ---------------------------------------------------------------------------


@app.get("/api/config")
async def api_config():
    return server_status()


@app.get("/api/health")
async def api_health():
    return {"ok": True, "app": APP_NAME}


@app.get("/api/me")
async def api_me(request: Request):
    user = require_user(request)
    return {"user": me_payload(user), "server": server_status()}


# ---------------------------------------------------------------------------
# Kimlik doğrulama
# ---------------------------------------------------------------------------


@app.post("/api/auth/send-code")
async def send_code(body: SendCodeBody, request: Request):
    email = normalize_email(body.email)
    purpose = body.purpose if body.purpose in ("signup", "login") else "signup"
    ip = client_ip(request)

    if not rate_limit(f"sendip:{ip}", 20, 3600):
        fail(429, "Çok fazla kod istendi. Lütfen bir süre sonra tekrar dene.", "rate_limited")

    previous = last_send_at.get(code_key(email, purpose), 0)
    wait = CODE_RESEND_SECONDS - (time.time() - previous)
    if wait > 0:
        fail(429, f"Yeni kod için {int(wait) + 1} saniye beklemelisin.", "cooldown")
    if not rate_limit(f"sendmail:{email}", 6, 3600):
        fail(429, "Bu e-posta için çok fazla kod istendi. Lütfen daha sonra tekrar dene.", "rate_limited")

    existing = find_user_by_email(email)
    if purpose == "signup":
        ensure_capacity(for_registration=True)
        if existing:
            fail(409, "Bu e-posta ile zaten bir hesap var. Giriş yapmayı dene.", "email_exists")
    else:
        if not existing:
            fail(404, "Bu e-posta ile kayıtlı bir hesap bulunamadı.", "no_account")

    code = f"{secrets.randbelow(900000) + 100000}"
    store_code(email, purpose, code)
    last_send_at[code_key(email, purpose)] = time.time()
    delivery = await deliver_code_email(email, code, purpose)

    return {
        "ok": True,
        "message": "Doğrulama kodu gönderildi.",
        "delivery": delivery,
        "dev_code": code if SHOW_DEV_CODE else None,
        "email": email,
        "resend_in": CODE_RESEND_SECONDS,
        "expires_in": CODE_TTL_SECONDS,
    }


@app.post("/api/auth/verify-signup")
async def verify_signup(body: VerifySignupBody, request: Request):
    email = normalize_email(body.email)
    name = validate_name(body.name)
    password = validate_password(body.password)
    code = validate_code_format(body.code)
    ensure_capacity(for_registration=True)
    if find_user_by_email(email):
        fail(409, "Bu e-posta ile zaten bir hesap var. Giriş yapmayı dene.", "email_exists")

    check_code(email, "signup", code)

    password_hash = await asyncio.to_thread(
        lambda: bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8")
    )
    user = {
        "id": uuid.uuid4().hex,
        "email": email,
        "name": name,
        "password_hash": password_hash,
        "avatar": "",
        "bio": "",
        "created_at": now_iso(),
    }
    async with store_lock:
        if find_user_by_email(email):
            fail(409, "Bu e-posta ile zaten bir hesap var. Giriş yapmayı dene.", "email_exists")
        users[user["id"]] = user
        write_json(USERS_FILE, users)

    response = JSONResponse({"ok": True, "user": me_payload(user), "server": server_status()})
    attach_session(response, request, user["id"])
    await hub.broadcast({"type": "server_stats", "server": server_status()})
    return response


@app.post("/api/auth/verify-login")
async def verify_login(body: VerifyLoginBody, request: Request):
    email = normalize_email(body.email)
    code = validate_code_format(body.code)
    user = find_user_by_email(email)
    if not user:
        fail(404, "Bu e-posta ile kayıtlı bir hesap bulunamadı.", "no_account")
    check_code(email, "login", code)

    response = JSONResponse({"ok": True, "user": me_payload(user), "server": server_status()})
    attach_session(response, request, user["id"])
    return response


@app.post("/api/auth/login")
async def password_login(body: PasswordLoginBody, request: Request):
    email = normalize_email(body.email)
    password = body.password or ""
    ip = client_ip(request)
    failure_key = f"login:{ip}:{email}"
    now = time.time()
    failures = login_failures[failure_key]
    while failures and now - failures[0] > 900:
        failures.popleft()
    if len(failures) >= 10:
        fail(429, "Çok fazla hatalı giriş denemesi. 15 dakika sonra tekrar dene.", "rate_limited")

    user = find_user_by_email(email)
    ok = False
    if user:
        try:
            ok = bcrypt.checkpw(password.encode("utf-8"), user["password_hash"].encode("utf-8"))
        except (ValueError, KeyError):
            ok = False
    if not ok:
        failures.append(now)
        fail(401, "E-posta veya şifre hatalı.", "bad_credentials")

    login_failures.pop(failure_key, None)
    response = JSONResponse({"ok": True, "user": me_payload(user), "server": server_status()})
    attach_session(response, request, user["id"])
    return response


@app.post("/api/auth/logout")
async def logout(request: Request):
    user = get_user_from_cookies(request.cookies)
    response = JSONResponse({"ok": True})
    clear_session(response, request)
    if user:
        await hub.broadcast({"type": "server_stats", "server": server_status()})
    return response


# ---------------------------------------------------------------------------
# Profil
# ---------------------------------------------------------------------------


@app.patch("/api/me")
async def update_profile(body: ProfileBody, request: Request):
    user = require_user(request)
    changed: list[str] = []

    if body.name is not None:
        user["name"] = validate_name(body.name)
        changed.append("name")
    if body.bio is not None:
        bio = body.bio.strip()
        if len(bio) > 140:
            fail(400, "Hakkında yazısı en fazla 140 karakter olabilir.", "bio_too_long")
        user["bio"] = bio
        changed.append("bio")

    old_avatar = user.get("avatar", "")
    if body.avatar is not None:
        if body.avatar.strip() == "":
            user["avatar"] = ""
            changed.append("avatar")
            remove_upload_file(old_avatar)
        else:
            ensure_capacity()
            image = decode_image(body.avatar, int(MAX_IMAGE_MB * 1024 * 1024))
            info = save_image(image, "avatars", 512, square=True)
            try:
                ensure_capacity()
            except HTTPException:
                remove_upload_file(info["path"])
                raise
            user["avatar"] = info["path"]
            changed.append("avatar")
            remove_upload_file(old_avatar)

    if not changed:
        fail(400, "Değiştirilecek bir alan gönderilmedi.", "nothing_to_update")

    async with store_lock:
        write_json(USERS_FILE, users)
    await hub.broadcast(
        {
            "type": "user_updated",
            "user": public_card(user),
            "server": server_status(),
        }
    )
    return {"ok": True, "user": me_payload(user), "server": server_status()}


# ---------------------------------------------------------------------------
# Kanallar ve mesajlar
# ---------------------------------------------------------------------------


def serialize_room(room: dict[str, Any]) -> dict[str, Any]:
    room_messages = messages_by_room.get(room["id"], [])
    last = room_messages[-1] if room_messages else None
    return {
        **room,
        "message_count": len(room_messages),
        "last_message_at": last["created_at"] if last else None,
    }


def require_room(room_id: str) -> dict[str, Any]:
    room = rooms.get(room_id)
    if not room:
        fail(404, "Kanal bulunamadı.", "room_not_found")
    return room


def serialize_message(message: dict[str, Any]) -> dict[str, Any]:
    payload = dict(message)
    payload["author"] = (
        dict(SYSTEM_USER)
        if message.get("user_id") == SYSTEM_USER_ID
        else public_card(users.get(message.get("user_id", "")))
    )
    payload.setdefault("reactions", {})
    payload.setdefault("images", [])
    return payload


@app.get("/api/rooms")
async def list_rooms(request: Request):
    require_user(request)
    ordered = sorted(
        rooms.values(),
        key=lambda r: (
            r.get("created_at", ""),
        ),
    )
    return {"rooms": [serialize_room(room) for room in ordered]}


@app.get("/api/users")
async def list_users(request: Request):
    user = require_user(request)
    online_ids = set(hub.online_ids())
    cards: list[dict[str, Any]] = []
    for candidate in users.values():
        if candidate["id"] == user["id"]:
            continue
        card = public_card(candidate)
        card["online"] = candidate["id"] in online_ids
        existing = dms.get(dm_id_for(user["id"], candidate["id"]))
        if existing:
            card["dm_id"] = existing["id"]
        cards.append(card)
    cards.sort(key=lambda card: (not card.get("online"), card.get("name", "").casefold()))
    return {"users": cards}


@app.post("/api/rooms")
async def create_room(body: RoomBody, request: Request):
    user = require_user(request)
    ensure_capacity()
    name = re.sub(r"\s+", " ", body.name.strip())
    if len(name) < 2:
        fail(400, "Kanal adı en az 2 karakter olmalı.", "bad_room_name")
    if len(name) > MAX_ROOM_NAME:
        fail(400, f"Kanal adı en fazla {MAX_ROOM_NAME} karakter olabilir.", "bad_room_name")
    if len(rooms) >= 40:
        fail(507, "Üzgünüz, sunucu doldu. Daha fazla kanal açılamıyor.", "server_full")
    description = re.sub(r"\s+", " ", body.description.strip())[:100]
    allowed_emoji = body.emoji.strip()[:8] or "✨"

    transliteration = str.maketrans("çğıöşüÇĞİÖŞÜ", "cgiosuCGIOSU")
    slug = re.sub(r"[^a-z0-9]+", "-", name.translate(transliteration).casefold()).strip("-")
    if not slug:
        slug = "kanal"
    base_slug = slug[:24]
    slug = base_slug
    counter = 2
    while slug in rooms:
        slug = f"{base_slug}-{counter}"
        counter += 1

    room = {
        "id": slug,
        "name": name,
        "emoji": allowed_emoji,
        "description": description,
        "created_by": user["id"],
        "created_at": now_iso(),
    }
    async with store_lock:
        rooms[slug] = room
        messages_by_room[slug] = []
        write_json(ROOMS_FILE, rooms)
        write_json(MESSAGE_DIR / f"{slug}.json", [])

    await hub.broadcast({"type": "room_created", "room": serialize_room(room)})
    return {"ok": True, "room": serialize_room(room)}


@app.get("/api/rooms/{room_id}/messages")
async def list_messages(
    room_id: str,
    request: Request,
    before_id: str | None = None,
    limit: int = 80,
):
    require_user(request)
    require_room(room_id)
    limit = max(10, min(100, limit))
    items = messages_by_room.get(room_id, [])

    if before_id:
        index = next((i for i, m in enumerate(items) if m["id"] == before_id), len(items))
        start = max(0, index - limit)
        sliced = items[start:index]
        has_more = start > 0
    else:
        start = max(0, len(items) - limit)
        sliced = items[start:]
        has_more = start > 0

    return {
        "messages": [serialize_message(message) for message in sliced],
        "has_more": has_more,
        "room": serialize_room(rooms[room_id]),
    }


def clean_text(value: str) -> str:
    text = (value or "").replace("\x00", "").strip()
    if len(text) > MAX_MESSAGE_CHARS:
        fail(400, f"Mesaj en fazla {MAX_MESSAGE_CHARS} karakter olabilir.", "message_too_long")
    return text


@app.post("/api/rooms/{room_id}/messages")
async def send_message(room_id: str, body: MessageBody, request: Request):
    user = require_user(request)
    require_room(room_id)
    text = clean_text(body.text)
    if not body.images and not text:
        fail(400, "Boş bir mesaj gönderilemez.", "empty_message")
    if len(body.images) > MAX_IMAGES_PER_MESSAGE:
        fail(400, f"Bir mesajda en fazla {MAX_IMAGES_PER_MESSAGE} fotoğraf paylaşabilirsin.", "too_many_images")

    ensure_capacity()
    saved = save_chat_images(body.images)

    message = {
        "id": uuid.uuid4().hex,
        "room_id": room_id,
        "user_id": user["id"],
        "text": text,
        "images": saved,
        "created_at": now_iso(),
        "reactions": {},
    }
    async with store_lock:
        messages_by_room.setdefault(room_id, []).append(message)
        write_json(MESSAGE_DIR / f"{room_id}.json", messages_by_room[room_id])

    payload = serialize_message(message)
    await hub.broadcast({"type": "message", "message": payload, "room": serialize_room(rooms[room_id])})
    return {"ok": True, "message": payload, "server": server_status()}


@app.post("/api/rooms/{room_id}/messages/{message_id}/react")
async def react_to_message(room_id: str, message_id: str, body: ReactionBody, request: Request):
    user = require_user(request)
    require_room(room_id)
    allowed = {"❤️", "😂", "🔥", "👍", "✨", "💜", "😮"}
    emoji = body.emoji.strip()
    if emoji not in allowed:
        fail(400, "Bu tepki desteklenmiyor.", "bad_reaction")

    message = next((m for m in messages_by_room.get(room_id, []) if m["id"] == message_id), None)
    if not message:
        fail(404, "Mesaj bulunamadı.", "message_not_found")

    reactions = message.setdefault("reactions", {})
    current = set(reactions.get(emoji, []))
    if user["id"] in current:
        current.remove(user["id"])
    else:
        current.add(user["id"])
    if current:
        reactions[emoji] = sorted(current)
    else:
        reactions.pop(emoji, None)

    async with store_lock:
        write_json(MESSAGE_DIR / f"{room_id}.json", messages_by_room[room_id])
    payload = serialize_message(message)
    await hub.broadcast({"type": "message_updated", "message": payload})
    return {"ok": True, "message": payload}


@app.delete("/api/rooms/{room_id}/messages/{message_id}")
async def delete_message(room_id: str, message_id: str, request: Request):
    user = require_user(request)
    require_room(room_id)
    items = messages_by_room.get(room_id, [])
    message = next((m for m in items if m["id"] == message_id), None)
    if not message:
        fail(404, "Mesaj bulunamadı.", "message_not_found")
    if message.get("user_id") != user["id"]:
        fail(403, "Yalnızca kendi mesajlarını silebilirsin.", "forbidden")

    async with store_lock:
        messages_by_room[room_id] = [m for m in items if m["id"] != message_id]
        write_json(MESSAGE_DIR / f"{room_id}.json", messages_by_room[room_id])
    for image in message.get("images", []):
        remove_upload_file(image.get("path", ""))
    await hub.broadcast(
        {
            "type": "message_deleted",
            "room_id": room_id,
            "message_id": message_id,
            "server": server_status(),
        }
    )
    return {"ok": True}


# ---------------------------------------------------------------------------
# Özel sohbetler (DM)
# ---------------------------------------------------------------------------


@app.get("/api/dms")
async def list_dms(request: Request):
    user = require_user(request)
    await ensure_dm(user["id"], None)
    items = [
        serialize_dm(dm, user["id"])
        for dm in dms.values()
        if user["id"] in dm.get("participants", [])
    ]
    items.sort(
        key=lambda item: (
            item.get("kind") == "self",
            item.get("last_message_at", ""),
            item.get("title", ""),
        ),
        reverse=True,
    )
    return {"dms": items}


@app.post("/api/dms")
async def start_dm(body: StartDmBody, request: Request):
    user = require_user(request)
    peer_id = (body.user_id or "").strip() or None
    dm = await ensure_dm(user["id"], peer_id)
    return {"ok": True, "dm": serialize_dm(dm, user["id"])}


@app.get("/api/dms/{dm_id}/messages")
async def list_dm_messages(
    dm_id: str,
    request: Request,
    before_id: str | None = None,
    limit: int = 80,
):
    user = require_user(request)
    require_dm(dm_id, user)
    limit = max(10, min(100, limit))
    items = dm_messages.get(dm_id, [])

    if before_id:
        index = next((i for i, m in enumerate(items) if m["id"] == before_id), len(items))
        start = max(0, index - limit)
        sliced = items[start:index]
        has_more = start > 0
    else:
        start = max(0, len(items) - limit)
        sliced = items[start:]
        has_more = start > 0

    await mark_dm_read(dm_id, user["id"])
    dm = require_dm(dm_id, user)
    return {
        "messages": [serialize_dm_message(message) for message in sliced],
        "has_more": has_more,
        "dm": serialize_dm(dm, user["id"]),
    }


@app.post("/api/dms/{dm_id}/messages")
async def send_dm_message(dm_id: str, body: MessageBody, request: Request):
    user = require_user(request)
    dm = require_dm(dm_id, user)
    text = clean_text(body.text)
    if not body.images and not text:
        fail(400, "Boş bir mesaj gönderilemez.", "empty_message")
    if len(body.images) > MAX_IMAGES_PER_MESSAGE:
        fail(400, f"Bir mesajda en fazla {MAX_IMAGES_PER_MESSAGE} fotoğraf paylaşabilirsin.", "too_many_images")

    ensure_capacity()
    saved = save_chat_images(body.images)
    message = {
        "id": uuid.uuid4().hex,
        "dm_id": dm_id,
        "user_id": user["id"],
        "text": text,
        "images": saved,
        "created_at": now_iso(),
        "reactions": {},
    }
    async with store_lock:
        dm_messages.setdefault(dm_id, []).append(message)
        write_json(DM_MESSAGE_DIR / f"{dm_id}.json", dm_messages[dm_id])

    payload = serialize_dm_message(message)
    participants = list(dm.get("participants", []))
    await hub.broadcast_to_users(
        participants,
        {
            "type": "dm_message",
            "message": payload,
            "dm": {
                "id": dm_id,
                "kind": dm.get("kind", "direct"),
                "participants": participants,
            },
            "server": server_status(),
        },
    )
    return {
        "ok": True,
        "message": payload,
        "dm": serialize_dm(dm, user["id"]),
        "server": server_status(),
    }


@app.post("/api/dms/{dm_id}/messages/{message_id}/react")
async def react_to_dm_message(
    dm_id: str,
    message_id: str,
    body: ReactionBody,
    request: Request,
):
    user = require_user(request)
    dm = require_dm(dm_id, user)
    allowed = {"❤️", "😂", "🔥", "👍", "✨", "💜", "😮"}
    emoji = body.emoji.strip()
    if emoji not in allowed:
        fail(400, "Bu tepki desteklenmiyor.", "bad_reaction")

    message = next((m for m in dm_messages.get(dm_id, []) if m["id"] == message_id), None)
    if not message:
        fail(404, "Mesaj bulunamadı.", "message_not_found")

    reactions = message.setdefault("reactions", {})
    current = set(reactions.get(emoji, []))
    if user["id"] in current:
        current.remove(user["id"])
    else:
        current.add(user["id"])
    if current:
        reactions[emoji] = sorted(current)
    else:
        reactions.pop(emoji, None)

    async with store_lock:
        write_json(DM_MESSAGE_DIR / f"{dm_id}.json", dm_messages[dm_id])
    payload = serialize_dm_message(message)
    await hub.broadcast_to_users(
        dm.get("participants", []),
        {"type": "dm_message_updated", "message": payload},
    )
    return {"ok": True, "message": payload}


@app.delete("/api/dms/{dm_id}/messages/{message_id}")
async def delete_dm_message(dm_id: str, message_id: str, request: Request):
    user = require_user(request)
    dm = require_dm(dm_id, user)
    items = dm_messages.get(dm_id, [])
    message = next((m for m in items if m["id"] == message_id), None)
    if not message:
        fail(404, "Mesaj bulunamadı.", "message_not_found")
    if message.get("user_id") != user["id"]:
        fail(403, "Yalnızca kendi mesajlarını silebilirsin.", "forbidden")

    async with store_lock:
        dm_messages[dm_id] = [m for m in items if m["id"] != message_id]
        write_json(DM_MESSAGE_DIR / f"{dm_id}.json", dm_messages[dm_id])
    for image in message.get("images", []):
        remove_upload_file(image.get("path", ""))
    await hub.broadcast_to_users(
        dm.get("participants", []),
        {
            "type": "dm_message_deleted",
            "dm_id": dm_id,
            "message_id": message_id,
            "server": server_status(),
        },
    )
    return {"ok": True}


@app.post("/api/dms/{dm_id}/read")
async def read_dm(dm_id: str, request: Request):
    user = require_user(request)
    dm = require_dm(dm_id, user)
    await mark_dm_read(dm_id, user["id"])
    return {"ok": True, "dm": serialize_dm(dm, user["id"])}


# ---------------------------------------------------------------------------
# WebSocket
# ---------------------------------------------------------------------------


@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
    origin = ws.headers.get("origin")
    host = ws.headers.get("host", "")
    if origin:
        try:
            if urlparse(origin).netloc != host:
                await ws.close(code=1008)
                return
        except ValueError:
            await ws.close(code=1008)
            return
    user = get_user_from_cookies(ws.cookies)
    if not user:
        await ws.close(code=4401)
        return

    await hub.connect(ws, user)
    try:
        while True:
            raw = await ws.receive_text()
            if len(raw) > 8000:
                continue
            try:
                event = json.loads(raw)
            except json.JSONDecodeError:
                continue
            event_type = event.get("type")
            if event_type == "ping":
                await hub.send(ws, {"type": "pong", "t": time.time()})
            elif event_type == "typing":
                dm_id = str(event.get("dm_id", ""))
                if dm_id:
                    dm = dms.get(dm_id)
                    if dm and user["id"] in dm.get("participants", []):
                        await hub.broadcast_to_users(
                            [uid for uid in dm.get("participants", []) if uid != user["id"]],
                            {
                                "type": "typing",
                                "dm_id": dm_id,
                                "user": public_card(user),
                            },
                        )
                else:
                    room_id = str(event.get("room_id", ""))
                    if room_id in rooms:
                        await hub.broadcast(
                            {
                                "type": "typing",
                                "room_id": room_id,
                                "user": public_card(user),
                            },
                            exclude=ws,
                        )
    except WebSocketDisconnect:
        pass
    except Exception:
        log.exception("WebSocket bağlantısı kapandı")
    finally:
        await hub.disconnect(ws)


# ---------------------------------------------------------------------------
# Statik dosyalar
# ---------------------------------------------------------------------------


@app.get("/")
async def index():
    return FileResponse(STATIC_DIR / "index.html", headers={"Cache-Control": "no-cache"})


@app.get("/manifest.webmanifest")
async def manifest():
    return FileResponse(STATIC_DIR / "manifest.webmanifest")


@app.get("/favicon.ico")
async def favicon():
    return FileResponse(STATIC_DIR / "img" / "logo.svg", media_type="image/svg+xml")


app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app.mount("/media", StaticFiles(directory=str(UPLOAD_DIR)), name="media")


# ---------------------------------------------------------------------------
# Çalıştırma
# ---------------------------------------------------------------------------


if __name__ == "__main__":
    try:
        import uvicorn
    except ImportError:
        print("uvicorn bulunamadı. Kurulum: python -m pip install -r requirements.txt")
        raise SystemExit(1)
    uvicorn.run(
        app,
        host=HOST,
        port=PORT,
        log_level="info",
        access_log=False,
        ws_ping_interval=25,
        ws_ping_timeout=60,
    )
