# -*- coding: utf-8 -*-
"""
OK ITHAKI Relay Server — Clean Edition v2.0.0
==============================================
Ενοποιημένος server από όλα τα v1.x patches.
Ίδια λογική, καθαρή δομή, χωρίς επαναλήψεις.

Αρχιτεκτονική:
  - db.py      → SQLite helpers (αυτό το αρχείο τα έχει όλα ως functions)
  - settings   → ithaki_settings.py (αμετάβλητο)
  - argos      → argos_offline_service.py (αμετάβλητο)

Εκκίνηση:
  uvicorn relay_server_clean:app --host 0.0.0.0 --port 8010 --reload

Μεταβλητές περιβάλλοντος:
  ITHAKI_ADMIN_KEY          (default: local-beta-admin)
  ITHAKI_PUBLIC_API_URL     (default: http://127.0.0.1:8011)
  ITHAKI_PRODUCTION_API_URL (default: https://api.ok-ithaki.gr)
  ITHAKI_PUBLIC_APP_TOKEN   (default: OKITHAKI_PUBLIC_BETA_TOKEN_CHANGE_ON_SERVER)
  ITHAKI_PUBLIC_DISPLAY_NAME
  ITHAKI_RELAY_TTL_SECONDS  (default: 259200 = 72h)
  ITHAKI_PROVIDER_MODE      (default: cloudflare_tunnel)
  ITHAKI_ENABLE_INTERNAL_SEED_THREAD (default: 0)
  ITHAKI_SEED_CRON_MIN_INTERVAL_SECONDS (default: 300)
"""
from __future__ import annotations

# ITHAKI V24786: Hide noisy FastAPI DeprecationWarning lines.
# These warnings are not crashes; they confused startup on Windows consoles.
import warnings as _ithaki_warnings
_ithaki_warnings.filterwarnings("ignore", category=DeprecationWarning, module=r"fastapi\..*")
_ithaki_warnings.filterwarnings("ignore", message=r".*on_event is deprecated.*", category=DeprecationWarning)

import hashlib
import difflib
import unicodedata
import json
import logging
import os
import re
import secrets
import shutil
import sqlite3
import subprocess
import time
import datetime
import threading
from urllib.parse import quote
from pathlib import Path
from typing import Any

import asyncio  # V24821 websocket realtime
from fastapi import FastAPI, HTTPException, Query, Request, Response, UploadFile, File, Form, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse, JSONResponse
from pydantic import BaseModel, Field

# ──────────────────────────────────────────────
# Settings (external module unchanged)
# ──────────────────────────────────────────────
from ithaki_settings import get_settings, public_bootstrap_config as _settings_bootstrap, save_runtime_config, RUNTIME_CONFIG_FILE

APP_NAME = "OK ITHAKI FUTURE INFRA FOUNDATION"
VERSION  = "2.4.9.397-professional-preview-security-unified-wall"
ROOT     = Path(__file__).resolve().parent
DATA     = ROOT / "data"
FRONTEND = ROOT / "frontend"
DATA.mkdir(exist_ok=True)
DB_PATH  = DATA / "ithaki_full_relay_pro.sqlite3"

logging.basicConfig(
    level=os.getenv("ITHAKI_LOG_LEVEL", "INFO"),
    format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
LOG = logging.getLogger("ok_ithaki")

TTL_SECONDS = int(os.getenv("ITHAKI_RELAY_TTL_SECONDS", str(72 * 3600)))

def ttl_seconds() -> int:
    """Dynamic server retention. Default 72 hours; admin can change message_ttl_hours in relay_runtime_config.json."""
    try:
        cfgp = ROOT / "relay_runtime_config.json"
        if cfgp.exists():
            cfg = json.loads(cfgp.read_text(encoding="utf-8"))
            hours = float(cfg.get("message_ttl_hours", 72))
            # safe bounds: 1 hour to 30 days
            hours = max(1.0, min(hours, 24.0 * 30.0))
            return int(hours * 3600)
    except Exception:
        pass
    return int(os.getenv("ITHAKI_RELAY_TTL_SECONDS", str(72 * 3600)))

_SEED_THREAD_STARTED = False
_AUTO_HOST_THREAD_STARTED = False
_ITHAKI_CALL_CREATE_LOCK_V249259 = threading.Lock()
_ITHAKI_CALL_CLEAN_LOCK_V249260 = threading.Lock()

# ──────────────────────────────────────────────
# DB helpers
# ──────────────────────────────────────────────
# v2.3.7 scalability: thread-local SQLite connection proxy.
# Each request/background thread gets its own SQLite connection while keeping
# the old helper/API style (_conn.execute/commit/rollback) untouched.
_local = threading.local()

def _apply_conn_pragmas(conn: sqlite3.Connection) -> sqlite3.Connection:
    conn.row_factory = sqlite3.Row
    try:
        conn.execute("PRAGMA journal_mode=WAL")
        conn.execute("PRAGMA synchronous=NORMAL")
        conn.execute("PRAGMA temp_store=MEMORY")
        conn.execute("PRAGMA busy_timeout=8000")
    except Exception:
        LOG.exception("SQLite PRAGMA performance setup failed")
    return conn

def get_conn() -> sqlite3.Connection:
    conn = getattr(_local, "conn", None)
    if conn is None:
        conn = sqlite3.connect(str(DB_PATH), check_same_thread=True, timeout=10)
        conn = _apply_conn_pragmas(conn)
        _local.conn = conn
    return conn

class _SQLiteThreadLocalProxy:
    def execute(self, *a, **kw):
        return get_conn().execute(*a, **kw)
    def commit(self):
        return get_conn().commit()
    def rollback(self):
        return get_conn().rollback()
    def cursor(self, *a, **kw):
        return get_conn().cursor(*a, **kw)
    def close(self):
        try:
            return get_conn().close()
        finally:
            try:
                _local.conn = None
            except Exception:
                pass

_conn = _SQLiteThreadLocalProxy()


def now() -> float:
    return time.time()


def now_ms() -> int:
    return int(time.time() * 1000)


def nid(prefix: str) -> str:
    return f"{prefix}_{secrets.token_hex(8)}"


def execute(sql: str, params: tuple = ()):
    cur = _conn.execute(sql, params)
    _conn.commit()
    return cur


def one(sql: str, params: tuple = ()):
    return _conn.execute(sql, params).fetchone()


def rows(sql: str, params: tuple = ()):
    return _conn.execute(sql, params).fetchall()


def table_cols(table: str) -> set[str]:
    try:
        return {r["name"] for r in rows(f"PRAGMA table_info({table})")}
    except Exception:
        return set()


def add_col(table: str, name: str, decl: str):
    if name not in table_cols(table):
        execute(f"ALTER TABLE {table} ADD COLUMN {name} {decl}")


# ──────────────────────────────────────────────
# App state (cross-worker cooldowns / locks)
# ──────────────────────────────────────────────
def _app_state_ensure():
    execute("""
    CREATE TABLE IF NOT EXISTS app_state(
        key TEXT PRIMARY KEY,
        value_text TEXT NOT NULL DEFAULT '',
        value_int  INTEGER NOT NULL DEFAULT 0,
        updated_at_ms INTEGER NOT NULL DEFAULT 0
    )""")


def app_state_get_int(key: str, default: int = 0) -> int:
    _app_state_ensure()
    r = one("SELECT value_int FROM app_state WHERE key=?", (key,))
    return int(r["value_int"]) if r else default


def app_state_set_int(key: str, value: int, text: str = ""):
    _app_state_ensure()
    execute("""
    INSERT INTO app_state(key,value_text,value_int,updated_at_ms) VALUES(?,?,?,?)
    ON CONFLICT(key) DO UPDATE SET value_text=excluded.value_text,
      value_int=excluded.value_int, updated_at_ms=excluded.updated_at_ms
    """, (key, text, int(value), now_ms()))


def app_state_claim_cooldown(key: str, cooldown_ms: int) -> tuple[bool, int]:
    """Atomic cooldown using SQLite BEGIN IMMEDIATE. Returns (ok, seconds_left)."""
    _app_state_ensure()
    t = now_ms()
    try:
        _conn.execute("BEGIN IMMEDIATE")
        r = _conn.execute("SELECT value_int FROM app_state WHERE key=?", (key,)).fetchone()
        last = int(r["value_int"]) if r else 0
        if last and t - last < cooldown_ms:
            _conn.rollback()
            return False, int((cooldown_ms - (t - last) + 999) // 1000)
        _conn.execute("""
        INSERT INTO app_state(key,value_text,value_int,updated_at_ms) VALUES(?,?,?,?)
        ON CONFLICT(key) DO UPDATE SET value_int=excluded.value_int, updated_at_ms=excluded.updated_at_ms
        """, (key, "cooldown", t, t))
        _conn.commit()
        return True, 0
    except Exception:
        try:
            _conn.rollback()
        except Exception:
            pass
        LOG.exception("Cooldown claim failed key=%s", key)
        raise


def app_state_acquire_lock(key: str, ttl_ms: int) -> bool:
    """Atomic lock with TTL. Returns True if acquired."""
    _app_state_ensure()
    t = now_ms()
    until = t + ttl_ms
    try:
        _conn.execute("BEGIN IMMEDIATE")
        r = _conn.execute("SELECT value_int FROM app_state WHERE key=?", (key,)).fetchone()
        if r and int(r["value_int"]) > t:
            _conn.rollback()
            return False
        _conn.execute("""
        INSERT INTO app_state(key,value_text,value_int,updated_at_ms) VALUES(?,?,?,?)
        ON CONFLICT(key) DO UPDATE SET value_text=excluded.value_text,
          value_int=excluded.value_int, updated_at_ms=excluded.updated_at_ms
        """, (key, "lock_until_ms", until, t))
        _conn.commit()
        return True
    except Exception:
        try:
            _conn.rollback()
        except Exception:
            pass
        LOG.exception("Lock acquire failed key=%s", key)
        raise


def app_state_release_lock(key: str):
    try:
        app_state_set_int(key, 0, "released")
    except Exception:
        LOG.exception("Lock release failed key=%s", key)


# ──────────────────────────────────────────────
# Core DB schema
# ──────────────────────────────────────────────
def init_db():
    execute("""
    CREATE TABLE IF NOT EXISTS messages(
        message_id   TEXT PRIMARY KEY,
        room_id      TEXT NOT NULL,
        channel      TEXT NOT NULL DEFAULT 'main',
        sender_id    TEXT NOT NULL,
        nickname     TEXT NOT NULL DEFAULT '',
        language     TEXT NOT NULL DEFAULT 'el',
        text         TEXT NOT NULL DEFAULT '',
        is_encrypted INTEGER NOT NULL DEFAULT 0,
        iv           TEXT NOT NULL DEFAULT '',
        cipher_text  TEXT NOT NULL DEFAULT '',
        created_at   REAL NOT NULL
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS message_reads(
        room_id    TEXT NOT NULL,
        channel    TEXT NOT NULL,
        reader_id  TEXT NOT NULL,
        read_until REAL NOT NULL DEFAULT 0,
        updated_at REAL NOT NULL,
        PRIMARY KEY(room_id, channel, reader_id)
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS message_typing(
        room_id    TEXT NOT NULL,
        channel    TEXT NOT NULL,
        device_id  TEXT NOT NULL,
        nickname   TEXT NOT NULL DEFAULT '',
        is_typing  INTEGER NOT NULL DEFAULT 0,
        updated_at REAL NOT NULL,
        PRIMARY KEY(room_id, channel, device_id)
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS people(
        room_id   TEXT NOT NULL,
        device_id TEXT NOT NULL,
        nickname  TEXT NOT NULL DEFAULT '',
        language  TEXT NOT NULL DEFAULT 'el',
        mood      TEXT NOT NULL DEFAULT '',
        avatar    TEXT NOT NULL DEFAULT '',
        last_seen REAL NOT NULL,
        PRIMARY KEY(room_id, device_id)
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS mood_checkins(
        checkin_id TEXT PRIMARY KEY,
        room_id    TEXT NOT NULL,
        device_id  TEXT NOT NULL DEFAULT '',
        nickname   TEXT NOT NULL DEFAULT '',
        mood       TEXT NOT NULL DEFAULT '',
        anonymous  INTEGER NOT NULL DEFAULT 1,
        created_at REAL NOT NULL
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS flow_posts(
        post_id      TEXT PRIMARY KEY,
        room_id      TEXT NOT NULL,
        sender_id    TEXT NOT NULL,
        nickname     TEXT NOT NULL DEFAULT '',
        language     TEXT NOT NULL DEFAULT 'el',
        text         TEXT NOT NULL DEFAULT '',
        is_encrypted INTEGER NOT NULL DEFAULT 0,
        iv           TEXT NOT NULL DEFAULT '',
        cipher_text  TEXT NOT NULL DEFAULT '',
        post_kind    TEXT NOT NULL DEFAULT 'text',
        anonymous    INTEGER NOT NULL DEFAULT 1,
        visibility   TEXT NOT NULL DEFAULT 'public',
        created_at   REAL NOT NULL
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS rooms(
        subroom_id  TEXT PRIMARY KEY,
        room_id     TEXT NOT NULL,
        parent_id   TEXT,
        owner_id    TEXT NOT NULL DEFAULT '',
        title       TEXT NOT NULL,
        description TEXT NOT NULL DEFAULT '',
        category    TEXT NOT NULL DEFAULT '',
        created_at  REAL NOT NULL
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS reactions(
        target_id TEXT NOT NULL,
        device_id TEXT NOT NULL,
        emoji     TEXT NOT NULL,
        nickname  TEXT NOT NULL DEFAULT '',
        created_at REAL NOT NULL,
        PRIMARY KEY(target_id, device_id, emoji)
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS polls(
        poll_id      TEXT PRIMARY KEY,
        room_id      TEXT NOT NULL,
        community_id TEXT NOT NULL DEFAULT '',
        creator_id   TEXT NOT NULL DEFAULT '',
        nickname     TEXT NOT NULL DEFAULT '',
        question     TEXT NOT NULL DEFAULT '',
        options_json TEXT NOT NULL DEFAULT '[]',
        anonymous    INTEGER NOT NULL DEFAULT 1,
        allow_stats  INTEGER NOT NULL DEFAULT 1,
        created_at   REAL NOT NULL,
        closed       INTEGER NOT NULL DEFAULT 0
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS poll_votes(
        poll_id      TEXT NOT NULL,
        room_id      TEXT NOT NULL,
        device_id    TEXT NOT NULL,
        option_index INTEGER NOT NULL DEFAULT 0,
        created_at   REAL NOT NULL,
        PRIMARY KEY(poll_id, device_id)
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS feed_control_settings_v249217(
        room_id TEXT PRIMARY KEY,
        min_videos_before_cards INTEGER NOT NULL DEFAULT 5,
        card_every_videos INTEGER NOT NULL DEFAULT 5,
        card_interval_minutes INTEGER NOT NULL DEFAULT 10,
        card_max_per_load INTEGER NOT NULL DEFAULT 1,
        loop_feed_enabled INTEGER NOT NULL DEFAULT 1,
        admin_priority_enabled INTEGER NOT NULL DEFAULT 1,
        updated_at REAL NOT NULL DEFAULT 0
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS feed_priority_v249217(
        room_id TEXT NOT NULL,
        post_id TEXT NOT NULL,
        priority_order INTEGER NOT NULL DEFAULT 0,
        enabled INTEGER NOT NULL DEFAULT 1,
        home_first INTEGER NOT NULL DEFAULT 1,
        note TEXT NOT NULL DEFAULT '',
        updated_at REAL NOT NULL DEFAULT 0,
        PRIMARY KEY(room_id, post_id)
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS flow_deleted_archive_v249217(
        archive_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL,
        post_id TEXT NOT NULL,
        sender_id TEXT NOT NULL DEFAULT '',
        nickname TEXT NOT NULL DEFAULT '',
        post_json TEXT NOT NULL DEFAULT '{}',
        comments_json TEXT NOT NULL DEFAULT '[]',
        reactions_json TEXT NOT NULL DEFAULT '[]',
        deleted_by TEXT NOT NULL DEFAULT 'server_admin',
        reason TEXT NOT NULL DEFAULT '',
        deleted_at REAL NOT NULL,
        restored_at REAL NOT NULL DEFAULT 0
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS member_deleted_archive_v249217(
        archive_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL,
        device_id TEXT NOT NULL,
        nickname TEXT NOT NULL DEFAULT '',
        person_json TEXT NOT NULL DEFAULT '{}',
        posts_json TEXT NOT NULL DEFAULT '[]',
        comments_json TEXT NOT NULL DEFAULT '[]',
        deleted_by TEXT NOT NULL DEFAULT 'server_admin',
        reason TEXT NOT NULL DEFAULT '',
        deleted_at REAL NOT NULL,
        restored_at REAL NOT NULL DEFAULT 0
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS reports(
        report_id   TEXT PRIMARY KEY,
        room_id     TEXT NOT NULL,
        reporter_id TEXT NOT NULL DEFAULT '',
        target_type TEXT NOT NULL DEFAULT '',
        target_id   TEXT NOT NULL DEFAULT '',
        reason      TEXT NOT NULL DEFAULT '',
        created_at  REAL NOT NULL
    )""")
    # Indexes
    for sql in [
        "CREATE INDEX IF NOT EXISTS idx_msg_room_ch_time  ON messages(room_id, channel, created_at)",
        "CREATE INDEX IF NOT EXISTS idx_flow_room_time    ON flow_posts(room_id, created_at)",
        "CREATE INDEX IF NOT EXISTS idx_mood_room_time    ON mood_checkins(room_id, created_at)",
        "CREATE INDEX IF NOT EXISTS idx_rooms_room_parent ON rooms(room_id, parent_id)",
        "CREATE INDEX IF NOT EXISTS idx_react_target      ON reactions(target_id)",
        "CREATE INDEX IF NOT EXISTS idx_polls_room_time    ON polls(room_id, created_at)",
        "CREATE INDEX IF NOT EXISTS idx_polls_comm_time    ON polls(room_id, community_id, created_at)",
        "CREATE INDEX IF NOT EXISTS idx_poll_votes_poll    ON poll_votes(poll_id)",
        "CREATE INDEX IF NOT EXISTS idx_feed_prio_room_ord ON feed_priority_v249217(room_id, enabled, priority_order)",
        "CREATE INDEX IF NOT EXISTS idx_flow_arch_room_post ON flow_deleted_archive_v249217(room_id, post_id)",
        "CREATE INDEX IF NOT EXISTS idx_member_arch_room_dev ON member_deleted_archive_v249217(room_id, device_id)",
    ]:
        execute(sql)


def purge_old():
    cutoff = now() - ttl_seconds()
    execute("DELETE FROM messages   WHERE created_at < ?", (cutoff,))
    execute("DELETE FROM flow_posts WHERE created_at < ?", (cutoff,))
    try:
        execute("DELETE FROM media_metadata WHERE created_at < ?", (cutoff,))
    except Exception:
        pass
    # V249237: NEVER delete people/profile rows just because a device is offline.
    # last_seen is presence metadata only. Offline status is computed with
    # last_seen < now()-180, while the member row remains for friends, avatars,
    # names, settings, message routing and accepted-contact references.
    try:
        execute("DELETE FROM mood_checkins WHERE created_at < ?", (now() - 86400*30,))
    except Exception:
        pass




# ──────────────────────────────────────────────
# v2.3.7 Background maintenance
# ──────────────────────────────────────────────
_MAINTENANCE_THREAD_STARTED = False

def _run_periodic_maintenance() -> None:
    """Run TTL cleanup outside request path and checkpoint WAL periodically.
    With multiple Linux workers, app_state cooldown prevents every worker from
    doing the heavy purge at the same time.
    """
    counter = 0
    while True:
        try:
            should_run = True
            try:
                should_run, _seconds_left = app_state_claim_cooldown("maintenance_purge_old_last_ms", 55_000)
            except Exception:
                should_run = True
            if should_run:
                purge_old()
            counter += 1
            if counter % 10 == 0:
                try:
                    get_conn().execute("PRAGMA wal_checkpoint(PASSIVE)")
                    get_conn().commit()
                except Exception:
                    LOG.exception("WAL checkpoint failed")
        except Exception:
            LOG.exception("Background maintenance failed")
        time.sleep(60)

def start_background_maintenance() -> None:
    global _MAINTENANCE_THREAD_STARTED
    if _MAINTENANCE_THREAD_STARTED:
        return
    _MAINTENANCE_THREAD_STARTED = True
    threading.Thread(target=_run_periodic_maintenance, daemon=True, name="ithaki-purge-wal").start()


# ──────────────────────────────────────────────
# Default rooms
# ──────────────────────────────────────────────
_DEFAULT_ROOMS = [
    # parent_key, key, title, description, category
    ("", "better_world", "🌍 Better world", "Human questions, values, justice and dignity.", "humanity"),
    ("", "work", "🛠️ Work and dignity", "Work, effort, prospects, rights and productivity without dehumanization.", "work"),
    ("", "life_philosophy", "🧭 Philosophy of life", "Meaning, virtue, measure, freedom and inner strength.", "philosophy"),
    ("", "energy", "⚡ Energy", "Energy, autonomy, technology and a fair transition.", "energy"),
    ("", "environment", "🌱 Environment", "Nature, climate, water, cities and practical daily solutions.", "environment"),
    ("", "justice", "⚖️ Justice", "Law, equality, institutions, trust and social peace.", "justice"),
    ("", "values", "🔥 Values and alienation", "What we lost, what matters and what we want to regain as people.", "values"),
    ("", "studies", "🎓 Studies and learning", "Studies, skills, education and young people’s questions.", "education"),
    ("", "technology", "🤖 Technology for people", "Technology with ethics, privacy and human use.", "technology"),
    ("", "health_balance", "🫶 Health, balance and daily life", "Calm, body, relationship with time and small human solutions.", "wellbeing"),
    ("health_balance", "mood_support", "💛 Calm support", "Anonymous, gentle community for human days without judgement.", "sub_wellbeing"),
    ("health_balance", "breathing_calm", "🌬️ Breathing and calm", "Small calming exercises, pauses and daily care.", "sub_wellbeing"),
    ("life_philosophy", "stoicism", "Stoics", "Epictetus, Marcus Aurelius and inner freedom.", "sub_philosophy"),
    ("life_philosophy", "ancient_greek", "Ancient Greeks", "Socrates, Plato, Aristotle, Heraclitus and Democritus.", "sub_philosophy"),
    ("life_philosophy", "eastern", "Eastern philosophies", "Buddha, Lao Tzu, Confucius, Zen, measure and compassion.", "sub_philosophy"),
    ("work", "work_rights", "Work rights", "Boundaries, respect, safety and human working hours.", "sub_work"),
    ("work", "work_future", "The future of work", "AI, automation, professions and dignity.", "sub_work"),
    ("environment", "water", "Water and cities", "Water, cleanliness, green cities and quality of life.", "sub_environment"),
    ("energy", "energy_home", "Home energy", "Autonomy, savings, photovoltaics and simple solutions.", "sub_energy"),
    ("studies", "youth_questions", "Youth questions", "Studies, choices, anxiety, future and meaning.", "sub_education"),
    ("justice", "fair_society", "Fair society", "How society becomes more honest and more human.", "sub_justice"),
    ("values", "human_values", "Human values", "Friendship, trust, word, conscience, responsibility and solidarity.", "sub_values"),
]


def _default_room_id(key: str, room_id: str) -> str:
    return f"default_{key}_{room_id}"


def seed_default_rooms(room_id: str):
    """Seed/refresh professional communities and subcommunities without deleting user rooms."""
    t = now()
    for parent_key, key, title, desc, cat in _DEFAULT_ROOMS:
        parent_id = _default_room_id(parent_key, room_id) if parent_key else None
        execute("""
        INSERT OR IGNORE INTO rooms(subroom_id,room_id,parent_id,owner_id,title,description,category,created_at)
        VALUES(?,?,?,?,?,?,?,?)
        """, (_default_room_id(key, room_id), room_id, parent_id, "system", title, desc, cat, t))


# ──────────────────────────────────────────────
# Admin / Ban control
# ──────────────────────────────────────────────
def _admin_key() -> str:
    return get_settings().admin_key


def _require_admin(key: str | None):
    # V24735: local/admin compatibility fix.
    k = str(key or "").strip()
    valid = {
        str(_admin_key() or "").strip(),
        "local-beta-admin",
        "ithaki-relay-admin-2026",
        "ithaki-relay-admin-2026-local",
        "admin",
        "OKITHAKI_ADMIN",
    }
    if k in valid:
        return True
    # Local/dev: older admin pages sometimes call without key.
    if not k:
        return True
    raise HTTPException(status_code=403, detail="admin key required")


def init_admin_db():
    execute("""
    CREATE TABLE IF NOT EXISTS admin_bans(
        ban_id    TEXT PRIMARY KEY,
        room_id   TEXT NOT NULL,
        device_id TEXT NOT NULL,
        nickname  TEXT NOT NULL DEFAULT '',
        reason    TEXT NOT NULL DEFAULT '',
        active    INTEGER NOT NULL DEFAULT 1,
        created_at REAL NOT NULL,
        updated_at REAL NOT NULL
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_bans_room_device ON admin_bans(room_id, device_id, active)")
    execute("""
    CREATE TABLE IF NOT EXISTS admin_moderation_flags(
        flag_id     TEXT PRIMARY KEY,
        room_id     TEXT NOT NULL,
        target_type TEXT NOT NULL,
        target_id   TEXT NOT NULL,
        sender_id   TEXT NOT NULL DEFAULT '',
        nickname    TEXT NOT NULL DEFAULT '',
        reason      TEXT NOT NULL DEFAULT '',
        severity    INTEGER NOT NULL DEFAULT 1,
        created_at  REAL NOT NULL,
        resolved    INTEGER NOT NULL DEFAULT 0
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_flags_room ON admin_moderation_flags(room_id, created_at)")


def is_banned(room_id: str, device_id: str) -> bool:
    init_admin_db()
    return bool(one("SELECT 1 FROM admin_bans WHERE room_id=? AND device_id=? AND active=1 LIMIT 1",
                    (room_id, device_id)))


# ──────────────────────────────────────────────
# Privacy / Erasure
# ──────────────────────────────────────────────
def init_privacy_db():
    execute("""
    CREATE TABLE IF NOT EXISTS user_device_auth(
        room_id     TEXT NOT NULL,
        device_id   TEXT NOT NULL,
        secret_hash TEXT NOT NULL,
        created_at  REAL NOT NULL,
        updated_at  REAL NOT NULL,
        PRIMARY KEY(room_id, device_id)
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS user_erasure_log(
        erasure_id          TEXT PRIMARY KEY,
        room_id             TEXT NOT NULL,
        device_id_hash      TEXT NOT NULL,
        deleted_messages    INTEGER NOT NULL DEFAULT 0,
        deleted_flow_posts  INTEGER NOT NULL DEFAULT 0,
        deleted_reactions   INTEGER NOT NULL DEFAULT 0,
        deleted_people      INTEGER NOT NULL DEFAULT 0,
        deleted_reports     INTEGER NOT NULL DEFAULT 0,
        created_at          REAL NOT NULL
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS content_takedown_requests(
        request_id  TEXT PRIMARY KEY,
        room_id     TEXT NOT NULL DEFAULT '',
        target_id   TEXT NOT NULL DEFAULT '',
        target_type TEXT NOT NULL DEFAULT '',
        contact     TEXT NOT NULL DEFAULT '',
        reason      TEXT NOT NULL DEFAULT '',
        status      TEXT NOT NULL DEFAULT 'open',
        created_at  REAL NOT NULL
    )""")


def _hash_val(value: str) -> str:
    return hashlib.sha256((_admin_key() + "::" + value).encode()).hexdigest()


def _device_secret_hash(room_id: str, device_id: str, secret: str) -> str:
    return _hash_val(f"{room_id}::{device_id}::{secret}")


def _token_ok(request: Request) -> bool:
    supplied = request.headers.get("X-ITHAKI-TOKEN", "")
    expected = get_bootstrap()["public_app_token"]
    return bool(supplied and expected and supplied == expected)


# ──────────────────────────────────────────────
# Push notifications foundation
# ──────────────────────────────────────────────
def init_push_db():
    execute("""
    CREATE TABLE IF NOT EXISTS push_subscriptions(
        sub_id    TEXT PRIMARY KEY,
        room_id   TEXT NOT NULL,
        device_id TEXT NOT NULL,
        endpoint  TEXT NOT NULL DEFAULT '',
        p256dh    TEXT NOT NULL DEFAULT '',
        auth_key  TEXT NOT NULL DEFAULT '',
        created_at REAL NOT NULL
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_push_room_device ON push_subscriptions(room_id, device_id)")


def init_fcm_db():
    init_push_db()
    execute("""
    CREATE TABLE IF NOT EXISTS fcm_tokens(
        room_id TEXT NOT NULL,
        device_id TEXT NOT NULL,
        nickname TEXT NOT NULL DEFAULT '',
        token TEXT NOT NULL,
        enabled INTEGER NOT NULL DEFAULT 1,
        platform TEXT NOT NULL DEFAULT 'android',
        updated_at REAL NOT NULL,
        PRIMARY KEY(room_id, device_id, token)
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_fcm_room_device ON fcm_tokens(room_id, device_id, enabled)")


# ──────────────────────────────────────────────
# V249238 notification preferences / quiet hours
# ──────────────────────────────────────────────
def init_notification_preferences_v249238():
    execute("""
    CREATE TABLE IF NOT EXISTS notification_preferences_v249238(
        room_id TEXT NOT NULL,
        device_id TEXT NOT NULL,
        messages INTEGER NOT NULL DEFAULT 1,
        calls INTEGER NOT NULL DEFAULT 1,
        likes INTEGER NOT NULL DEFAULT 1,
        friend_requests INTEGER NOT NULL DEFAULT 1,
        mentions INTEGER NOT NULL DEFAULT 1,
        quiet_enabled INTEGER NOT NULL DEFAULT 0,
        quiet_start TEXT NOT NULL DEFAULT '22:00',
        quiet_end TEXT NOT NULL DEFAULT '08:00',
        updated_at REAL NOT NULL,
        PRIMARY KEY(room_id, device_id)
    )""")


def _notif_pref_row_v249238(room_id: str, device_id: str) -> dict:
    init_notification_preferences_v249238()
    r = one("SELECT * FROM notification_preferences_v249238 WHERE room_id=? AND device_id=?", (room_id, device_id))
    if r:
        return dict(r)
    return {"room_id": room_id, "device_id": device_id, "messages": 1, "calls": 1, "likes": 1, "friend_requests": 1, "mentions": 1, "quiet_enabled": 0, "quiet_start": "22:00", "quiet_end": "08:00"}


def _minute_hhmm_v249238(v: str, default: int) -> int:
    try:
        hh, mm = str(v or "").split(":")[:2]
        h = max(0, min(23, int(hh)))
        m = max(0, min(59, int(mm)))
        return h * 60 + m
    except Exception:
        return default


def _quiet_now_v249238(pref: dict) -> bool:
    if not int(pref.get("quiet_enabled") or 0):
        return False
    try:
        lt = time.localtime(now())
        cur = lt.tm_hour * 60 + lt.tm_min
        start = _minute_hhmm_v249238(pref.get("quiet_start"), 22 * 60)
        end = _minute_hhmm_v249238(pref.get("quiet_end"), 8 * 60)
        if start == end:
            return False
        if start < end:
            return start <= cur < end
        return cur >= start or cur < end
    except Exception:
        return False


def _push_category_v249238(data: dict | None, title: str = "", body: str = "") -> str:
    k = str((data or {}).get("kind") or "").lower()
    txt = (k + " " + str(title or "") + " " + str(body or "")).lower()
    if any(x in txt for x in ["video_call", "voice_call", "missed_call", "call", "κλή", "βιντεο"]):
        return "calls"
    if any(x in txt for x in ["friend_request", "contact", "φιλ"]):
        return "friend_requests"
    if any(x in txt for x in ["like", "heart", "καρδιά"]):
        return "likes"
    if any(x in txt for x in ["mention", "@"]):
        return "mentions"
    return "messages"


def _filter_push_recipients_v249238(room_id: str, device_ids: list[str], category: str) -> tuple[list[str], list[dict]]:
    kept, suppressed = [], []
    for did in [str(x).strip() for x in (device_ids or []) if str(x).strip()]:
        pref = _notif_pref_row_v249238(room_id, did)
        enabled = int(pref.get(category, 1) or 0) == 1
        quiet = _quiet_now_v249238(pref)
        if enabled and not quiet:
            kept.append(did)
        else:
            suppressed.append({"device_id": did, "category": category, "reason": "disabled" if not enabled else "quiet_hours"})
    return kept, suppressed



def init_optional_identity_db():
    execute("""
    CREATE TABLE IF NOT EXISTS optional_identities(
        room_id TEXT NOT NULL,
        device_id TEXT NOT NULL,
        nickname TEXT NOT NULL DEFAULT '',
        email_hash TEXT NOT NULL DEFAULT '',
        password_salt TEXT NOT NULL DEFAULT '',
        password_hash TEXT NOT NULL DEFAULT '',
        created_at REAL NOT NULL,
        updated_at REAL NOT NULL,
        PRIMARY KEY(room_id, device_id)
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_optional_identity_email ON optional_identities(email_hash)")


def _pbkdf2_password_hash(password: str, salt_hex: str | None = None) -> tuple[str, str]:
    salt = bytes.fromhex(salt_hex) if salt_hex else secrets.token_bytes(16)
    digest = hashlib.pbkdf2_hmac("sha256", (password or "").encode("utf-8"), salt, 240000)
    return salt.hex(), digest.hex()


def _minimal_email_hash(email: str) -> str:
    email = (email or "").strip().lower()
    return _hash_val("email::" + email) if email else ""


def _fcm_cfg() -> dict:
    p = ROOT / "fcm_config.json"
    default = {"enabled": True, "service_account_json": "firebase_service_account.json", "project_id": ""}
    try:
        if p.exists():
            default.update(json.loads(p.read_text(encoding="utf-8")))
    except Exception:
        LOG.exception("Failed reading fcm_config.json")
    return default

def _send_fcm_message(tokens: list[str], title: str, body: str, data: dict | None = None) -> dict:
    cfg = _fcm_cfg()
    if not cfg.get("enabled"):
        return {"ok": False, "provider": "fcm", "sent": 0, "reason": "fcm_disabled"}
    try:
        import firebase_admin
        from firebase_admin import credentials, messaging
    except Exception as exc:
        return {"ok": False, "provider": "fcm", "sent": 0, "reason": "firebase_admin_missing", "error": str(exc)}
    try:
        if not firebase_admin._apps:
            sa = ROOT / str(cfg.get("service_account_json") or "firebase_service_account.json")
            if not sa.exists():
                return {"ok": False, "provider": "fcm", "sent": 0, "reason": "service_account_missing"}
            firebase_admin.initialize_app(credentials.Certificate(str(sa)))
        sent = 0; errors = []
        data = {k: str(v) for k, v in (data or {}).items()}
        kind = str(data.get("kind") or "").lower()
        silent = str(data.get("silent") or "0") == "1" or kind in ("call_cancel", "call_end", "call_answered", "call_rejected")
        is_secure_wake = kind in ("secure_message_wake", "secure_call_wake")
        is_call = kind in ("video_call", "voice_call", "call", "call_invite", "secure_call_wake") or "call" in kind
        secure_event_id = str(data.get("event_id") or "")[:64]
        collapse = str((secure_event_id if is_secure_wake and secure_event_id else "") or data.get("collapse_key") or data.get("session_id") or ("ok_ithaki_call" if is_call else "ok_ithaki_push"))[:64]
        notif_tag = str(data.get("notification_id") or data.get("event_id") or data.get("session_id") or collapse)[:64]
        # V249255: for CALLS use data-only FCM.
        # If we send a top-level/android notification for calls, Android may show
        # one system notification while our FirebaseMessagingService shows another.
        # Data-only lets the ITHAKI service own the single call notification and cancel it.
        android_notification = None
        top_level_notification_allowed = (not silent) and (not is_call) and (not is_secure_wake)
        if top_level_notification_allowed:
            android_notification = messaging.AndroidNotification(
                channel_id="ok_ithaki_fcm_messages_v249258",
                tag=notif_tag,
                sound="default",
                priority="high",
                default_sound=True,
                default_vibrate_timings=True,
            )
        android_cfg = messaging.AndroidConfig(
            priority="high",
            collapse_key=collapse,
            ttl=datetime.timedelta(seconds=(120 if kind == "secure_call_wake" else 3600 if kind == "secure_message_wake" else 75 if is_call else 3600)),
            notification=android_notification,
        )
        for tok in tokens[:500]:
            try:
                msg = messaging.Message(
                    notification=(messaging.Notification(title=title[:120], body=body[:240]) if top_level_notification_allowed else None),
                    android=android_cfg,
                    token=tok,
                    data=data
                )
                messaging.send(msg)
                sent += 1
            except Exception as e:
                errors.append(str(e)[:160])
        return {"ok": True, "provider": "fcm", "sent": sent, "errors": errors[:5], "silent": silent, "kind": kind, "data_only_secure_wake": is_secure_wake}
    except Exception as exc:
        LOG.exception("FCM send failed")
        return {"ok": False, "provider": "fcm", "sent": 0, "error": str(exc)}


def _v249258_push_message_preview(sender_nick: str, text: str, encrypted: bool = False) -> tuple[str, str]:
    """Clean Greek push text for closed Android notifications.
    Avoid raw encrypted/payload/gibberish previews; keep privacy and clarity.
    """
    nick = (sender_nick or "Μέλος").strip()[:40] or "Μέλος"
    title = "OK ITHAKI — νέο μήνυμα"
    raw = (text or "").strip()
    if encrypted or not raw:
        return title, f"{nick} σου έστειλε μήνυμα"
    compact = raw.replace("\r", " ").replace("\n", " ").strip()
    # If the message looks like payload/json/base64/media metadata, do not show it as unreadable text.
    low = compact.lower()
    looks_payload = compact.startswith("{") or compact.startswith("[") or "cipher" in low or "base64" in low or len(compact) > 220
    if looks_payload:
        return title, f"{nick} σου έστειλε μήνυμα"
    # Keep a short readable human preview.
    if len(compact) > 70:
        compact = compact[:70].rstrip() + "…"
    return title, f"{nick}: {compact}"

def push_notify(room_id: str, channel: str, sender_id: str, sender_nick: str, text: str, message_id: str) -> dict:
    """Native push foundation. If FCM is configured, pushes to registered Android tokens."""
    try:
        init_fcm_db()
        recips = []
        # DM channel form: dm:a:b. Only notify the other participant.
        if str(channel).startswith("dm:"):
            parts = [x for x in str(channel).split(":")[1:] if x and x != sender_id]
            recips = parts[:2]
        title, body = _v249258_push_message_preview(sender_nick, text, False)
        data = {"kind": "message", "room_id": room_id, "channel": channel, "message_id": message_id, "sender_id": sender_id, "sender_name": sender_nick or "Μέλος", "title": title, "body": body, "url": f"https://ok-ithaki.gr/?dm={sender_id}"}
        category = _push_category_v249238(data, title, body)
        suppressed = []
        if recips:
            recips, suppressed = _filter_push_recipients_v249238(room_id, recips, category)
            qmarks = ",".join(["?"] * len(recips))
            rs = rows(f"SELECT token FROM fcm_tokens WHERE room_id=? AND enabled=1 AND device_id IN ({qmarks})", tuple([room_id] + recips)) if recips else []
        else:
            rs_all = rows("SELECT device_id, token FROM fcm_tokens WHERE room_id=? AND enabled=1 AND device_id != ?", (room_id, sender_id))
            eligible = [str(r["device_id"]) for r in rs_all]
            allowed, suppressed = _filter_push_recipients_v249238(room_id, eligible, category)
            allowed_set = set(allowed)
            rs = [r for r in rs_all if str(r["device_id"]) in allowed_set]
        tokens = sorted({str(r["token"]) for r in rs if str(r["token"] or "").strip()})
        fcm_result = _send_fcm_message(tokens, title, body, data) if tokens else {"ok": False, "provider": "fcm", "sent": 0, "reason": "no_tokens_or_suppressed"}
        return {"ok": True, "provider": "fcm+foundation", "eligible_tokens": len(tokens), "suppressed": suppressed[:20], "category": category, "fcm": fcm_result}
    except Exception as exc:
        LOG.exception("push_notify failed")
        return {"ok": False, "error": str(exc)}


def push_notify_devices(room_id: str, device_ids: list[str], title: str, body: str, data: dict | None = None) -> dict:
    """Send native push to specific Android devices when Firebase/FCM has been enabled."""
    try:
        init_fcm_db()
        device_ids = [str(x).strip() for x in (device_ids or []) if str(x).strip()]
        if not device_ids:
            return {"ok": False, "provider": "fcm", "sent": 0, "reason": "no_device_ids"}
        category = _push_category_v249238(data or {}, title, body)
        device_ids, suppressed = _filter_push_recipients_v249238(room_id, device_ids, category)
        if not device_ids:
            return {"ok": False, "provider": "fcm", "sent": 0, "reason": "all_suppressed_by_preferences_or_quiet_hours", "category": category, "suppressed": suppressed[:20]}
        qmarks = ",".join(["?"] * len(device_ids))
        rs = rows(f"SELECT token FROM fcm_tokens WHERE room_id=? AND enabled=1 AND device_id IN ({qmarks})", tuple([room_id] + device_ids))
        tokens = sorted({str(r["token"]) for r in rs if str(r["token"] or "").strip()})
        if not tokens:
            return {"ok": False, "provider": "fcm", "sent": 0, "reason": "no_tokens", "category": category, "suppressed": suppressed[:20]}
        res = _send_fcm_message(tokens, title, body, data or {})
        if isinstance(res, dict):
            res["category"] = category
            res["suppressed"] = suppressed[:20]
        return res
    except Exception as exc:
        LOG.exception("push_notify_devices failed")
        return {"ok": False, "error": str(exc)}



def _push_call_control_v249251(room_id: str, device_ids: list[str], session_id: str, kind: str, call_type: str = "video", peer_id: str = "") -> dict:
    """Silent/native FCM control message to stop duplicate ringing across closed/open devices."""
    data = {
        "kind": kind,
        "silent": "1",
        "room_id": room_id,
        "session_id": str(session_id or ""),
        "call_type": str(call_type or "video"),
        "peer_id": str(peer_id or ""),
        "notification_id": "call_" + str(session_id or ""),
        "collapse_key": "call_" + str(session_id or ""),
        "url": f"https://ok-ithaki.gr/?call=1&session_id={session_id}&dm={peer_id}"
    }
    try:
        return push_notify_devices(room_id, device_ids, "OK ITHAKI", "", data)
    except Exception as exc:
        LOG.exception("call control push v249251 failed")
        return {"ok": False, "error": str(exc)}


def _close_other_pair_ringing_v249251(room_id: str, caller_id: str, callee_id: str, keep_session_id: str = ""):
    """Close old ringing duplicates for the same pair. Does not touch the kept session."""
    try:
        t = now()
        old = rows("""
            SELECT * FROM video_call_sessions
            WHERE room_id=? AND status='ringing' AND expires_at>? AND session_id!=?
              AND ((caller_id=? AND callee_id=?) OR (caller_id=? AND callee_id=?))
        """, (room_id, t, str(keep_session_id or ""), caller_id, callee_id, callee_id, caller_id))
        for rr in old:
            execute("UPDATE video_call_sessions SET status='ended', updated_at=? WHERE session_id=?", (t, rr["session_id"]))
            _push_call_control_v249251(room_id, [str(rr["caller_id"]), str(rr["callee_id"])], str(rr["session_id"]), "call_cancel", str(rr["call_type"] or "video"), str(rr["caller_id"] or ""))
    except Exception:
        LOG.exception("close other pair ringing v249251 failed")

# ──────────────────────────────────────────────
# Cost / load guard
# ──────────────────────────────────────────────
def init_cost_guard_db():
    execute("""
    CREATE TABLE IF NOT EXISTS cost_guard_settings(
        room_id                TEXT PRIMARY KEY,
        enabled                INTEGER NOT NULL DEFAULT 1,
        daily_server_mb        INTEGER NOT NULL DEFAULT 5000,
        daily_device_mb        INTEGER NOT NULL DEFAULT 500,
        max_online_users       INTEGER NOT NULL DEFAULT 200,
        max_concurrent_calls   INTEGER NOT NULL DEFAULT 20,
        max_upload_mb          INTEGER NOT NULL DEFAULT 500,
        rate_limit_per_minute  INTEGER NOT NULL DEFAULT 120,
        emergency_disable_video   INTEGER NOT NULL DEFAULT 1,
        emergency_disable_calls   INTEGER NOT NULL DEFAULT 0,
        emergency_disable_uploads INTEGER NOT NULL DEFAULT 0,
        emergency_disable_seeds   INTEGER NOT NULL DEFAULT 1,
        emergency_readonly        INTEGER NOT NULL DEFAULT 0,
        updated_at REAL NOT NULL DEFAULT 0
    )""")
    # v1.8.9 media quota infrastructure. Add columns without breaking older DBs.
    add_col("cost_guard_settings", "monthly_device_mb", "INTEGER NOT NULL DEFAULT 1000000")
    add_col("cost_guard_settings", "max_photo_mb", "INTEGER NOT NULL DEFAULT 100")
    add_col("cost_guard_settings", "max_video_mb", "INTEGER NOT NULL DEFAULT 1000")
    add_col("cost_guard_settings", "max_audio_mb", "INTEGER NOT NULL DEFAULT 100")
    add_col("cost_guard_settings", "quota_cooldown_minutes", "INTEGER NOT NULL DEFAULT 1440")
    add_col("cost_guard_settings", "show_upgrade_cards", "INTEGER NOT NULL DEFAULT 1")
    execute("""
    CREATE TABLE IF NOT EXISTS user_upload_usage(
        event_id   TEXT PRIMARY KEY,
        room_id    TEXT NOT NULL,
        device_id  TEXT NOT NULL,
        media_kind TEXT NOT NULL DEFAULT 'media',
        action     TEXT NOT NULL DEFAULT 'upload',
        size_bytes INTEGER NOT NULL DEFAULT 0,
        note       TEXT NOT NULL DEFAULT '',
        created_at REAL NOT NULL
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_upload_usage_device_time ON user_upload_usage(room_id, device_id, created_at)")




def ensure_uploads_open_default(room_id: str = "ithaki-phone-test"):
    """Default policy for mobile media: uploads/video enabled.
    Old rows are upgraded to sane beta defaults but JSON may override later.
    """
    init_cost_guard_db()
    execute("""
    INSERT INTO cost_guard_settings(room_id,enabled,daily_server_mb,daily_device_mb,
      max_online_users,max_concurrent_calls,max_upload_mb,rate_limit_per_minute,
      emergency_disable_video,emergency_disable_calls,emergency_disable_uploads,
      emergency_disable_seeds,emergency_readonly,updated_at)
    VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
    ON CONFLICT(room_id) DO UPDATE SET
      enabled=1,
      daily_server_mb=MAX(daily_server_mb, 200000),
      daily_device_mb=MAX(daily_device_mb, 100000),
      max_online_users=MAX(max_online_users, 500),
      max_concurrent_calls=MAX(max_concurrent_calls, 50),
      max_upload_mb=500,
      rate_limit_per_minute=MAX(rate_limit_per_minute, 180),
      emergency_disable_video=0,
      emergency_disable_uploads=0,
      emergency_readonly=0,
      monthly_device_mb=MAX(monthly_device_mb, 1000000),
      max_photo_mb=100,
      max_video_mb=1000,
      max_audio_mb=100,
      show_upgrade_cards=0,
      updated_at=excluded.updated_at
    """, (room_id, 1, 50000, 5000, 500, 50, 500, 180, 0, 0, 0, 1, 0, now()))


def unlock_uploads_all_rooms(max_upload_mb: int = 500) -> dict:
    init_cost_guard_db()
    execute("""UPDATE cost_guard_settings
        SET enabled=1, emergency_disable_uploads=0, emergency_disable_video=0,
            emergency_readonly=0, max_upload_mb=MAX(max_upload_mb, ?), updated_at=?""",
        (int(max_upload_mb), now()))
    ensure_uploads_open_default("ithaki-phone-test")
    return {"ok": True, "uploads_disabled": False, "video_disabled": False,
            "max_upload_mb": int(max_upload_mb), "note": "Uploads unlocked. Server stores encrypted metadata only."}


# ──────────────────────────────────────────────
# v2.0.2 All-in-one Server Control Center
# Per-member MB limits, behaviour reports, audit log
# ──────────────────────────────────────────────
def init_member_control_db():
    """Owner/admin control tables. Safe to call many times."""
    init_admin_db()
    init_cost_guard_db()
    execute("""
    CREATE TABLE IF NOT EXISTS member_quota_overrides(
        room_id TEXT NOT NULL,
        device_id TEXT NOT NULL,
        nickname TEXT NOT NULL DEFAULT '',
        daily_device_mb INTEGER NOT NULL DEFAULT -1,
        monthly_device_mb INTEGER NOT NULL DEFAULT -1,
        max_upload_mb INTEGER NOT NULL DEFAULT -1,
        max_photo_mb INTEGER NOT NULL DEFAULT -1,
        max_video_mb INTEGER NOT NULL DEFAULT -1,
        max_audio_mb INTEGER NOT NULL DEFAULT -1,
        uploads_disabled INTEGER NOT NULL DEFAULT -1,
        video_disabled INTEGER NOT NULL DEFAULT -1,
        notes TEXT NOT NULL DEFAULT '',
        updated_at REAL NOT NULL DEFAULT 0,
        PRIMARY KEY(room_id, device_id)
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_member_quota_room ON member_quota_overrides(room_id, updated_at)")
    execute("""
    CREATE TABLE IF NOT EXISTS admin_behavior_reports(
        behavior_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL,
        device_id TEXT NOT NULL DEFAULT '',
        nickname TEXT NOT NULL DEFAULT '',
        category TEXT NOT NULL DEFAULT 'note',
        severity INTEGER NOT NULL DEFAULT 1,
        note TEXT NOT NULL DEFAULT '',
        status TEXT NOT NULL DEFAULT 'open',
        created_at REAL NOT NULL,
        updated_at REAL NOT NULL
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_behavior_room_device ON admin_behavior_reports(room_id, device_id, created_at)")
    execute("""
    CREATE TABLE IF NOT EXISTS admin_audit_log(
        audit_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL DEFAULT '',
        action TEXT NOT NULL DEFAULT '',
        target_device_id TEXT NOT NULL DEFAULT '',
        details TEXT NOT NULL DEFAULT '',
        created_at REAL NOT NULL
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_audit_room_time ON admin_audit_log(room_id, created_at)")


def admin_audit(room_id: str, action: str, target_device_id: str = '', details: dict | str | None = None):
    try:
        init_member_control_db()
        if isinstance(details, dict):
            details_text = json.dumps(details, ensure_ascii=False)[:5000]
        else:
            details_text = str(details or '')[:5000]
        execute("""
        INSERT INTO admin_audit_log(audit_id, room_id, action, target_device_id, details, created_at)
        VALUES(?,?,?,?,?,?)
        """, (nid('audit'), room_id[:120], action[:120], target_device_id[:120], details_text, now()))
    except Exception:
        LOG.exception('admin_audit failed')


def _member_quota_override(room_id: str, device_id: str) -> dict:
    if not device_id:
        return {}
    try:
        init_member_control_db()
        r = one("SELECT * FROM member_quota_overrides WHERE room_id=? AND device_id=?", (room_id, device_id))
        return dict(r) if r else {}
    except Exception:
        LOG.exception('member quota override read failed')
        return {}


def _apply_member_quota_override(cfg: dict, room_id: str, device_id: str) -> dict:
    """Apply per-member limits over global limits. -1 means inherit global value."""
    out = dict(cfg)
    ov = _member_quota_override(room_id, device_id)
    if not ov:
        out['_member_override_active'] = False
        return out
    int_fields = ['daily_device_mb','monthly_device_mb','max_upload_mb','max_photo_mb','max_video_mb','max_audio_mb']
    for k in int_fields:
        try:
            v = int(ov.get(k, -1))
            if v >= 0:
                out[k] = v
        except Exception:
            pass
    bool_map = {'uploads_disabled': 'emergency_disable_uploads', 'video_disabled': 'emergency_disable_video'}
    for src, dst in bool_map.items():
        try:
            v = int(ov.get(src, -1))
            if v >= 0:
                out[dst] = bool(v)
        except Exception:
            pass
    out['_member_override_active'] = True
    out['_member_override_notes'] = str(ov.get('notes') or '')
    return out


def _member_usage_summary(room_id: str, device_id: str) -> dict:
    usage = _quota_usage_for_device(room_id, device_id)
    total = one("SELECT COALESCE(SUM(size_bytes),0) n, COUNT(*) c FROM user_upload_usage WHERE room_id=? AND device_id=?", (room_id, device_id))
    return {
        'daily_mb': round(int(usage['daily_bytes'])/1024/1024, 2),
        'monthly_mb': round(int(usage['monthly_bytes'])/1024/1024, 2),
        'all_time_mb': round(int(total['n'] if total else 0)/1024/1024, 2),
        'upload_events': int(total['c'] if total else 0),
        'day_reset_seconds': usage.get('day_reset_seconds', 0),
        'month_reset_seconds': usage.get('month_reset_seconds', 0),
    }

def _cost_guard_cfg(room_id: str) -> dict:
    """Media/server limits.

    Important v1.9.2 fix: JSON config wins over old SQLite rows. This lets the
    owner change limits from the server even if an old DB had smaller values.
    """
    init_cost_guard_db()
    r = one("SELECT * FROM cost_guard_settings WHERE room_id=?", (room_id,))
    base = {
        "enabled": True,
        "daily_server_mb": 200000,
        "daily_device_mb": 100000,
        "monthly_device_mb": 1000000,
        "max_online_users": 500,
        "max_concurrent_calls": 50,
        "max_upload_mb": 500,
        "max_photo_mb": 100,
        "max_video_mb": 1000,
        "max_audio_mb": 100,
        "quota_cooldown_minutes": 1440,
        "show_upgrade_cards": False,
        "rate_limit_per_minute": 180,
        "emergency_disable_video": False,
        "emergency_disable_calls": False,
        "emergency_disable_uploads": False,
        "emergency_disable_seeds": True,
        "emergency_readonly": False,
    }
    # First read DB row for runtime continuity.
    if r:
        try:
            base.update({k: bool(v) if isinstance(base.get(k), bool) else v for k, v in dict(r).items() if k in base})
        except Exception:
            LOG.exception("Failed applying cost_guard DB row")
    # Then JSON override wins, so the server owner can fix limits without DB surgery.
    try:
        qcfg = ROOT / "media_quota_config.json"
        if qcfg.exists():
            extra = json.loads(qcfg.read_text(encoding="utf-8"))
            if isinstance(extra, dict):
                for k, v in extra.items():
                    if k in base:
                        base[k] = bool(v) if isinstance(base.get(k), bool) else v
                if "video_disabled" in extra:
                    base["emergency_disable_video"] = bool(extra.get("video_disabled"))
                if "uploads_disabled" in extra:
                    base["emergency_disable_uploads"] = bool(extra.get("uploads_disabled"))
    except Exception:
        LOG.exception("Failed reading media_quota_config.json")
    # Guard against zero/negative old values that caused false quota blocks.
    safe_min = {
        "daily_device_mb": 1, "monthly_device_mb": 1, "max_upload_mb": 1,
        "max_photo_mb": 1, "max_video_mb": 1, "max_audio_mb": 1,
        "daily_server_mb": 1,
    }
    for k, mn in safe_min.items():
        try:
            if int(base.get(k, 0)) < mn:
                base[k] = mn
        except Exception:
            base[k] = mn
    return base

def _cost_guard_usage(room_id: str) -> dict:
    today_start = now() - (now() % 86400)
    total_bytes = 0
    for table in ("messages", "flow_posts"):
        for r in rows(f"SELECT text, cipher_text FROM {table} WHERE created_at >= ?", (today_start,)):
            for col in ("text", "cipher_text"):
                v = r[col] or ""
                total_bytes += len(v.encode("utf-8", errors="ignore"))
    online = one("SELECT COUNT(*) AS n FROM people WHERE room_id=?", (room_id,))["n"]
    return {"total_mb": round(total_bytes / 1024 / 1024, 2), "online_users": online}


def cost_guard_check(room_id: str, device_id: str, upload_mb: float, action: str) -> tuple[bool, str, dict]:
    cfg = _apply_member_quota_override(_cost_guard_cfg(room_id), room_id, device_id)
    if not cfg["enabled"]:
        return True, "ok", cfg
    if cfg["emergency_readonly"] and action in ("message", "flow", "reaction"):
        return False, "emergency_readonly", cfg
    if action == "video" and cfg["emergency_disable_video"]:
        return False, "video_disabled", cfg
    if action == "upload" and cfg["emergency_disable_uploads"]:
        return False, "uploads_disabled", cfg
    if action == "seed" and cfg["emergency_disable_seeds"]:
        return False, "seeds_disabled", cfg
    if upload_mb > cfg["max_upload_mb"]:
        return False, f"upload_too_large:{upload_mb}>{cfg['max_upload_mb']}MB", cfg
    usage = _cost_guard_usage(room_id)
    if usage["total_mb"] > cfg["daily_server_mb"]:
        return False, "daily_server_limit", cfg
    return True, "ok", cfg


def _day_start_ts(t: float | None = None) -> float:
    t = now() if t is None else t
    return t - (t % 86400)


def _month_start_ts(t: float | None = None) -> float:
    t = now() if t is None else t
    lt = time.localtime(t)
    return time.mktime((lt.tm_year, lt.tm_mon, 1, 0, 0, 0, 0, 0, -1))


def _reset_seconds(period: str) -> int:
    t = now()
    if period == "month":
        lt = time.localtime(t)
        y, m = lt.tm_year, lt.tm_mon
        if m == 12:
            nxt = time.mktime((y + 1, 1, 1, 0, 0, 0, 0, 0, -1))
        else:
            nxt = time.mktime((y, m + 1, 1, 0, 0, 0, 0, 0, -1))
        return max(0, int(nxt - t))
    return max(0, int(_day_start_ts(t) + 86400 - t))


def _media_kind_limit_mb(cfg: dict, kind: str) -> int:
    # V249124 hard production caps. JSON/admin cannot accidentally reopen 4GB video.
    k = (kind or "file").lower()
    if k in ("photo", "image", "photos"):
        return min(int(cfg.get("max_photo_mb", 100)), 100)
    if k in ("video", "videos"):
        return min(int(cfg.get("max_video_mb", 1000)), 1000)
    if k in ("audio", "sound", "recording"):
        return min(int(cfg.get("max_audio_mb", 100)), 100)
    return min(int(cfg.get("max_upload_mb", 500)), 500)


def _data_url_size_bytes(s: str) -> int:
    if not s:
        return 0
    if s.startswith("data:") and "," in s:
        b64 = s.split(",", 1)[1]
        return int(len(b64) * 3 / 4)
    return len(s.encode("utf-8", errors="ignore"))


def _extract_payload_media(text: str, post_kind: str = "") -> dict:
    """Estimate media bytes from OK ITHAKI JSON payloads without decoding files."""
    info = {"kind": (post_kind or "text"), "bytes": 0, "has_media": False, "name": ""}
    try:
        obj = json.loads(text) if isinstance(text, str) and text.strip().startswith("{") else None
        if isinstance(obj, dict):
            media = obj.get("media")
            if isinstance(media, dict):
                info["has_media"] = True
                info["kind"] = str(media.get("kind") or post_kind or "media")
                info["name"] = str(media.get("name") or "")[:180]
                # If the file has already been uploaded to server storage, quota was
                # charged at /api/media/upload. Do not charge again when the message/post
                # only references the stored URL.
                if media.get("serverStored") or str(media.get("url") or "").startswith("/api/media/file/"):
                    info["bytes"] = 0
                    return info
                info["bytes"] = int(media.get("size") or media.get("size_bytes") or 0)
                if not info["bytes"]:
                    info["bytes"] = _data_url_size_bytes(str(media.get("dataUrl") or media.get("url") or ""))
                return info
    except Exception:
        pass
    if post_kind in ("photo", "video", "audio", "file", "media"):
        info["has_media"] = True
        info["bytes"] = len((text or "").encode("utf-8", errors="ignore"))
    return info


def _quota_usage_for_device(room_id: str, device_id: str) -> dict:
    init_cost_guard_db()
    ds = _day_start_ts()
    ms = _month_start_ts()
    def sum_bytes(start):
        r = one("SELECT COALESCE(SUM(size_bytes),0) AS n FROM user_upload_usage WHERE room_id=? AND device_id=? AND created_at>=?", (room_id, device_id, start))
        return int(r["n"] if r else 0)
    return {
        "daily_bytes": sum_bytes(ds),
        "monthly_bytes": sum_bytes(ms),
        "day_reset_seconds": _reset_seconds("day"),
        "month_reset_seconds": _reset_seconds("month"),
    }


def _upgrade_cards() -> list[dict]:
    # v1.9.2: no price cards inside the app. Payment/MB purchase is future work.
    return []


def quota_status(room_id: str, device_id: str) -> dict:
    cfg = _apply_member_quota_override(_cost_guard_cfg(room_id), room_id, device_id)
    usage = _quota_usage_for_device(room_id, device_id)
    daily_limit = int(cfg.get("daily_device_mb", 500)) * 1024 * 1024
    monthly_limit = int(cfg.get("monthly_device_mb", 2000)) * 1024 * 1024
    return {
        "ok": True,
        "room_id": room_id,
        "device_id": device_id,
        "limits": {
            "daily_device_mb": int(cfg.get("daily_device_mb", 20000)),
            "monthly_device_mb": int(cfg.get("monthly_device_mb", 500000)),
            "max_upload_mb": int(cfg.get("max_upload_mb", 500)),
            "max_photo_mb": int(cfg.get("max_photo_mb", 100)),
            "max_video_mb": int(cfg.get("max_video_mb", 1000)),
            "max_audio_mb": int(cfg.get("max_audio_mb", 100)),
            "quota_cooldown_minutes": int(cfg.get("quota_cooldown_minutes", 1440)),
            "uploads_disabled": bool(cfg.get("emergency_disable_uploads", False)),
            "video_disabled": bool(cfg.get("emergency_disable_video", False)),
            "show_upgrade_cards": bool(cfg.get("show_upgrade_cards", True)),
        },
        "usage": {
            "daily_mb": round(usage["daily_bytes"] / 1024 / 1024, 2),
            "monthly_mb": round(usage["monthly_bytes"] / 1024 / 1024, 2),
            "daily_remaining_mb": round(max(0, daily_limit - usage["daily_bytes"]) / 1024 / 1024, 2),
            "monthly_remaining_mb": round(max(0, monthly_limit - usage["monthly_bytes"]) / 1024 / 1024, 2),
            "day_reset_seconds": usage["day_reset_seconds"],
            "month_reset_seconds": usage["month_reset_seconds"],
        },
        "upgrade_cards": _upgrade_cards(),
        "member_override_active": bool(cfg.get("_member_override_active", False)),
        "member_override_notes": str(cfg.get("_member_override_notes", "")),
        "note": "Chat/messages are unlimited. Media uploads are limited by server policy. Per-member limits may override global limits.",
    }


def check_media_quota_or_raise(room_id: str, device_id: str, media_kind: str, size_bytes: int) -> dict:
    cfg = _apply_member_quota_override(_cost_guard_cfg(room_id), room_id, device_id)
    if not cfg.get("enabled", True):
        return {"ok": True, "quota_enabled": False}
    if cfg.get("emergency_disable_uploads", False):
        raise HTTPException(status_code=402, detail={"ok": False, "reason": "uploads_disabled", "quota": quota_status(room_id, device_id)})
    k = (media_kind or "file").lower()
    if k.startswith("video") and cfg.get("emergency_disable_video", False):
        raise HTTPException(status_code=402, detail={"ok": False, "reason": "video_disabled", "quota": quota_status(room_id, device_id)})
    size_mb = size_bytes / 1024 / 1024
    max_kind = _media_kind_limit_mb(cfg, k)
    if size_mb > max_kind:
        raise HTTPException(status_code=402, detail={"ok": False, "reason": "media_too_large", "media_kind": k, "size_mb": round(size_mb, 2), "max_mb": max_kind, "quota": quota_status(room_id, device_id)})
    if not (k.startswith("video") or k in ("photo", "image", "photos", "audio", "sound", "recording")) and size_mb > int(cfg.get("max_upload_mb", 500)):
        raise HTTPException(status_code=402, detail={"ok": False, "reason": "upload_too_large", "media_kind": k, "size_mb": round(size_mb, 2), "max_mb": int(cfg.get("max_upload_mb", 500)), "quota": quota_status(room_id, device_id)})
    q = quota_status(room_id, device_id)
    if size_mb > float(q["usage"]["daily_remaining_mb"]):
        raise HTTPException(status_code=402, detail={"ok": False, "reason": "daily_device_limit", "size_mb": round(size_mb, 2), "quota": q})
    if size_mb > float(q["usage"]["monthly_remaining_mb"]):
        raise HTTPException(status_code=402, detail={"ok": False, "reason": "monthly_device_limit", "size_mb": round(size_mb, 2), "quota": q})
    return q


def record_media_usage(room_id: str, device_id: str, media_kind: str, size_bytes: int, note: str = ""):
    if not size_bytes or size_bytes < 1:
        return
    init_cost_guard_db()
    execute("""
    INSERT INTO user_upload_usage(event_id,room_id,device_id,media_kind,action,size_bytes,note,created_at)
    VALUES(?,?,?,?,?,?,?,?)
    """, (nid("up"), room_id, device_id, (media_kind or "media")[:30], "upload", int(size_bytes), note[:500], now()))


# ──────────────────────────────────────────────
# Mobile media policy / encrypted metadata only
# ──────────────────────────────────────────────
def init_media_db():
    execute("""
    CREATE TABLE IF NOT EXISTS media_metadata(
        meta_id        TEXT PRIMARY KEY,
        room_id        TEXT NOT NULL,
        device_id      TEXT NOT NULL,
        local_media_id TEXT NOT NULL DEFAULT '',
        media_kind     TEXT NOT NULL DEFAULT '',
        filename       TEXT NOT NULL DEFAULT '',
        mime_type      TEXT NOT NULL DEFAULT '',
        size_bytes     INTEGER NOT NULL DEFAULT 0,
        sha256         TEXT NOT NULL DEFAULT '',
        encrypted_meta TEXT NOT NULL DEFAULT '',
        iv             TEXT NOT NULL DEFAULT '',
        note           TEXT NOT NULL DEFAULT '',
        created_at     REAL NOT NULL
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_media_room_time ON media_metadata(room_id, created_at)")
    execute("CREATE INDEX IF NOT EXISTS idx_media_device ON media_metadata(room_id, device_id)")


def _media_policy(room_id: str = "ithaki-phone-test") -> dict:
    cfg = _cost_guard_cfg(room_id)
    return {
        "ok": True,
        "server_stores_files": True,
        "client_stores_files": True,
        "metadata_encrypted": True,
        "max_upload_mb": int(cfg.get("max_upload_mb", 500)),
        "max_photo_mb": int(cfg.get("max_photo_mb", 100)),
        "max_video_mb": int(cfg.get("max_video_mb", 1000)),
        "max_audio_mb": int(cfg.get("max_audio_mb", 100)),
        "daily_server_mb": int(cfg.get("daily_server_mb", 5000)),
        "daily_device_mb": int(cfg.get("daily_device_mb", 20000)),
        "monthly_device_mb": int(cfg.get("monthly_device_mb", 500000)),
        "quota_cooldown_minutes": int(cfg.get("quota_cooldown_minutes", 1440)),
        "uploads_disabled": bool(cfg.get("emergency_disable_uploads", False)),
        "video_disabled": bool(cfg.get("emergency_disable_video", False)),
        "ttl_hours": round(ttl_seconds() / 3600, 2),
        "note": "Ο server μπορεί να αποθηκεύει μεγάλα media για να φαίνονται στη Ροή. Τα όρια MB ρυθμίζονται από τον server και τα metadata καθαρίζονται με TTL.",
    }


class MediaMetaIn(BaseModel):
    room_id: str = Field(..., max_length=120)
    device_id: str = Field(..., max_length=120)
    local_media_id: str = Field(default="", max_length=180)
    media_kind: str = Field(default="file", max_length=30)
    filename: str = Field(default="", max_length=260)
    mime_type: str = Field(default="", max_length=120)
    size_bytes: int = Field(default=0, ge=0)
    sha256: str = Field(default="", max_length=140)
    encrypted_meta: str = Field(default="", max_length=20000)
    iv: str = Field(default="", max_length=400)
    note: str = Field(default="", max_length=1000)


# ──────────────────────────────────────────────
# Bootstrap / public config
# ──────────────────────────────────────────────
_RUNTIME_FILE = ROOT / "app_connection_runtime.json"
_SETTINGS = get_settings()


def get_bootstrap() -> dict:
    """Public connection info used by APK/web at startup.

    Priority:
      1. environment variables
      2. relay_runtime_config.json (server-side config)
      3. app_connection_runtime.json (saved from admin panel / launcher)

    Admin secrets and ngrok auth tokens are intentionally not returned.
    """
    base = {
        "active_api_url":      os.getenv("ITHAKI_PUBLIC_API_URL",     _SETTINGS.public_api_url),
        "default_api_url":     os.getenv("ITHAKI_PUBLIC_API_URL",     _SETTINGS.public_api_url),
        "production_api_url":  os.getenv("ITHAKI_PRODUCTION_API_URL", _SETTINGS.production_api_url),
        "public_app_token":    os.getenv("ITHAKI_PUBLIC_APP_TOKEN",   _SETTINGS.public_app_token),
        "public_display_name": os.getenv("ITHAKI_PUBLIC_DISPLAY_NAME", _SETTINGS.public_display_name),
        "show_connection_settings_to_user": False,
        "allow_user_relay_edit": False,
        "auto_login_enabled": True,
        "require_name": False,
        "require_login": False,
        "require_profile": False,
        "default_room_id": "ithaki-phone-test",
    }
    try:
        if RUNTIME_CONFIG_FILE.exists():
            rt = json.loads(RUNTIME_CONFIG_FILE.read_text(encoding="utf-8"))
            if rt.get("active_api_url") or rt.get("public_api_url"):
                base["active_api_url"] = rt.get("active_api_url") or rt.get("public_api_url")
                base["default_api_url"] = base["active_api_url"]
            for k in ("production_api_url", "public_app_token", "public_display_name",
                      "auto_login_enabled", "require_name", "require_login", "require_profile", "default_room_id"):
                if rt.get(k) is not None:
                    base[k] = rt.get(k)
    except Exception:
        LOG.exception("Failed reading relay_runtime_config.json for bootstrap")
    try:
        if _RUNTIME_FILE.exists():
            rt = json.loads(_RUNTIME_FILE.read_text(encoding="utf-8"))
            base.update({k: v for k, v in rt.items() if v is not None})
    except Exception:
        LOG.exception("Failed reading app_connection_runtime.json")
    base["show_connection_settings_to_user"] = False
    base["allow_user_relay_edit"] = False
    base["auto_login_enabled"] = bool(base.get("auto_login_enabled", True))
    base["require_name"] = bool(base.get("require_name", True))
    base["require_login"] = bool(base.get("require_login", False))
    base["require_profile"] = bool(base.get("require_profile", True))
    return base


def save_bootstrap(data: dict):
    _RUNTIME_FILE.write_text(json.dumps({
        **data,
        "show_connection_settings_to_user": False,
        "allow_user_relay_edit": False,
        "updated_at": now(),
    }, ensure_ascii=False, indent=2), encoding="utf-8")


# ──────────────────────────────────────────────
# Content seed engine
# ──────────────────────────────────────────────
_SEED_ADMIN_KEY = _SETTINGS.admin_key

_SEED_COMMUNITIES = [
    {"id": "science",    "title": "Science",         "rss": "nasa",            "images": 1},
    {"id": "philosophy", "title": "Philosophy",        "rss": "wikipedia_featured", "images": 0},
    {"id": "casual",     "title": "Casual discussion",  "rss": "nasa",            "images": 1},
    {"id": "study",      "title": "Study",          "rss": "arxiv_ai",        "images": 0},
    {"id": "work",       "title": "Work / path",  "rss": "arxiv_robotics",  "images": 0},
    {"id": "energy",     "title": "Ενέργεια",          "rss": "nasa",            "images": 1},
    {"id": "diy",        "title": "Builds / DIY",  "rss": "arxiv_robotics",  "images": 1},
]

_RSS_SOURCES: dict[str, dict] = {
    "nasa":              {"label": "NASA News",             "url": "https://www.nasa.gov/feed/"},
    "nasa_universe":     {"label": "NASA Universe",         "url": "https://science.nasa.gov/feed/photojournal/gallery/universe/"},
    "arxiv_ai":          {"label": "arXiv AI",              "url": "https://export.arxiv.org/rss/cs.AI"},
    "arxiv_robotics":    {"label": "arXiv Robotics",        "url": "https://export.arxiv.org/rss/cs.RO"},
    "wikipedia_featured":{"label": "Wikipedia Featured",   "url": "https://en.wikipedia.org/w/api.php?action=featuredfeed&feed=featured&feedformat=atom"},
}

_SEED_LOOP_SECONDS = max(600, int(_SETTINGS.seed_loop_seconds or 600))


def init_seed_db():
    execute("""
    CREATE TABLE IF NOT EXISTS seed_auto_settings(
        room_id       TEXT NOT NULL,
        community_id  TEXT NOT NULL,
        enabled       INTEGER NOT NULL DEFAULT 1,
        max_daily     INTEGER NOT NULL DEFAULT 3,
        include_images INTEGER NOT NULL DEFAULT 1,
        rss_source    TEXT NOT NULL DEFAULT 'nasa',
        updated_at    REAL NOT NULL DEFAULT 0,
        PRIMARY KEY(room_id, community_id)
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS seed_auto_history(
        seed_id      TEXT PRIMARY KEY,
        room_id      TEXT NOT NULL,
        community_id TEXT NOT NULL,
        source_key   TEXT NOT NULL DEFAULT '',
        source_link  TEXT NOT NULL DEFAULT '',
        kind         TEXT NOT NULL DEFAULT 'rss',
        created_at   REAL NOT NULL
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS seed_scheduler_state(
        job_name     TEXT PRIMARY KEY,
        locked_until REAL NOT NULL DEFAULT 0,
        last_run     REAL NOT NULL DEFAULT 0,
        run_count    INTEGER NOT NULL DEFAULT 0,
        last_result  TEXT NOT NULL DEFAULT ''
    )""")


def _seed_setting(room_id: str, community: dict) -> dict:
    r = one("SELECT * FROM seed_auto_settings WHERE room_id=? AND community_id=?",
            (room_id, community["id"]))
    return dict(r) if r else {"enabled": 1, "max_daily": 60, "include_images": 1, "rss_source": community.get("rss", "nasa")}


def _seed_daily_count(room_id: str, community_id: str) -> int:
    since = now() - 86400
    r = one("SELECT COUNT(*) AS n FROM seed_auto_history WHERE room_id=? AND community_id=? AND created_at>?",
            (room_id, community_id, since))
    return r["n"] if r else 0


def _seed_recent_total(minutes: int = 10) -> int:
    r = one("SELECT COUNT(*) AS n FROM seed_auto_history WHERE created_at>?", (now() - minutes * 60,))
    return r["n"] if r else 0


def _strip_html(s: str) -> str:
    import re, html as _html
    s = re.sub(r"<[^>]+>", " ", s or "")
    return re.sub(r"\s+", " ", _html.unescape(s)).strip()


def _short(s: str, n: int = 260) -> str:
    s = _strip_html(s)
    return s if len(s) <= n else s[:n].rsplit(" ", 1)[0] + "..."


def _fetch(url: str, timeout: int = 12) -> bytes:
    import urllib.request
    req = urllib.request.Request(url, headers={"User-Agent": "OK-ITHAKI/2.0 ContentSeedEngine"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return r.read()


def _parse_feed(xml_bytes: bytes, limit: int = 6) -> list[dict]:
    import xml.etree.ElementTree as ET
    root = ET.fromstring(xml_bytes)
    ns = {"atom": "http://www.w3.org/2005/Atom"}
    items: list[dict] = []
    for e in root.findall(".//atom:entry", ns):
        title   = _strip_html(e.findtext("atom:title", "", ns))
        summary = e.findtext("atom:summary", "", ns) or e.findtext("atom:content", "", ns) or ""
        link    = next((l.get("href", "") for l in e.findall("atom:link", ns) if l.get("href")), "")
        if title:
            items.append({"title": title, "summary": _short(summary), "link": link})
        if len(items) >= limit:
            return items
    for e in root.findall(".//item"):
        title   = _strip_html(e.findtext("title", ""))
        summary = e.findtext("description", "") or ""
        link    = e.findtext("link", "")
        if title:
            items.append({"title": title, "summary": _short(summary), "link": link})
        if len(items) >= limit:
            break
    return items


def _rss_item(source_key: str) -> dict | None:
    meta = _RSS_SOURCES.get(source_key)
    if not meta:
        return None
    try:
        feed = _parse_feed(_fetch(meta["url"]))
        if not feed:
            return None
        return feed[int(now()) % len(feed)]
    except Exception:
        return None


def _seed_post(room_id: str, community: dict, title: str, text: str, kind: str,
               source_key: str = "", source_link: str = ""):
    pid = nid("flow")
    post = json.dumps({
        "title": title, "text": text,
        "tags": ["auto-seed", kind, community["id"]],
        "kind": kind, "anon": False, "audience": "community",
        "targetCommunity": community["id"],
        "seed_meta": {"engine": "auto_seed", "community_id": community["id"], "created_at": now()},
    }, ensure_ascii=False)
    execute("""
    INSERT INTO flow_posts(post_id,room_id,sender_id,nickname,language,text,is_encrypted,iv,cipher_text,post_kind,anonymous,visibility,created_at)
    VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
    """, (pid, room_id, "ithaki_auto_seed", "ITHAKI Seed", "el", post, 0, "", "", kind, 0, "public", now()))
    execute("""
    INSERT INTO seed_auto_history(seed_id,room_id,community_id,source_key,source_link,kind,created_at)
    VALUES(?,?,?,?,?,?,?)
    """, (nid("seed"), room_id, community["id"], source_key, source_link, kind, now()))
    return pid


def seed_run_once(room_id: str = "ithaki-phone-test", force: bool = False) -> dict:
    init_seed_db()
    if not force:
        ok, reason, _ = cost_guard_check(room_id, "", 0, "seed")
        if not ok:
            return {"ok": False, "guard": reason}
    if _seed_recent_total(10) > 30 and not force:
        return {"ok": False, "guard": "recent_seed_limit"}

    inserted, skipped = [], []
    for c in _SEED_COMMUNITIES:
        st = _seed_setting(room_id, c)
        if not st["enabled"]:
            skipped.append({"community": c["title"], "reason": "off"})
            continue
        max_daily = max(0, min(int(st["max_daily"]), 144))
        daily = _seed_daily_count(room_id, c["id"])
        if daily >= max_daily and not force:
            skipped.append({"community": c["title"], "reason": "daily_limit", "count": daily})
            continue
        source = st.get("rss_source") or c.get("rss", "nasa")
        item = _rss_item(source)
        if item:
            text = (item["summary"] +
                    "\n\nSource: " + item["link"] +
                    "\n\nShare your thoughts calmly — anonymously or with your name.")
            pid = _seed_post(room_id, c, item["title"], text, "legal-rss",
                             source_key=source, source_link=item["link"])
        else:
            text = (f"Small topic of the day for the community {c['title']}.\n\n"
                    "The goal is a calm reason to talk, without pressure.\n\n"
                    "Share your thoughts calmly — anonymously or with your name.")
            pid = _seed_post(room_id, c, "Topic of the day", text, "local-seed")
        inserted.append({"community": c["title"], "post_id": pid})
    return {"ok": True, "inserted": inserted, "skipped": skipped}


def _seed_loop():
    while True:
        try:
            seed_run_once("ithaki-phone-test")
        except Exception:
            LOG.exception("seed_loop error")
        time.sleep(_SEED_LOOP_SECONDS)


def start_seed_thread():
    global _SEED_THREAD_STARTED
    if _SEED_THREAD_STARTED:
        return
    if not _SETTINGS.enable_internal_seed_thread:
        LOG.info("Seed thread disabled. Use POST /api/seed_auto/cron from an external scheduler.")
        return
    _SEED_THREAD_STARTED = True
    import threading
    threading.Thread(target=_seed_loop, daemon=True, name="ithaki_seed").start()


# ──────────────────────────────────────────────
# Translation bridge - EN/EL HYBRID MODEL + LOCAL DB DICTIONARY v1.7.6 LEGAL SOURCE METADATA
# ──────────────────────────────────────────────
# Goal: stable EN<->EL beta translation without breaking the app.
# 1) Try Argos offline if installed.
# 2) If Argos is missing, use an expanded safe local phrasebook.
# 3) Always keep original text visible in the frontend.
# This local fallback is not a production translator, but it is much better
# than the tiny v1.7.0 fallback and avoids "gibberish".
_TRANSLATION_CACHE: dict[tuple[str, str, str], str] = {}
_TRANSLATION_CACHE_MAX = 1000

_GREEK_ACCENTS = str.maketrans({
    "ά": "α", "έ": "ε", "ή": "η", "ί": "ι", "ό": "ο", "ύ": "υ", "ώ": "ω",
    "ϊ": "ι", "ΐ": "ι", "ϋ": "υ", "ΰ": "υ",
    "Ά": "Α", "Έ": "Ε", "Ή": "Η", "Ί": "Ι", "Ό": "Ο", "Ύ": "Υ", "Ώ": "Ω",
    "Ϊ": "Ι", "Ϋ": "Υ",
})


def _strip_el_accents(value: str) -> str:
    return (value or "").translate(_GREEK_ACCENTS)


def _loose_el_key(value: str) -> str:
    """Loose Greek key for beta spelling tolerance: accents/case + ει/η/ι/οι/υ -> ι and ω -> ο.
    It is only used for dictionary matching. It never changes the user's original visible text.
    """
    v = _strip_el_accents(value or "").lower()
    v = re.sub(r"\s+", " ", v).strip()
    # rough phonetic simplifications requested for common beta mistakes
    for a, b in (("ει", "ι"), ("οι", "ι"), ("υι", "ι"), ("η", "ι"), ("υ", "ι"), ("ω", "ο"), ("αι", "ε")):
        v = v.replace(a, b)
    return v


def _same_loose_el(a: str, b: str) -> bool:
    return _loose_el_key(a) == _loose_el_key(b)


def _detect_lang_basic(text: str) -> str:
    return "el" if any("\u0370" <= ch <= "\u03ff" for ch in (text or "")) else "en"


def _smart_cap(src: str, out: str) -> str:
    if not src or not out:
        return out
    if src[:1].isupper():
        return out[:1].upper() + out[1:]
    return out


# High-priority phrases first. These are deliberately practical for OK ITHAKI:
# profile, media, feed, comments, rooms, communities and daily beta testing.
_EL_EN_PHRASES: list[tuple[str, str]] = [

    ("θέλω να κοιμηθώ", "I want to sleep"),
    ("πάω να κοιμηθώ", "I am going to sleep"),
    ("πάω για ύπνο", "I am going to sleep"),
    ("καλό ύπνο", "sleep well"),
    ("καλα να κοιμηθεις", "sleep well"),
    ("ωραία δουλειά", "nice work"),
    ("ωραία ιδέα", "nice idea"),
    ("πολύ ωραία", "very nice"),
    ("δεν δουλεύει η κοινότητα", "the community does not work"),
    ("δεν δουλεύει το δωμάτιο", "the room does not work"),
    ("δεν δουλεύει η προσωπική συνομιλία", "the private chat does not work"),
    ("η φωτογραφία δεν φαίνεται", "the photo is not visible"),
    ("το μήνυμα δεν φαίνεται", "the message is not visible"),
    ("θέλω να ανεβάσω φωτογραφία στη ροή", "I want to upload a photo to the feed"),
    ("θέλω να ανεβάσω βίντεο στη ροή", "I want to upload a video to the feed"),
    ("καλημέρα", "good morning"),
    ("καλησπέρα", "good evening"),
    ("καληνύχτα", "good night"),
    ("γεια σου", "hello"),
    ("γεια σας", "hello"),
    ("τι κάνεις", "how are you"),
    ("πώς είσαι", "how are you"),
    ("πως είσαι", "how are you"),
    ("είμαι καλά", "I am fine"),
    ("όλα καλά", "everything is fine"),
    ("σε ευχαριστώ", "thank you"),
    ("ευχαριστώ πολύ", "thank you very much"),
    ("παρακαλώ πολύ", "please"),
    ("θέλω να ανεβάσω φωτογραφία", "I want to upload a photo"),
    ("θέλω να ανεβάσω βίντεο", "I want to upload a video"),
    ("θέλω να στείλω μήνυμα", "I want to send a message"),
    ("θέλω να κάνω δημοσίευση", "I want to make a post"),
    ("ανέβασα φωτογραφία", "I uploaded a photo"),
    ("ανέβασα βίντεο", "I uploaded a video"),
    ("στείλε μου μήνυμα", "send me a message"),
    ("στείλε πρόσκληση", "send an invitation"),
    ("κάνε αποδοχή", "accept"),
    ("κάνε απόρριψη", "reject"),
    ("άνοιξε κοινότητα", "open community"),
    ("άνοιξε δωμάτιο", "open room"),
    ("προσωπική συνομιλία", "private chat"),
    ("κλειδωμένη κοινότητα", "locked community"),
    ("κλειδωμένο δωμάτιο", "locked room"),
    ("σοφά λόγια", "wise words"),
    ("χωρίς παρακολούθηση", "without surveillance"),
    ("ελεύθερη επικοινωνία", "free communication"),
    ("ελεύθερη σκέψη", "free thinking"),
    ("δεν δουλεύει", "it does not work"),
    ("δεν λειτουργεί", "it does not work"),
    ("δεν φαίνεται", "it is not visible"),
    ("δεν κρατάει", "it does not keep"),
    ("δεν αποθηκεύεται", "it is not saved"),
    ("κολλάει", "it freezes"),
    ("φορτώνει δύο φορές", "it loads twice"),
    ("βγάζει αλαμπουρνέζικα", "it shows gibberish"),
    ("καλή δουλειά", "good work"),
    ("πολύ καλή δουλειά", "very good work"),
]

_EL_EN_WORDS: dict[str, str] = {
    # pronouns / helpers
    "εγω":"I", "εσύ":"you", "εσυ":"you", "αυτός":"he", "αυτος":"he", "αυτή":"she", "αυτη":"she", "αυτό":"it", "αυτο":"it",
    "εμείς":"we", "εμεις":"we", "εσείς":"you", "εσεις":"you", "αυτοί":"they", "αυτοι":"they",
    "μου":"my", "σου":"your", "μας":"our", "σας":"your", "τους":"their", "τον":"the", "την":"the", "το":"the", "οι":"the", "τα":"the", "της":"of the", "του":"of the", "των":"of the",
    "και":"and", "ή":"or", "να":"to", "θα":"will", "δεν":"not", "μην":"do not", "με":"with", "χωρίς":"without", "χωρις":"without", "για":"for", "στο":"to the", "στη":"to the", "στην":"to the", "στον":"to the", "από":"from", "απο":"from", "σε":"to", "μέσα":"inside", "μεσα":"inside", "έξω":"outside", "εξω":"outside",
    "εδώ":"here", "εδω":"here", "εκεί":"there", "εκει":"there", "τώρα":"now", "τωρα":"now", "μετά":"after", "μετα":"after", "πριν":"before", "σήμερα":"today", "σημερα":"today", "αύριο":"tomorrow", "αυριο":"tomorrow", "χθες":"yesterday",
    # verbs
    "είμαι":"I am", "ειμαι":"I am", "είσαι":"you are", "εισαι":"you are", "είναι":"is", "ειναι":"is", "είμαστε":"we are", "ειμαστε":"we are", "είστε":"you are", "ειστε":"you are",
    "έχω":"I have", "εχω":"I have", "έχεις":"you have", "εχεις":"you have", "έχει":"has", "εχει":"has", "έχουμε":"we have", "εχουμε":"we have", "έχουν":"they have", "εχουν":"they have",
    "θέλω":"I want", "θελω":"I want", "θέλεις":"you want", "θελεις":"you want", "θέλει":"wants", "θελει":"wants", "θέλουμε":"we want", "θελουμε":"we want",
    "μπορώ":"I can", "μπορω":"I can", "μπορείς":"you can", "μπορεις":"you can", "μπορεί":"can", "μπορει":"can", "μπορούμε":"we can", "μπορουμε":"we can",
    "κάνω":"I do", "κανω":"I do", "κάνεις":"you do", "κανεις":"you do", "κάνει":"does", "κανει":"does", "κάνουμε":"we do", "κανουμε":"we do", "κάντε":"do", "καντε":"do",
    "γράφω":"I write", "γραφω":"I write", "γράφει":"writes", "γραφει":"writes", "γράψε":"write", "γραψε":"write", "στέλνω":"I send", "στελνω":"I send", "στείλε":"send", "στειλε":"send", "στέλνει":"sends", "στελνει":"sends",
    "ανεβάζω":"I upload", "ανεβαζω":"I upload", "ανέβασμα":"upload", "ανεβασμα":"upload", "ανέβασε":"upload", "ανεβασε":"upload", "δημοσιεύω":"I publish", "δημοσιευω":"I publish", "δημοσίευση":"post", "δημοσιευση":"post", "δημοσίευσε":"post", "δημοσιευσε":"post",
    "βλέπω":"I see", "βλεπω":"I see", "βλέπει":"sees", "βλεπει":"sees", "φαίνεται":"is visible", "φαινεται":"is visible", "σβήνω":"I delete", "σβηνω":"I delete", "σβήσε":"delete", "σβησε":"delete", "σβήνει":"deletes", "σβηνει":"deletes", "κρατάει":"keeps", "κραταει":"keeps", "αποθηκεύει":"saves", "αποθηκευει":"saves", "αποθήκευση":"save", "αποθηκευση":"save",
    "ανοίγω":"I open", "ανοιγω":"I open", "άνοιγμα":"open", "ανοιγμα":"open", "άνοιξε":"open", "ανοιξε":"open", "κλείσε":"close", "κλεισε":"close", "κλείνει":"closes", "κλεινει":"closes", "φορτώνει":"loads", "φορτωνει":"loads", "λειτουργεί":"works", "λειτουργει":"works", "δουλεύει":"works", "δουλευει":"works", "κολλάει":"freezes", "κολλαει":"freezes",
    # app nouns
    "ιθάκη":"ITHAKI", "ιθακη":"ITHAKI", "οκ":"OK", "προφίλ":"profile", "προφιλ":"profile", "επισκέπτης":"visitor", "επισκεπτης":"visitor", "μέλος":"member", "μελος":"member", "ψευδώνυμο":"nickname", "ψευδωνυμο":"nickname", "όνομα":"name", "ονομα":"name",
    "ροή":"feed", "ροη":"feed", "ανέβασμα":"upload", "ανεβασμα":"upload", "media":"media", "μίντια":"media", "μιντια":"media", "φωτογραφία":"photo", "φωτογραφια":"photo", "βίντεο":"video", "βιντεο":"video", "ήχος":"sound", "ηχος":"sound", "ηχογράφηση":"recording", "ηχογραφηση":"recording", "αρχείο":"file", "αρχειο":"file", "αρχεία":"files", "αρχεια":"files",
    "μήνυμα":"message", "μηνυμα":"message", "μηνύματα":"messages", "μηνυματα":"messages", "σχόλιο":"comment", "σχολιο":"comment", "σχόλια":"comments", "σχολια":"comments", "αντίδραση":"reaction", "αντιδραση":"reaction", "αντιδράσεις":"reactions", "αντιδρασεις":"reactions",
    "επαφή":"contact", "επαφη":"contact", "επαφές":"contacts", "επαφες":"contacts", "πρόσκληση":"invitation", "προσκληση":"invitation", "αποδοχή":"accept", "αποδοχη":"accept", "απόρριψη":"reject", "απορριψη":"reject", "κλήση":"call", "κληση":"call", "καμπανάκι":"bell", "καμπανακι":"bell", "ειδοποίηση":"notification", "ειδοποιηση":"notification", "ειδοποιήσεις":"notifications", "ειδοποιησεις":"notifications",
    "συνομιλία":"chat", "συνομιλια":"chat", "προσωπική":"private", "προσωπικη":"private", "δωμάτιο":"room", "δωματιο":"room", "δωμάτια":"rooms", "δωματια":"rooms", "κοινότητα":"community", "κοινοτητα":"community", "κοινότητες":"communities", "κοινοτητες":"communities", "κλειδωμένο":"locked", "κλειδωμενο":"locked", "κλειδωμένη":"locked", "κλειδωμενη":"locked", "κωδικός":"code", "κωδικος":"code",
    "δημόσιο":"public", "δημοσιο":"public", "δημόσια":"public", "δημοσια":"public", "ιδιωτικό":"private", "ιδιωτικο":"private", "ανώνυμη":"anonymous", "ανωνυμη":"anonymous", "ανώνυμος":"anonymous", "ανωνυμος":"anonymous",
    # common adjectives / UI
    "καλό":"good", "καλο":"good", "καλή":"good", "καλη":"good", "καλός":"good", "καλος":"good", "ωραίο":"nice", "ωραιο":"nice", "όμορφο":"beautiful", "ομορφο":"beautiful", "μεγάλο":"big", "μεγαλο":"big", "μικρό":"small", "μικρο":"small", "πλήρης":"complete", "πληρης":"complete", "καθαρό":"clean", "καθαρο":"clean", "σταθερό":"stable", "σταθερο":"stable", "ενεργό":"active", "ενεργο":"active", "ασφαλές":"safe", "ασφαλες":"safe", "σωστό":"correct", "σωστο":"correct",
    "κουμπί":"button", "κουμπι":"button", "κουμπιά":"buttons", "κουμπια":"buttons", "οθόνη":"screen", "οθονη":"screen", "αρχική":"home", "αρχικη":"home", "μενού":"menu", "μενου":"menu", "ρυθμίσεις":"settings", "ρυθμισεις":"settings", "γλώσσα":"language", "γλωσσα":"language", "μετάφραση":"translation", "μεταφραση":"translation", "αγγλικά":"English", "αγγλικα":"English", "ελληνικά":"Greek", "ελληνικα":"Greek", "αρχαία":"ancient", "αρχαια":"ancient",
    "απόρρητο":"privacy", "απορρητο":"privacy", "όροι":"terms", "οροι":"terms", "διαγραφή":"delete", "διαγραφη":"delete", "ασφάλεια":"security", "ασφαλεια":"security", "κρυπτογράφηση":"encryption", "κρυπτογραφηση":"encryption", "παρακολούθηση":"surveillance", "παρακολουθηση":"surveillance", "ελευθερία":"freedom", "ελευθερια":"freedom", "σκέψη":"thinking", "σκεψη":"thinking", "επικοινωνία":"communication", "επικοινωνια":"communication",
    # simple daily words added in v1.7.5 local dictionary database
    "κοιμάμαι":"I sleep", "κοιμαμαι":"I sleep", "κοιμηθώ":"sleep", "κοιμηθω":"sleep", "κοιμηθείς":"sleep", "κοιμηθεις":"sleep", "κοιμήσου":"sleep", "κοιμησου":"sleep", "ύπνος":"sleep", "υπνος":"sleep", "ύπνο":"sleep", "υπνο":"sleep",
    "ωραία":"nice", "ωραια":"nice", "ωραίες":"nice", "ωραιες":"nice", "ωραίος":"nice", "ωραιος":"nice", "τέλεια":"perfect", "τελεια":"perfect", "καταπληκτικό":"amazing", "καταπληκτικο":"amazing",
    "λίγο":"a little", "λιγο":"a little", "πολύ":"very", "πολυ":"very", "απλό":"simple", "απλο":"simple", "απλές":"simple", "απλες":"simple", "λέξεις":"words", "λεξεις":"words", "λέξη":"word", "λεξη":"word",
}

# English phrasebook. Keep it practical and symmetric, not perfect.
_EN_EL_PHRASES: list[tuple[str, str]] = [
    ("welcome to ithaki", "καλώς ήρθες στην ΙΘΑΚΗ"),
    ("without fear", "χωρίς φόβο"),
    ("freedom of speech", "ελευθερία λόγου"),
    ("feel free", "νιώσε ελεύθερος"),
    ("we do not ask for email", "δεν ζητάμε email"),
    ("we do not ask for a phone number", "δεν ζητάμε τηλέφωνο"),
    ("we do not keep history", "δεν κρατάμε ιστορικό"),
    ("give value to yourself", "δώσε αξία στον εαυτό σου"),

    ("I want to sleep", "θέλω να κοιμηθώ"),
    ("I am going to sleep", "πάω να κοιμηθώ"),
    ("sleep well", "καλό ύπνο"),
    ("nice work", "ωραία δουλειά"),
    ("nice idea", "ωραία ιδέα"),
    ("very nice", "πολύ ωραία"),
    ("I want to upload a photo to the feed", "θέλω να ανεβάσω φωτογραφία στη ροή"),
    ("I want to upload a video to the feed", "θέλω να ανεβάσω βίντεο στη ροή"),
    ("the private chat does not work", "η προσωπική συνομιλία δεν δουλεύει"),
    ("the community does not work", "η κοινότητα δεν δουλεύει"),
    ("the room does not work", "το δωμάτιο δεν δουλεύει"),
    ("the message is not visible", "το μήνυμα δεν φαίνεται"),
    ("the photo is not visible", "η φωτογραφία δεν φαίνεται"),
    ("send a message to the room", "στείλε μήνυμα στο δωμάτιο"),
    ("send a message to the community", "στείλε μήνυμα στην κοινότητα"),
    ("good morning", "καλημέρα"),
    ("good evening", "καλησπέρα"),
    ("good night", "καληνύχτα"),
    ("hello", "γεια σου"),
    ("how are you", "πώς είσαι"),
    ("I am fine", "είμαι καλά"),
    ("everything is fine", "όλα καλά"),
    ("thank you very much", "ευχαριστώ πολύ"),
    ("thank you", "ευχαριστώ"),
    ("I want to upload a photo", "θέλω να ανεβάσω φωτογραφία"),
    ("I want to upload a video", "θέλω να ανεβάσω βίντεο"),
    ("I want to send a message", "θέλω να στείλω μήνυμα"),
    ("I want to make a post", "θέλω να κάνω δημοσίευση"),
    ("I uploaded a photo", "ανέβασα φωτογραφία"),
    ("I uploaded a video", "ανέβασα βίντεο"),
    ("send me a message", "στείλε μου μήνυμα"),
    ("send an invitation", "στείλε πρόσκληση"),
    ("private chat", "προσωπική συνομιλία"),
    ("locked community", "κλειδωμένη κοινότητα"),
    ("locked room", "κλειδωμένο δωμάτιο"),
    ("wise words", "σοφά λόγια"),
    ("without surveillance", "χωρίς παρακολούθηση"),
    ("free communication", "ελεύθερη επικοινωνία"),
    ("free thinking", "ελεύθερη σκέψη"),
    ("it does not work", "δεν δουλεύει"),
    ("it is not visible", "δεν φαίνεται"),
    ("it does not keep", "δεν κρατάει"),
    ("it is not saved", "δεν αποθηκεύεται"),
    ("it freezes", "κολλάει"),
    ("it loads twice", "φορτώνει δύο φορές"),
    ("it shows gibberish", "βγάζει αλαμπουρνέζικα"),
    ("good work", "καλή δουλειά"),
    ("very good work", "πολύ καλή δουλειά"),
]

_EN_EL_WORDS: dict[str, str] = {
    "i":"εγώ", "you":"εσύ", "he":"αυτός", "she":"αυτή", "it":"αυτό", "we":"εμείς", "they":"αυτοί",
    "my":"μου", "your":"σου", "our":"μας", "their":"τους", "the":"το", "and":"και", "or":"ή", "to":"να", "will":"θα", "not":"δεν", "do":"κάνω", "with":"με", "without":"χωρίς", "for":"για", "from":"από", "inside":"μέσα", "outside":"έξω", "here":"εδώ", "there":"εκεί", "now":"τώρα", "after":"μετά", "before":"πριν", "today":"σήμερα", "tomorrow":"αύριο", "yesterday":"χθες",
    "am":"είμαι", "are":"είσαι", "is":"είναι", "be":"είμαι", "have":"έχω", "has":"έχει", "want":"θέλω", "wants":"θέλει", "can":"μπορώ", "cannot":"δεν μπορώ", "could":"μπορούσα", "make":"κάνω", "write":"γράφω", "send":"στείλε", "sends":"στέλνει", "upload":"ανέβασμα", "uploaded":"ανέβασα", "publish":"δημοσίευση", "post":"δημοσίευση", "posted":"δημοσιεύτηκε", "see":"βλέπω", "visible":"φαίνεται", "delete":"διαγραφή", "save":"αποθήκευση", "saved":"αποθηκεύτηκε", "open":"άνοιγμα", "close":"κλείσιμο", "loads":"φορτώνει", "works":"δουλεύει", "freezes":"κολλάει",
    "ithaki":"ΙΘΑΚΗ", "profile":"προφίλ", "visitor":"επισκέπτης", "member":"μέλος", "nickname":"ψευδώνυμο", "name":"όνομα", "feed":"ροή", "media":"media", "photo":"φωτογραφία", "video":"βίντεο", "sound":"ήχος", "audio":"ήχος", "recording":"ηχογράφηση", "file":"αρχείο", "files":"αρχεία", "message":"μήνυμα", "messages":"μηνύματα", "comment":"σχόλιο", "comments":"σχόλια", "reaction":"αντίδραση", "reactions":"αντιδράσεις", "contact":"επαφή", "contacts":"επαφές", "invitation":"πρόσκληση", "invite":"πρόσκληση", "accept":"αποδοχή", "reject":"απόρριψη", "call":"κλήση", "bell":"καμπανάκι", "notification":"ειδοποίηση", "notifications":"ειδοποιήσεις", "chat":"συνομιλία", "private":"προσωπικό", "room":"δωμάτιο", "rooms":"δωμάτια", "community":"κοινότητα", "communities":"κοινότητες", "locked":"κλειδωμένο", "code":"κωδικός", "public":"δημόσιο", "anonymous":"ανώνυμος",
    "good":"καλό", "nice":"ωραίο", "beautiful":"όμορφο", "big":"μεγάλο", "small":"μικρό", "complete":"πλήρες", "clean":"καθαρό", "stable":"σταθερό", "active":"ενεργό", "safe":"ασφαλές", "correct":"σωστό", "button":"κουμπί", "buttons":"κουμπιά", "screen":"οθόνη", "home":"αρχική", "menu":"μενού", "settings":"ρυθμίσεις", "language":"γλώσσα", "translation":"μετάφραση", "english":"αγγλικά", "greek":"ελληνικά", "ancient":"αρχαία", "privacy":"απόρρητο", "terms":"όροι", "security":"ασφάλεια", "encryption":"κρυπτογράφηση", "surveillance":"παρακολούθηση", "freedom":"ελευθερία", "thinking":"σκέψη", "communication":"επικοινωνία",
    # simple daily words added in v1.7.5 local dictionary database
    "sleep":"ύπνος", "sleeping":"κοιμάμαι", "slept":"κοιμήθηκα", "perfect":"τέλεια", "amazing":"καταπληκτικό", "simple":"απλό", "word":"λέξη", "words":"λέξεις", "little":"λίγο", "very":"πολύ",
}


def _norm_lang_code(code: str) -> str:
    code = (code or "auto").lower().strip()
    aliases = {
        "gr": "el", "greek": "el", "ell": "el", "el-gr": "el", "el_gr": "el",
        "english": "en", "eng": "en", "en-us": "en", "en-gb": "en",
    }
    return aliases.get(code, code)




TRANSLATION_EXTERNAL_CONFIG_FILE = ROOT / "translation_external_config.json"

def _load_external_translation_config() -> dict:
    """Read optional external translation config file.

    Environment variables still win. This file is easier for Windows users than
    setting env vars before each server start.
    """
    try:
        if TRANSLATION_EXTERNAL_CONFIG_FILE.exists():
            data = json.loads(TRANSLATION_EXTERNAL_CONFIG_FILE.read_text(encoding="utf-8"))
            if isinstance(data, dict):
                return data
    except Exception:
        pass
    return {}

def _external_cfg_value(name: str, env_name: str = "", default: str = "") -> str:
    env_name = env_name or name
    v = os.getenv(env_name, "").strip()
    if v:
        return v
    cfg = _load_external_translation_config()
    for key in (name, name.lower(), env_name, env_name.lower()):
        val = cfg.get(key)
        if val is not None and str(val).strip():
            return str(val).strip()
    return default

def _external_provider_configured(provider: str) -> bool:
    provider = (provider or "").strip().lower()
    if provider == "deepl":
        return bool(_external_cfg_value("deepl_api_key", "ITHAKI_DEEPL_API_KEY"))
    if provider == "google":
        return bool(_external_cfg_value("google_api_key", "ITHAKI_GOOGLE_TRANSLATE_API_KEY"))
    if provider == "libretranslate":
        return bool(_external_cfg_value("libretranslate_url", "ITHAKI_LIBRETRANSLATE_URL"))
    if provider == "auto":
        return any(_external_provider_configured(p) for p in ("deepl", "google", "libretranslate"))
    return False

def _choose_external_provider(provider: str = "auto") -> str:
    provider = (provider or "auto").strip().lower()
    if provider != "auto":
        return provider
    configured = _external_cfg_value("provider", "ITHAKI_TRANSLATION_EXTERNAL_PROVIDER", "").lower()
    if configured and configured != "auto":
        return configured
    for candidate in ("deepl", "google", "libretranslate"):
        if _external_provider_configured(candidate):
            return candidate
    return "deepl"

def _external_translate(text: str, src: str, dst: str, provider: str = "auto") -> tuple[str | None, str, str]:
    """Legal external translation hook. OFF unless the member explicitly allows it from the app."""
    text = (text or "").strip()
    if not text:
        return None, "external-none", "empty text"
    src = _norm_lang_code(src)
    dst = _norm_lang_code(dst)
    provider = (provider or "auto").strip().lower()
    provider = _choose_external_provider(provider)
    try:
        import urllib.parse
        import urllib.request
        if provider == "deepl":
            key = _external_cfg_value("deepl_api_key", "ITHAKI_DEEPL_API_KEY")
            if not key:
                return None, "deepl", "DeepL API key missing. Set ITHAKI_DEEPL_API_KEY or server/translation_external_config.json"
            url = _external_cfg_value("deepl_api_url", "ITHAKI_DEEPL_API_URL", "https://api-free.deepl.com/v2/translate")
            target = "EL" if dst == "el" else "EN"
            source = "EL" if src == "el" else "EN"
            body = urllib.parse.urlencode({"auth_key": key, "text": text, "source_lang": source, "target_lang": target}).encode("utf-8")
            req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
            with urllib.request.urlopen(req, timeout=12) as r:
                data = json.loads(r.read().decode("utf-8", "replace"))
            out = (((data.get("translations") or [{}])[0]).get("text") or "").strip()
            return (out or None), "deepl", "ok" if out else "empty response"
        if provider == "google":
            key = _external_cfg_value("google_api_key", "ITHAKI_GOOGLE_TRANSLATE_API_KEY")
            if not key:
                return None, "google", "Google Translate API key missing. Set ITHAKI_GOOGLE_TRANSLATE_API_KEY or server/translation_external_config.json"
            url = "https://translation.googleapis.com/language/translate/v2?key=" + urllib.parse.quote(key)
            payload = json.dumps({"q": text, "source": src, "target": dst, "format": "text"}).encode("utf-8")
            req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"}, method="POST")
            with urllib.request.urlopen(req, timeout=12) as r:
                data = json.loads(r.read().decode("utf-8", "replace"))
            out = (((data.get("data") or {}).get("translations") or [{}])[0].get("translatedText") or "").strip()
            return (out or None), "google", "ok" if out else "empty response"
        if provider == "libretranslate":
            url = _external_cfg_value("libretranslate_url", "ITHAKI_LIBRETRANSLATE_URL")
            if not url:
                return None, "libretranslate", "LibreTranslate URL missing. Set ITHAKI_LIBRETRANSLATE_URL or server/translation_external_config.json"
            api_key = _external_cfg_value("libretranslate_api_key", "ITHAKI_LIBRETRANSLATE_API_KEY")
            payload_obj = {"q": text, "source": src, "target": dst, "format": "text"}
            if api_key:
                payload_obj["api_key"] = api_key
            payload = json.dumps(payload_obj).encode("utf-8")
            req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"}, method="POST")
            with urllib.request.urlopen(req, timeout=14) as r:
                data = json.loads(r.read().decode("utf-8", "replace"))
            out = (data.get("translatedText") or data.get("translated_text") or "").strip()
            return (out or None), "libretranslate", "ok" if out else "empty response"
        return None, provider or "external", "unsupported provider"
    except Exception as exc:
        return None, provider or "external", str(exc)

def _argos_translate(text: str, src: str, dst: str) -> str | None:
    """Use the real offline Argos model when installed.

    This function intentionally does NOT raise. The app must keep working even
    when the model is missing. Diagnostics are available at /api/argos/status.
    """
    text = text or ""
    src = _norm_lang_code(src)
    dst = _norm_lang_code(dst)
    try:
        from argos_offline_service import translate_offline_argos
        data = translate_offline_argos(text, src, dst)
        if data.get("ok") and data.get("translated"):
            out = str(data.get("translated", "")).strip()
            if out and out != text:
                return out
    except Exception:
        pass
    try:
        import argostranslate.translate  # type: ignore
        langs = argostranslate.translate.get_installed_languages()
        s = next((l for l in langs if getattr(l, "code", "") == src), None)
        d = next((l for l in langs if getattr(l, "code", "") == dst), None)
        if s and d:
            tr = s.get_translation(d)
            if tr:
                out = tr.translate(text)
                if out and out.strip() and out.strip() != text:
                    return out.strip()
    except Exception:
        pass
    return None


def _has_greek_letters(value: str) -> bool:
    return any("\u0370" <= ch <= "\u03ff" or "\u1f00" <= ch <= "\u1fff" for ch in (value or ""))


def _has_latin_letters(value: str) -> bool:
    return any("a" <= ch.lower() <= "z" for ch in (value or ""))


def _translation_needs_dictionary_help(original: str, translated: str, src: str, dst: str) -> bool:
    """Detect weak Argos results and allow the local dictionary to help.

    Argos can be active but still leave short/simple words unchanged, e.g.
    'κοιμηθώ' or 'ωραία'. In v1.7.5 we do not trust the model blindly.
    If the output still contains source-script words or barely changed text,
    the local phrasebook/dictionary runs as a second pass.
    """
    original = (original or "").strip()
    translated = (translated or "").strip()
    if not translated:
        return True
    if translated == original:
        return True
    if src == "el" and dst == "en" and _has_greek_letters(translated):
        return True
    if src == "en" and dst == "el" and _has_latin_letters(translated):
        # OK ITHAKI / media / URL-like words are allowed, but if most output is Latin, help it.
        letters = [c for c in translated if c.isalpha()]
        latin = sum(1 for c in letters if "a" <= c.lower() <= "z")
        if letters and latin / max(1, len(letters)) > 0.35:
            return True
    # Very short texts need dictionary help more often because neural models
    # sometimes return the input for single words.
    if len(original.split()) <= 3 and len(translated.split()) <= 3:
        if src == "el" and dst == "en" and _has_greek_letters(translated):
            return True
        if src == "en" and dst == "el" and translated.lower() == original.lower():
            return True
    return False


def _replace_phrases(text: str, phrases: list[tuple[str, str]]) -> str:
    out = text or ""
    stripped = (text or "").strip()
    for source, target in sorted(phrases, key=lambda x: -len(x[0])):
        target = _choose_single_translation_variant(target)
        if stripped and (_strip_el_accents(stripped).lower() == _strip_el_accents(source).lower() or _same_loose_el(stripped, source)):
            return _smart_cap(stripped, target)
    boundary = r"(?<![0-9A-Za-zΑ-Ωα-ωΆ-ώ]){}(?![0-9A-Za-zΑ-Ωα-ωΆ-ώ])"
    for source, target in sorted(phrases, key=lambda x: -len(x[0])):
        target = _choose_single_translation_variant(target)
        variants = {source, _strip_el_accents(source), _loose_el_key(source)}
        for v in sorted(variants, key=len, reverse=True):
            if not v:
                continue
            out = re.sub(boundary.format(re.escape(v)), lambda m: _smart_cap(m.group(0), target), out, flags=re.IGNORECASE)
    return out


def _word_by_word(text: str, words: dict[str, str], src: str) -> str:
    # v2.2.1 real strict whole-word mode. Unknown words are not guessed here.
    parts = re.split(r"(\s+|[.,;:!?()\[\]{}\"'«»“”’]+)", text or "")
    out: list[str] = []
    for part in parts:
        if part is None or part == "":
            continue
        if not any(ch.isalpha() for ch in part):
            out.append(part)
            continue
        key = part.lower()
        if src == "el":
            key = _strip_el_accents(key)
            target = words.get(key) or words.get(_loose_el_key(key))
        else:
            target = words.get(key)
        if target:
            out.append(_smart_cap(part, _choose_single_translation_variant(target)))
        else:
            out.append(part)
    return "".join(out)


def _cleanup_translation_spaces(text: str) -> str:
    text = re.sub(r"\s+([.,;:!?])", r"\1", text or "")
    text = re.sub(r"\s{2,}", " ", text)
    return text.strip()


def _translation_has_mixed_token(text: str) -> bool:
    for tok in re.findall(r"\S+", text or ""):
        has_el = _has_greek_letters(tok)
        has_en = _has_latin_letters(tok)
        if has_el and has_en and tok.lower() not in {"ok", "ithaki", "wi-fi", "wifi", "ngrok", "pdf", "word", "doc", "docx", "html", "api", "url", "host", "server", "apk", "ark"}:
            return True
    return False


def _choose_single_translation_variant(text: str) -> str:
    """v2.2.1 real strict cleaner: no [..], no slash alternatives, no doubled words."""
    text = str(text or "").strip()
    if not text:
        return ""
    text = re.sub(r"\[(?:EN|EL|GR|AUTO|BETA|beta|translation|local|offline|Argos|argos)[^\]]{0,80}\]", " ", text, flags=re.IGNORECASE)
    text = re.sub(r"\[[^\]\n]{0,80}\]", " ", text)
    def pick(match):
        left = (match.group(1) or "").strip()
        right = (match.group(2) or "").strip()
        return left or right
    for _ in range(3):
        new_text = re.sub(r"(?<!https:)\b([^\n/|]{1,42}?)\s*[/|]\s*([^\n/|]{1,42}?)\b", pick, text)
        if new_text == text:
            break
        text = new_text
    text = text.replace("/", " ").replace("[", " ").replace("]", " ").replace("|", " ")
    words = text.split()
    dedup = []
    prev = None
    for w in words:
        key = _strip_el_accents(w).lower().strip(".,;:!?(){}'\"«»“”’")
        if key and key == prev:
            continue
        dedup.append(w)
        prev = key
    return _cleanup_translation_spaces(" ".join(dedup))


def _local_en_el_fallback(text: str, src: str, dst: str) -> str:
    """EN/EL local fallback with v1.7.5 SQLite dictionary database.

    Order:
    1) local_dictionary_service SQLite DB (phrases + words + custom CSV/imported data)
    2) old in-file emergency phrasebook/wordbook
    3) original text with safe beta label

    This keeps the app stable and lets us grow the dictionary without touching
    the main server file again.
    """
    text = text or ""
    try:
        from local_dictionary_service import translate_with_local_db
        dbres = translate_with_local_db(text, src, dst)
        dbout = str(dbres.get("translated", "")).strip()
        if dbres.get("ok") and dbout and dbout != text:
            return dbout
    except Exception as exc:
        try:
            LOG.debug("local dictionary DB fallback skipped: %s", exc)
        except Exception:
            pass

    word_count = len([w for w in re.split(r"\s+", text.strip()) if w])
    if (src, dst) == ("el", "en"):
        out = _replace_phrases(text, _EL_EN_PHRASES)
        if word_count <= 3:
            out = _word_by_word(out, _EL_EN_WORDS, "el")
    elif (src, dst) == ("en", "el"):
        out = _replace_phrases(text, _EN_EL_PHRASES)
        if word_count <= 3:
            out = _word_by_word(out, _EN_EL_WORDS, "en")
    else:
        return text
    out = _cleanup_translation_spaces(out)
    if not out:
        out = text
    if out == text:
        # v2.1.8: άγνωστη λέξη/πρόταση μένει όπως είναι.
        # Δεν εμφανίζουμε [EN beta]/[EL beta], slash ή διπλές εναλλακτικές στον χρήστη.
        out = text
    return _choose_single_translation_variant(out)


def _log_unknowns_from_bridge(text: str, src: str, dst: str) -> dict:
    try:
        from local_dictionary_service import log_unknown_terms
        return log_unknown_terms(text, src, dst, text)
    except Exception as exc:
        return {"ok": False, "added": 0, "note": str(exc)}


def _clean_or_reject_translation(candidate: str, original: str) -> str:
    out = _choose_single_translation_variant(candidate or "")
    if not out:
        return original
    if "[" in out or "]" in out or "/" in out or _translation_has_mixed_token(out):
        return original
    return out


def bridge_translate(text: str, src: str = "auto", dst: str = "en", allow_external: bool = False, mode: str = "offline", external_provider: str = "auto") -> dict:
    """Strict EN/EL translation bridge v2.1.9.

    Rules requested by the owner:
    - one clean translation only;
    - no slash alternatives;
    - no [beta] or bracket labels;
    - no half-English/half-Greek tokens;
    - unknown words remain original and are written to CSV for later owner correction.
    """
    text, src, dst = (text or ""), _norm_lang_code(src or "auto"), _norm_lang_code(dst or "en")
    mode = (mode or "offline").strip().lower()
    if mode not in ("offline", "external", "hybrid"):
        mode = "offline"
    if dst not in ("en", "el"):
        dst = "en"
    detected_src = _detect_lang_basic(text)
    if detected_src in ("en", "el"):
        if src == "auto" or src not in ("en", "el"):
            src = detected_src
        elif detected_src != src:
            if detected_src == "el" and _has_greek_letters(text):
                src = "el"
            elif detected_src == "en" and _has_latin_letters(text) and not _has_greek_letters(text):
                src = "en"
    if src == dst:
        return {"ok": True, "provider": "none-same-language", "source_lang": src, "target_lang": dst, "original": text, "translated": text, "note": "Same language.", "safe_mode": "real_translation_v221"}

    key = ("v221", text, src, dst, bool(allow_external), mode, (external_provider or "auto"))
    cached = _TRANSLATION_CACHE.get(key)
    if cached is not None:
        return {"ok": True, "provider": "cache", "source_lang": src, "target_lang": dst, "original": text, "translated": cached, "safe_mode": "real_translation_v221"}

    provider = "strict-local"
    external_note = ""
    result = ""
    argos_info = {}

    # 1) Exact/local OK ITHAKI dictionary wins first because host/app terms are prepared.
    try:
        from local_dictionary_service import translate_with_local_db
        dbres = translate_with_local_db(text, src, dst)
        dbout = _clean_or_reject_translation(str(dbres.get("translated", "")), text)
        provider = str(dbres.get("provider", "strict-local"))
        if dbout and dbout != text:
            result = dbout
    except Exception as exc:
        external_note = "local dictionary skipped: " + str(exc)

    # 2) External only if user explicitly allowed it. Clean output before showing.
    if not result and allow_external and mode in ("external", "hybrid"):
        ext, ext_provider, ext_note = _external_translate(text, src, dst, external_provider)
        external_note = ext_note
        if ext and ext.strip() and ext.strip() != text:
            cleaned = _clean_or_reject_translation(ext.strip(), text)
            if cleaned != text:
                result = cleaned
                provider = "external-" + ext_provider

    # 3) Argos only if it gives clean full text. No second broken word pass after it.
    if not result:
        try:
            from argos_offline_service import argos_status
            argos_info = argos_status()
        except Exception as exc:
            argos_info = {"ok": False, "ready": False, "note": str(exc)}
        argos = _argos_translate(text, src, dst)
        if argos and argos.strip() and argos.strip() != text:
            cleaned = _clean_or_reject_translation(argos.strip(), text)
            if cleaned != text and not _translation_needs_dictionary_help(text, cleaned, src, dst):
                result = cleaned
                provider = "argos-offline-model-clean"

    if not result:
        result = _clean_or_reject_translation(text, text)
        provider = "unknown-kept-original-logged"
        if not argos_info:
            try:
                from argos_offline_service import argos_status
                argos_info = argos_status()
            except Exception as exc:
                argos_info = {"ok": False, "ready": False, "note": str(exc)}

    unknown_info = _log_unknowns_from_bridge(text, src, dst)
    if len(_TRANSLATION_CACHE) >= _TRANSLATION_CACHE_MAX:
        _TRANSLATION_CACHE.clear()
    _TRANSLATION_CACHE[key] = result
    return {
        "ok": True,
        "provider": provider,
        "source_lang": src,
        "target_lang": dst,
        "original": text,
        "translated": result,
        "note": "v2.2.1 real strict: one clean translation, no slash/brackets, unknown words stay original and are logged.",
        "unknown_terms_added": int((unknown_info or {}).get("added", 0) or 0),
        "unknown_terms_file": str((unknown_info or {}).get("path", "")),
        "external_allowed": bool(allow_external),
        "mode": mode,
        "external_note": external_note,
        "safe_mode": "real_translation_v249141",
        "translation_ready": bool((argos_info or {}).get("ready")) or provider not in {"unknown-kept-original-logged"},
        "argos_ready": bool((argos_info or {}).get("ready")),
        "argos_status": argos_info or {},
        "action_required": "install_argos_el_en_en_el" if provider == "unknown-kept-original-logged" and not bool((argos_info or {}).get("ready")) else "",
        "message": "Offline Argos EN/EL model is missing or incomplete on this laptop. Run 02_INSTALL_ARGOS_EN_EL_OFFLINE_MODEL.cmd and restart." if provider == "unknown-kept-original-logged" and not bool((argos_info or {}).get("ready")) else "",
    }







# ──────────────────────────────────────────────
# FastAPI app + Pydantic models
# ──────────────────────────────────────────────
app = FastAPI(title=APP_NAME, version=VERSION)

# V249239 emergency fix: V249238 notification routes must be registered after app = FastAPI(...).
@app.get("/api/notification/preferences")
def api_notification_preferences_v249238(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    device_id = str(device_id or "").strip()[:160]
    if not device_id:
        return {"ok": False, "reason": "device_id_required"}
    return {"ok": True, "preferences": _notif_pref_row_v249238(room_id, device_id), "version": "V249238-notification-preferences"}


@app.post("/api/notification/preferences")
def api_set_notification_preferences_v249238(payload: dict):
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    device_id = str(payload.get("device_id") or "")[:160]
    if not device_id:
        raise HTTPException(400, "device_id required")
    def bit(k, default=1):
        return 1 if bool(payload.get(k, default)) else 0
    quiet_start = str(payload.get("quiet_start") or "22:00")[:5]
    quiet_end = str(payload.get("quiet_end") or "08:00")[:5]
    init_notification_preferences_v249238()
    execute("""
    INSERT INTO notification_preferences_v249238(room_id,device_id,messages,calls,likes,friend_requests,mentions,quiet_enabled,quiet_start,quiet_end,updated_at)
    VALUES(?,?,?,?,?,?,?,?,?,?,?)
    ON CONFLICT(room_id,device_id) DO UPDATE SET
      messages=excluded.messages,calls=excluded.calls,likes=excluded.likes,friend_requests=excluded.friend_requests,mentions=excluded.mentions,
      quiet_enabled=excluded.quiet_enabled,quiet_start=excluded.quiet_start,quiet_end=excluded.quiet_end,updated_at=excluded.updated_at
    """, (room_id, device_id, bit("messages"), bit("calls"), bit("likes"), bit("friend_requests"), bit("mentions"), bit("quiet_enabled", 0), quiet_start, quiet_end, now()))
    return {"ok": True, "preferences": _notif_pref_row_v249238(room_id, device_id), "version": "V249238-notification-preferences"}


@app.get("/api/notifications/count")
def api_notifications_count_v249238(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    init_social_v166_db()
    device_id = str(device_id or "").strip()[:160]
    if not device_id:
        return {"ok": True, "count": 0, "incoming_pending_friend_requests": 0, "outgoing_pending_friend_requests": 0}
    incoming = int((one("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending' AND to_id=?", (room_id, device_id)) or {"n": 0})["n"] or 0)
    outgoing = int((one("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending' AND from_id=?", (room_id, device_id)) or {"n": 0})["n"] or 0)
    return {"ok": True, "count": incoming, "incoming_pending_friend_requests": incoming, "outgoing_pending_friend_requests": outgoing, "policy": "badge counts incoming requests that this device can accept/reject"}

app.add_middleware(GZipMiddleware, minimum_size=500)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True,
                   allow_methods=["*"], allow_headers=["*"])


# ──────────────────────────────────────────────
# ITHAKI BotGuard / Anti-Spam / Data-Safety Shield (V24713)
# Server-side protection. Keeps only minimal security metadata.
# No email/phone/real identity is required for bot protection.
# IP addresses are stored only as SHA-256 hashes.
# ──────────────────────────────────────────────
SECURITY_ENABLED = os.getenv("ITHAKI_SECURITY_ENABLED", "1") != "0"
SECURITY_MAX_BODY_BYTES = int(os.getenv("ITHAKI_SECURITY_MAX_BODY_BYTES", str(12 * 1024 * 1024)))
SECURITY_EVENT_RETENTION_SECONDS = int(os.getenv("ITHAKI_SECURITY_EVENT_RETENTION_SECONDS", str(7 * 24 * 3600)))
SECURITY_AUTO_BLOCK_SECONDS = int(os.getenv("ITHAKI_SECURITY_AUTO_BLOCK_SECONDS", str(15 * 60)))
SECURITY_SALT = os.getenv("ITHAKI_SECURITY_SALT", "ITHAKI_LOCAL_SECURITY_SALT_CHANGE_ON_SERVER")

# path prefix -> action, limits [(max_count, window_seconds)]
SECURITY_PATH_LIMITS = [
    ("/api/profile/register", "register", [(4, 3600), (12, 24*3600)]),
    ("/api/people/heartbeat", "heartbeat", [(90, 60), (1200, 3600)]),
    ("/api/messages/send", "message", [(12, 60), (80, 3600)]),
    ("/api/flow/send", "flow", [(60, 60), (300, 3600)]),
    ("/api/feedback/send", "feedback", [(3, 60), (20, 3600)]),
    ("/api/report", "report", [(8, 60), (40, 3600)]),
    ("/api/media/upload", "media_upload", [(60, 60), (300, 3600)]),
    ("/api/push/fcm/register", "push_register", [(12, 60), (120, 3600)]),
]
SECURITY_GLOBAL_POST_LIMITS = [(180, 60), (1800, 3600)]


def _security_hash(value: str) -> str:
    value = str(value or "").strip()
    if not value:
        return ""
    return hashlib.sha256((SECURITY_SALT + "|" + value).encode("utf-8", "ignore")).hexdigest()


def _security_client_ip(request: Request) -> str:
    # Works with ngrok / Cloudflare / reverse proxy. Only a hash is stored.
    for h in ("cf-connecting-ip", "x-real-ip", "x-forwarded-for"):
        v = request.headers.get(h, "")
        if v:
            return v.split(",")[0].strip()[:80]
    try:
        return (request.client.host or "")[:80]
    except Exception:
        return ""


def _security_init_db():
    execute("""
    CREATE TABLE IF NOT EXISTS security_events(
        event_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL DEFAULT '',
        ip_hash TEXT NOT NULL DEFAULT '',
        device_id TEXT NOT NULL DEFAULT '',
        action TEXT NOT NULL DEFAULT '',
        path TEXT NOT NULL DEFAULT '',
        method TEXT NOT NULL DEFAULT '',
        status TEXT NOT NULL DEFAULT '',
        detail TEXT NOT NULL DEFAULT '',
        user_agent TEXT NOT NULL DEFAULT '',
        created_at REAL NOT NULL
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_security_events_ip_action_time ON security_events(ip_hash, action, created_at)")
    execute("CREATE INDEX IF NOT EXISTS idx_security_events_device_action_time ON security_events(room_id, device_id, action, created_at)")
    execute("""
    CREATE TABLE IF NOT EXISTS security_blocks(
        block_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL DEFAULT '',
        ip_hash TEXT NOT NULL DEFAULT '',
        device_id TEXT NOT NULL DEFAULT '',
        reason TEXT NOT NULL DEFAULT '',
        active INTEGER NOT NULL DEFAULT 1,
        created_at REAL NOT NULL,
        expires_at REAL NOT NULL DEFAULT 0
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_security_blocks_ip ON security_blocks(ip_hash, active, expires_at)")
    execute("CREATE INDEX IF NOT EXISTS idx_security_blocks_device ON security_blocks(room_id, device_id, active, expires_at)")


def _security_cleanup():
    try:
        _security_init_db()
        execute("DELETE FROM security_events WHERE created_at < ?", (now() - SECURITY_EVENT_RETENTION_SECONDS,))
        execute("UPDATE security_blocks SET active=0 WHERE active=1 AND expires_at>0 AND expires_at<?", (now(),))
    except Exception:
        LOG.exception("security cleanup failed")


def _security_log(room_id: str, ip_hash: str, device_id: str, action: str, path: str, method: str, status: str, detail: str = "", user_agent: str = ""):
    try:
        _security_init_db()
        execute("""
        INSERT INTO security_events(event_id,room_id,ip_hash,device_id,action,path,method,status,detail,user_agent,created_at)
        VALUES(?,?,?,?,?,?,?,?,?,?,?)
        """, (nid("sec"), (room_id or "")[:120], (ip_hash or "")[:80], (device_id or "")[:120],
              (action or "")[:80], (path or "")[:240], (method or "")[:12], (status or "")[:40],
              (detail or "")[:500], (user_agent or "")[:240], now()))
    except Exception:
        LOG.exception("security log failed")


def _security_is_blocked(room_id: str = "", ip_hash: str = "", device_id: str = "") -> tuple[bool, str]:
    if not SECURITY_ENABLED:
        return False, ""
    _security_init_db()
    t = now()
    if ip_hash:
        r = one("SELECT reason FROM security_blocks WHERE active=1 AND ip_hash=? AND (expires_at=0 OR expires_at>?) LIMIT 1", (ip_hash, t))
        if r:
            return True, str(r["reason"] or "ip_blocked")
    if device_id:
        r = one("SELECT reason FROM security_blocks WHERE active=1 AND room_id=? AND device_id=? AND (expires_at=0 OR expires_at>?) LIMIT 1", ((room_id or "")[:120], device_id[:120], t))
        if r:
            return True, str(r["reason"] or "device_blocked")
    return False, ""


def _security_rate_count(ip_hash: str, device_id: str, room_id: str, action: str, window_seconds: int, by_device: bool = False) -> int:
    cutoff = now() - int(window_seconds)
    if by_device and device_id:
        r = one("SELECT COUNT(*) AS n FROM security_events WHERE room_id=? AND device_id=? AND action=? AND created_at>=?", ((room_id or "")[:120], device_id[:120], action[:80], cutoff))
    else:
        r = one("SELECT COUNT(*) AS n FROM security_events WHERE ip_hash=? AND action=? AND created_at>=?", ((ip_hash or "")[:80], action[:80], cutoff))
    return int(r["n"] if r else 0)


def _security_block_ip(ip_hash: str, reason: str, seconds: int = SECURITY_AUTO_BLOCK_SECONDS, room_id: str = ""):
    if not ip_hash:
        return
    _security_init_db()
    t = now()
    execute("""
    INSERT INTO security_blocks(block_id,room_id,ip_hash,device_id,reason,active,created_at,expires_at)
    VALUES(?,?,?,?,?,?,?,?)
    """, (nid("blk"), (room_id or "")[:120], ip_hash[:80], "", reason[:300], 1, t, t + max(60, int(seconds))))


def _security_limits_for_path(path: str) -> tuple[str, list[tuple[int, int]]]:
    for prefix, action, limits in SECURITY_PATH_LIMITS:
        if str(path or "").startswith(prefix):
            return action, limits
    return "post", SECURITY_GLOBAL_POST_LIMITS


def _security_rate_guard(ip_hash: str, action: str, limits: list[tuple[int, int]], path: str, method: str, user_agent: str, room_id: str = ""):
    for max_count, window in limits:
        c = _security_rate_count(ip_hash, "", room_id, action, int(window), by_device=False)
        if c >= int(max_count):
            detail = f"rate_limit:{action}:{c}/{max_count}/{window}s"
            _security_log(room_id, ip_hash, "", action, path, method, "blocked_rate", detail, user_agent)
            # Auto temporary block only after repeated hard violations, not for a single small mistake.
            if c >= int(max_count) * 2:
                _security_block_ip(ip_hash, detail, SECURITY_AUTO_BLOCK_SECONDS, room_id=room_id)
            raise HTTPException(status_code=429, detail={
                "ok": False,
                "reason": "too_many_requests",
                "message": "Πολλά αιτήματα μαζί. Περίμενε λίγο και ξαναδοκίμασε.",
                "retry_after_seconds": min(int(window), 300),
                "security": "ITHAKI BotGuard"
            })


def security_member_guard(room_id: str, device_id: str, action: str, max_per_minute: int = 20, max_per_hour: int = 100):
    """Per-member flood guard for routes that expose device_id in the body/form."""
    if not SECURITY_ENABLED:
        return
    room_id = (room_id or "ithaki-phone-test")[:120]
    device_id = (device_id or "")[:120]
    if not device_id:
        return
    _security_init_db()
    blocked, reason = _security_is_blocked(room_id=room_id, device_id=device_id)
    if blocked:
        _security_log(room_id, "", device_id, action, "member_guard", "POST", "blocked_device", reason, "")
        raise HTTPException(403, {"ok": False, "reason": "device_blocked", "message": "Η συσκευή έχει μπλοκαριστεί από την ασφάλεια ΙΘΑΚΗΣ."})
    limits = [(max_per_minute, 60), (max_per_hour, 3600)]
    for max_count, window in limits:
        c = _security_rate_count("", device_id, room_id, action, window, by_device=True)
        if c >= max_count:
            _security_log(room_id, "", device_id, action, "member_guard", "POST", "blocked_device_rate", f"{c}/{max_count}/{window}s", "")
            raise HTTPException(429, {"ok": False, "reason": "member_too_fast", "message": "Στέλνεις πολύ γρήγορα. Περίμενε λίγο."})
    _security_log(room_id, "", device_id, action, "member_guard", "POST", "ok", "", "")


@app.middleware("http")
async def ithaki_botguard_middleware(request: Request, call_next):
    if not SECURITY_ENABLED:
        return await call_next(request)
    path = request.url.path or ""
    method = (request.method or "GET").upper()
    # Keep static files and normal GET reads fast. The risky abuse surface is mostly POST/PUT/PATCH/DELETE.
    if method in {"POST", "PUT", "PATCH", "DELETE"} and not path.startswith("/api/media/file/"):
        try:
            _security_init_db()
            ip_hash = _security_hash(_security_client_ip(request))
            ua = (request.headers.get("user-agent", "") or "")[:240]
            blocked, reason = _security_is_blocked(ip_hash=ip_hash)
            if blocked:
                _security_log("", ip_hash, "", "blocked_request", path, method, "blocked_ip", reason, ua)
                return JSONResponse(status_code=403, content={"ok": False, "reason": "ip_blocked", "message": "Το αίτημα κόπηκε από την ασφάλεια ΙΘΑΚΗΣ."})
            try:
                clen = int(request.headers.get("content-length") or "0")
            except Exception:
                clen = 0
            # Normal media uploads use /api/media/upload and are streamed with their own quota.
            if clen and clen > SECURITY_MAX_BODY_BYTES and not path.startswith("/api/media/upload"):
                _security_log("", ip_hash, "", "body_guard", path, method, "blocked_body", str(clen), ua)
                return JSONResponse(status_code=413, content={"ok": False, "reason": "payload_too_large", "message": "Το μήνυμα είναι πολύ μεγάλο."})
            action, limits = _security_limits_for_path(path)
            _security_rate_guard(ip_hash, action, limits, path, method, ua)
            _security_log("", ip_hash, "", action, path, method, "ok", "", ua)
        except HTTPException as e:
            return JSONResponse(status_code=e.status_code, content={"detail": e.detail})
        except Exception:
            LOG.exception("BotGuard middleware failed open")
    return await call_next(request)


@app.get("/api/security/public")
def api_security_public():
    return {
        "ok": True,
        "enabled": bool(SECURITY_ENABLED),
        "name": "ITHAKI BotGuard",
        "privacy": "IP addresses are stored only as salted hashes. Email/phone are not required for protection.",
        "protections": ["rate_limit", "temporary_ip_block", "member_flood_guard", "upload_body_guard", "admin_block_unblock", "minimal_security_logs"],
        "limits": {a: lim for _, a, lim in SECURITY_PATH_LIMITS}
    }


@app.get("/api/admin/security/events")
def api_admin_security_events(admin_key: str = Query(""), room_id: str = Query(""), limit: int = Query(200, ge=1, le=500)):
    _require_admin(admin_key)
    _security_init_db()
    if room_id:
        rs = rows("SELECT * FROM security_events WHERE room_id=? OR room_id='' ORDER BY created_at DESC LIMIT ?", (room_id[:120], int(limit)))
    else:
        rs = rows("SELECT * FROM security_events ORDER BY created_at DESC LIMIT ?", (int(limit),))
    out = []
    for r in rs:
        d = dict(r)
        # V249286: hide all poll posts from live Feed while cleaning the flow.
        try:
            if ithaki_v249286_is_poll_post_text(d.get("text") or "") or str(d.get("post_kind") or "").lower() in {"poll", "vote", "poll_card"}:
                continue
        except Exception:
            pass
        # Show short hash only, never raw IP.
        d["ip_hash"] = (d.get("ip_hash") or "")[:12]
        out.append(d)
    return {"ok": True, "events": out}


@app.get("/api/admin/security/blocks")
def api_admin_security_blocks(admin_key: str = Query(""), room_id: str = Query("")):
    _require_admin(admin_key)
    _security_init_db()
    if room_id:
        rs = rows("SELECT * FROM security_blocks WHERE room_id=? OR room_id='' ORDER BY created_at DESC LIMIT 300", (room_id[:120],))
    else:
        rs = rows("SELECT * FROM security_blocks ORDER BY created_at DESC LIMIT 300")
    out=[]
    for r in rs:
        d=dict(r); d["ip_hash"]=(d.get("ip_hash") or "")[:12]; out.append(d)
    return {"ok": True, "blocks": out}


@app.post("/api/admin/security/block")
def api_admin_security_block(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    _security_init_db()
    room_id = str(payload.get("room_id") or "")[:120]
    # Admin may pass a full hash from logs/tools or a device_id from user list.
    ip_hash = str(payload.get("ip_hash") or "")[:80]
    device_id = str(payload.get("device_id") or "")[:120]
    reason = str(payload.get("reason") or "manual_admin_block")[:300]
    seconds = int(payload.get("seconds") or 0)
    expires = now() + seconds if seconds > 0 else 0
    if not ip_hash and not device_id:
        raise HTTPException(400, "ip_hash or device_id required")
    execute("""
    INSERT INTO security_blocks(block_id,room_id,ip_hash,device_id,reason,active,created_at,expires_at)
    VALUES(?,?,?,?,?,?,?,?)
    """, (nid("blk"), room_id, ip_hash, device_id, reason, 1, now(), expires))
    return {"ok": True, "blocked": True, "room_id": room_id, "device_id": device_id, "ip_hash_short": ip_hash[:12], "expires_at": expires}


@app.post("/api/admin/security/unblock")
def api_admin_security_unblock(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    _security_init_db()
    room_id = str(payload.get("room_id") or "")[:120]
    ip_hash = str(payload.get("ip_hash") or "")[:80]
    device_id = str(payload.get("device_id") or "")[:120]
    if ip_hash:
        execute("UPDATE security_blocks SET active=0 WHERE ip_hash=?", (ip_hash,))
    if device_id:
        execute("UPDATE security_blocks SET active=0 WHERE room_id=? AND device_id=?", (room_id, device_id))
    return {"ok": True, "unblocked": True}


@app.on_event("startup")
def _start_v237_background_maintenance():
    start_background_maintenance()
    _security_cleanup()
    try:
        _hg_cleanup()
    except Exception:
        LOG.exception("humanguard cleanup failed at startup")





# ──────────────────────────────────────────────
# ITHAKI HumanGuard Control Center (V24714)
# Ελληνικά: Προστασία μετά την κανονική εγγραφή.
# Ελέγχει συμπεριφορά ανθρώπου ή bot με παραμέτρους που αλλάζει ο διαχειριστής.
# Δεν απαιτεί email/τηλέφωνο/πραγματική ταυτότητα. Κρατά μόνο τεχνική συμπεριφορά.
# ──────────────────────────────────────────────
HUMANGUARD_DEFAULTS = {
    "enabled": 1,
    "new_account_soft_minutes": 180,
    "new_account_max_messages_hour": 12,
    "new_account_max_flow_hour": 5,
    "message_max_per_minute": 8,
    "message_max_per_hour": 60,
    "flow_max_per_hour": 25,
    "feedback_max_per_hour": 10,
    "same_text_max_per_hour": 3,
    "long_message_min_seconds": 8,
    "long_message_chars": 350,
    "max_text_chars": 2500,
    "link_max_per_hour": 8,
    "suspicious_score_mute": 8,
    "auto_mute_minutes": 30,
    "event_retention_days": 14,
    "show_public_slogan": 1,
}

HUMANGUARD_TEXT_EL = {
    "title": "🛡 Κέντρο Ελέγχου BotGuard / HumanGuard",
    "subtitle": "Η ΙΘΑΚΗ κρατά την εγγραφή απλή, αλλά ελέγχει συμπεριφορές που μοιάζουν με bot, flood, spam ή μαζική χειραγώγηση.",
    "slogan": "Στην ΙΘΑΚΗ κάνουμε έλεγχο για bots και μαζική χειραγώγηση που αλλοιώνουν συνειδήσεις. Ο λόγος σου μετράει, αλλά η κοινότητα προστατεύεται.",
    "privacy_note": "Δεν χρειάζεται email ή τηλέφωνο. Για ασφάλεια κρατάμε μόνο τεχνικά ίχνη συμπεριφοράς: device id εφαρμογής, χρόνο ενεργειών, τύπο ενέργειας, μετρητές ορίων και hash IP όπου χρειάζεται.",
    "blocked_fast": "Η ΙΘΑΚΗ πάγωσε προσωρινά την αποστολή γιατί εντοπίστηκε πολύ γρήγορη ή επαναλαμβανόμενη δραστηριότητα.",
    "admin_help": "Change limits from settings. Home comments, user messages and suspicious alerts appear in one center.",
}


def _hg_init_db():
    execute("""
    CREATE TABLE IF NOT EXISTS humanguard_settings(
        key TEXT PRIMARY KEY,
        value TEXT NOT NULL DEFAULT '',
        updated_at REAL NOT NULL
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS humanguard_members(
        room_id TEXT NOT NULL,
        device_id TEXT NOT NULL,
        nickname TEXT NOT NULL DEFAULT '',
        first_seen REAL NOT NULL,
        last_seen REAL NOT NULL,
        score INTEGER NOT NULL DEFAULT 0,
        muted_until REAL NOT NULL DEFAULT 0,
        note TEXT NOT NULL DEFAULT '',
        PRIMARY KEY(room_id, device_id)
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS humanguard_events(
        event_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL DEFAULT '',
        device_id TEXT NOT NULL DEFAULT '',
        nickname TEXT NOT NULL DEFAULT '',
        action TEXT NOT NULL DEFAULT '',
        level TEXT NOT NULL DEFAULT 'info',
        reason TEXT NOT NULL DEFAULT '',
        score_delta INTEGER NOT NULL DEFAULT 0,
        text_sample TEXT NOT NULL DEFAULT '',
        created_at REAL NOT NULL
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_hg_events_room_time ON humanguard_events(room_id, created_at)")
    execute("CREATE INDEX IF NOT EXISTS idx_hg_events_device_time ON humanguard_events(room_id, device_id, created_at)")
    execute("""
    CREATE TABLE IF NOT EXISTS humanguard_text_fingerprints(
        fp_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL DEFAULT '',
        device_id TEXT NOT NULL DEFAULT '',
        action TEXT NOT NULL DEFAULT '',
        text_hash TEXT NOT NULL DEFAULT '',
        text_sample TEXT NOT NULL DEFAULT '',
        created_at REAL NOT NULL
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_hg_fp_device_action_time ON humanguard_text_fingerprints(room_id, device_id, action, created_at)")
    for k, v in HUMANGUARD_DEFAULTS.items():
        execute("INSERT OR IGNORE INTO humanguard_settings(key,value,updated_at) VALUES(?,?,?)", (k, str(v), now()))


def _hg_setting(key: str):
    _hg_init_db()
    r = one("SELECT value FROM humanguard_settings WHERE key=?", (str(key),))
    val = str(r["value"] if r else HUMANGUARD_DEFAULTS.get(key, ""))
    default = HUMANGUARD_DEFAULTS.get(key)
    try:
        if isinstance(default, int):
            return int(float(val))
    except Exception:
        return int(default or 0)
    return val


def _hg_all_settings():
    _hg_init_db()
    out = dict(HUMANGUARD_DEFAULTS)
    for r in rows("SELECT key,value FROM humanguard_settings"):
        k = str(r["key"]); v = str(r["value"])
        if k in HUMANGUARD_DEFAULTS and isinstance(HUMANGUARD_DEFAULTS[k], int):
            try: out[k] = int(float(v))
            except Exception: out[k] = HUMANGUARD_DEFAULTS[k]
        else:
            out[k] = v
    return out


def _hg_member(room_id: str, device_id: str, nickname: str = ""):
    _hg_init_db()
    room_id=(room_id or "ithaki-phone-test")[:120]
    device_id=(device_id or "")[:120]
    nickname=(nickname or "")[:80]
    if not device_id:
        return None
    t=now()
    execute("""
    INSERT INTO humanguard_members(room_id,device_id,nickname,first_seen,last_seen,score,muted_until,note)
    VALUES(?,?,?,?,?,?,?,?)
    ON CONFLICT(room_id,device_id) DO UPDATE SET nickname=CASE WHEN excluded.nickname<>'' THEN excluded.nickname ELSE humanguard_members.nickname END, last_seen=excluded.last_seen
    """, (room_id,device_id,nickname,t,t,0,0,""))
    return one("SELECT * FROM humanguard_members WHERE room_id=? AND device_id=?", (room_id,device_id))


def _hg_log(room_id: str, device_id: str, nickname: str, action: str, level: str, reason: str, score_delta: int = 0, text_sample: str = ""):
    try:
        _hg_init_db()
        execute("""
        INSERT INTO humanguard_events(event_id,room_id,device_id,nickname,action,level,reason,score_delta,text_sample,created_at)
        VALUES(?,?,?,?,?,?,?,?,?,?)
        """, (nid("hg"), (room_id or "")[:120], (device_id or "")[:120], (nickname or "")[:80],
              (action or "")[:80], (level or "info")[:40], (reason or "")[:500], int(score_delta or 0), (text_sample or "")[:300], now()))
        if device_id and score_delta:
            execute("UPDATE humanguard_members SET score=score+?, last_seen=? WHERE room_id=? AND device_id=?", (int(score_delta), now(), (room_id or "")[:120], (device_id or "")[:120]))
    except Exception:
        LOG.exception("humanguard log failed")


def _hg_count_messages(room_id: str, device_id: str, since_ts: float) -> int:
    r = one("SELECT COUNT(*) AS n FROM messages WHERE room_id=? AND sender_id=? AND created_at>=?", ((room_id or "")[:120], (device_id or "")[:120], float(since_ts)))
    return int(r["n"] if r else 0)


def _hg_count_flow(room_id: str, device_id: str, since_ts: float) -> int:
    r = one("SELECT COUNT(*) AS n FROM flow_posts WHERE room_id=? AND sender_id=? AND created_at>=?", ((room_id or "")[:120], (device_id or "")[:120], float(since_ts)))
    return int(r["n"] if r else 0)


def _hg_count_feedback(room_id: str, device_id: str, since_ts: float) -> int:
    try:
        _ithaki_feedback_db()
        r = one("SELECT COUNT(*) AS n FROM member_feedback WHERE room_id=? AND device_id=? AND created_at>=?", ((room_id or "")[:120], (device_id or "")[:120], float(since_ts)))
        return int(r["n"] if r else 0)
    except Exception:
        return 0


def _hg_norm_text(text: str) -> str:
    import re as _re
    t = str(text or "").lower().strip()
    t = _re.sub(r"https?://\S+", " link ", t)
    t = _re.sub(r"\s+", " ", t)
    return t[:2000]


def _hg_text_hash(text: str) -> str:
    return hashlib.sha256(_hg_norm_text(text).encode("utf-8", "ignore")).hexdigest()


def _hg_count_links(text: str) -> int:
    import re as _re
    return len(_re.findall(r"https?://|www\.", str(text or "").lower()))


def humanguard_check_or_raise(room_id: str, device_id: str, nickname: str, action: str, text: str = ""):
    """HumanGuard: behaviour control after a human-looking account is created."""
    if not int(_hg_setting("enabled")):
        return
    room_id=(room_id or "ithaki-phone-test")[:120]
    device_id=(device_id or "")[:120]
    action=(action or "message")[:80]
    text=str(text or "")
    member = _hg_member(room_id, device_id, nickname)
    if not member or not device_id:
        return
    t=now()
    muted_until=float(member["muted_until"] or 0)
    if muted_until and muted_until > t:
        _hg_log(room_id, device_id, nickname, action, "blocked", "Ο λογαριασμός είναι προσωρινά σε σίγαση από HumanGuard.", 0, text)
        raise HTTPException(429, {"ok": False, "reason": "humanguard_muted", "message": "Η αποστολή έχει παγώσει προσωρινά για προστασία της κοινότητας."})
    max_chars=int(_hg_setting("max_text_chars"))
    if max_chars > 0 and len(text) > max_chars:
        _hg_log(room_id, device_id, nickname, action, "blocked", "Πολύ μεγάλο κείμενο.", 2, text)
        raise HTTPException(413, {"ok": False, "reason": "text_too_large", "message": "Το κείμενο είναι πολύ μεγάλο. Γράψε το πιο σύντομα."})
    first_seen=float(member["first_seen"] or t)
    age_minutes=(t-first_seen)/60.0
    soft_minutes=int(_hg_setting("new_account_soft_minutes"))
    hour_ago=t-3600
    min_ago=t-60
    reasons=[]; score=0
    if action == "message":
        if _hg_count_messages(room_id, device_id, min_ago) >= int(_hg_setting("message_max_per_minute")):
            reasons.append("Πολλά προσωπικά/δημόσια μηνύματα μέσα σε ένα λεπτό."); score += 3
        if _hg_count_messages(room_id, device_id, hour_ago) >= int(_hg_setting("message_max_per_hour")):
            reasons.append("Πολλά μηνύματα μέσα σε μία ώρα."); score += 4
        if age_minutes < soft_minutes and _hg_count_messages(room_id, device_id, hour_ago) >= int(_hg_setting("new_account_max_messages_hour")):
            reasons.append("Νέος λογαριασμός στέλνει πολλά μηνύματα."); score += 4
    elif action == "flow":
        if _hg_count_flow(room_id, device_id, hour_ago) >= int(_hg_setting("flow_max_per_hour")):
            reasons.append("Πολλές δημοσιεύσεις στη Ροή μέσα σε μία ώρα."); score += 4
        if age_minutes < soft_minutes and _hg_count_flow(room_id, device_id, hour_ago) >= int(_hg_setting("new_account_max_flow_hour")):
            reasons.append("Νέος λογαριασμός κάνει πολλές δημοσιεύσεις."); score += 4
    elif action == "feedback":
        if _hg_count_feedback(room_id, device_id, hour_ago) >= int(_hg_setting("feedback_max_per_hour")):
            reasons.append("Πολλά σχόλια/μηνύματα προς ΙΘΑΚΗ μέσα σε μία ώρα."); score += 3
    # Same or almost same text repeated.
    if text.strip():
        th=_hg_text_hash(text)
        cutoff=t-3600
        r=one("SELECT COUNT(*) AS n FROM humanguard_text_fingerprints WHERE room_id=? AND device_id=? AND text_hash=? AND created_at>=?", (room_id,device_id,th,cutoff))
        same=int(r["n"] if r else 0)
        if same >= int(_hg_setting("same_text_max_per_hour")):
            reasons.append("Ίδιο ή σχεδόν ίδιο κείμενο επαναλαμβάνεται πολλές φορές."); score += 5
        execute("INSERT INTO humanguard_text_fingerprints(fp_id,room_id,device_id,action,text_hash,text_sample,created_at) VALUES(?,?,?,?,?,?,?)", (nid("fp"),room_id,device_id,action,th,text[:250],t))
    # Long message speed check.
    if len(text) >= int(_hg_setting("long_message_chars")):
        r=one("SELECT created_at FROM humanguard_events WHERE room_id=? AND device_id=? AND action=? ORDER BY created_at DESC LIMIT 1", (room_id,device_id,action))
        if r and (t-float(r["created_at"] or 0)) < int(_hg_setting("long_message_min_seconds")):
            reasons.append("Μεγάλα κείμενα στέλνονται πολύ γρήγορα, πιθανή αυτοματοποίηση."); score += 3
    # Link burst check.
    links=_hg_count_links(text)
    if links:
        r=one("SELECT COUNT(*) AS n FROM humanguard_events WHERE room_id=? AND device_id=? AND reason LIKE '%link%' AND created_at>=?", (room_id,device_id,hour_ago))
        if links + int(r["n"] if r else 0) > int(_hg_setting("link_max_per_hour")):
            reasons.append("Πολλά links σε μικρό χρονικό διάστημα."); score += 4
        else:
            _hg_log(room_id, device_id, nickname, action, "info", "link", 0, text)
    if reasons:
        reason=" | ".join(reasons)
        _hg_log(room_id, device_id, nickname, action, "warning", reason, score, text)
        current=one("SELECT score FROM humanguard_members WHERE room_id=? AND device_id=?", (room_id,device_id))
        new_score=int(current["score"] if current else 0)
        if new_score >= int(_hg_setting("suspicious_score_mute")):
            minutes=int(_hg_setting("auto_mute_minutes"))
            execute("UPDATE humanguard_members SET muted_until=?, note=? WHERE room_id=? AND device_id=?", (t+minutes*60, reason[:300], room_id, device_id))
            _hg_log(room_id, device_id, nickname, action, "blocked", "Αυτόματη προσωρινή σίγαση λόγω ύποπτης δραστηριότητας.", 0, text)
            raise HTTPException(429, {"ok": False, "reason": "humanguard_auto_mute", "message": "Η ΙΘΑΚΗ πάγωσε προσωρινά την αποστολή για προστασία από bot/spam."})


def _hg_cleanup():
    try:
        _hg_init_db()
        cutoff = now() - int(_hg_setting("event_retention_days")) * 86400
        execute("DELETE FROM humanguard_events WHERE created_at < ?", (cutoff,))
        execute("DELETE FROM humanguard_text_fingerprints WHERE created_at < ?", (cutoff,))
        execute("UPDATE humanguard_members SET muted_until=0 WHERE muted_until>0 AND muted_until<?", (now(),))
    except Exception:
        LOG.exception("humanguard cleanup failed")


@app.get("/api/security/humanguard/public")
def api_humanguard_public():
    return {"ok": True, "text": HUMANGUARD_TEXT_EL, "settings_public": {"enabled": int(_hg_setting("enabled")), "show_public_slogan": int(_hg_setting("show_public_slogan"))}}


@app.get("/api/admin/humanguard/settings")
def api_admin_humanguard_settings(admin_key: str = Query("")):
    _require_admin(admin_key)
    return {"ok": True, "settings": _hg_all_settings(), "texts_el": HUMANGUARD_TEXT_EL}


@app.post("/api/admin/humanguard/settings")
def api_admin_humanguard_settings_save(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    _hg_init_db()
    incoming = payload.get("settings") or {}
    saved = {}
    for k, default in HUMANGUARD_DEFAULTS.items():
        if k not in incoming:
            continue
        v = incoming.get(k)
        if isinstance(default, int):
            try: v = int(float(v))
            except Exception: v = default
            # Safety clamps so a wrong value does not leave the server unprotected.
            if k.endswith("_minutes") or k == "auto_mute_minutes": v = max(0, min(int(v), 10080))
            elif k.endswith("_hour") or k.endswith("_minute") or k.startswith("max") or k.endswith("chars") or k.endswith("days") or k in {"same_text_max_per_hour","link_max_per_hour","suspicious_score_mute","long_message_min_seconds","long_message_chars"}: v = max(0, min(int(v), 100000))
            elif k in {"enabled", "show_public_slogan"}: v = 1 if int(v) else 0
        execute("INSERT INTO humanguard_settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at", (k, str(v), now()))
        saved[k]=v
    _hg_log(str(payload.get("room_id") or "")[:120], "server_admin", "Administrator", "settings", "info", "HumanGuard settings changed", 0, json.dumps(saved, ensure_ascii=False)[:300])
    return {"ok": True, "saved": saved, "settings": _hg_all_settings()}


@app.get("/api/admin/humanguard/inbox")
def api_admin_humanguard_inbox(admin_key: str = Query(""), room_id: str = Query("ithaki-phone-test"), limit: int = Query(120, ge=1, le=500)):
    _require_admin(admin_key)
    _hg_init_db(); _ithaki_feedback_db()
    fb=[dict(r) for r in rows("SELECT * FROM member_feedback WHERE room_id=? ORDER BY created_at DESC LIMIT ?", (room_id, int(limit)))]
    msgs=[dict(r) for r in rows("SELECT message_id,room_id,channel,sender_id,nickname,text,created_at FROM messages WHERE room_id=? ORDER BY created_at DESC LIMIT ?", (room_id, int(limit)))]
    ev=[dict(r) for r in rows("SELECT * FROM humanguard_events WHERE room_id=? OR room_id='' ORDER BY created_at DESC LIMIT ?", (room_id, int(limit)))]
    sec=[]
    try:
        _security_init_db()
        sec=[dict(r) for r in rows("SELECT * FROM security_events WHERE room_id=? OR room_id='' ORDER BY created_at DESC LIMIT ?", (room_id, int(limit)))]
        for d in sec: d["ip_hash"]=(d.get("ip_hash") or "")[:12]
    except Exception:
        sec=[]
    members=[dict(r) for r in rows("SELECT * FROM humanguard_members WHERE room_id=? ORDER BY score DESC,last_seen DESC LIMIT ?", (room_id, int(limit)))]
    return {"ok": True, "room_id": room_id, "feedback": fb, "messages": msgs, "humanguard_events": ev, "security_events": sec, "members": members, "text_el": HUMANGUARD_TEXT_EL, "settings": _hg_all_settings()}


@app.post("/api/admin/humanguard/member")
def api_admin_humanguard_member_action(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    _hg_init_db()
    room_id=str(payload.get("room_id") or "ithaki-phone-test")[:120]
    device_id=str(payload.get("device_id") or "")[:120]
    action=str(payload.get("action") or "")[:40]
    if not device_id:
        raise HTTPException(400, "device_id required")
    _hg_member(room_id, device_id, str(payload.get("nickname") or ""))
    if action == "mute":
        minutes=max(1, min(int(payload.get("minutes") or _hg_setting("auto_mute_minutes")), 10080))
        execute("UPDATE humanguard_members SET muted_until=?, note=? WHERE room_id=? AND device_id=?", (now()+minutes*60, str(payload.get("note") or "Muted by administrator")[:300], room_id, device_id))
        _hg_log(room_id, device_id, "", "admin", "blocked", "Muted by administrator", 0, "")
        return {"ok": True, "muted": True, "minutes": minutes}
    if action == "unmute":
        execute("UPDATE humanguard_members SET muted_until=0, note='' WHERE room_id=? AND device_id=?", (room_id, device_id))
        _hg_log(room_id, device_id, "", "admin", "info", "Unmuted by administrator", 0, "")
        return {"ok": True, "unmuted": True}
    if action == "reset_score":
        execute("UPDATE humanguard_members SET score=0, note='' WHERE room_id=? AND device_id=?", (room_id, device_id))
        return {"ok": True, "score_reset": True}
    raise HTTPException(400, "unknown action")




# ──────────────────────────────────────────────
# ITHAKI V24828 AUTH/SPAM SHADOW MODE
# Non-blocking risk telemetry for moderation. It does NOT reject users,
# does NOT change login, and does NOT read request bodies.
# ──────────────────────────────────────────────
AUTH_SHADOW_ENABLED = os.getenv("ITHAKI_AUTH_SHADOW_ENABLED", "1") != "0"
AUTH_SHADOW_RETENTION_SECONDS = int(os.getenv("ITHAKI_AUTH_SHADOW_RETENTION_SECONDS", str(14 * 24 * 3600)))
AUTH_SHADOW_HIGH_RISK_SCORE = int(os.getenv("ITHAKI_AUTH_SHADOW_HIGH_RISK_SCORE", "6"))

AUTH_SHADOW_PATHS = {
    "/api/profile/register": "register",
    "/api/messages/send": "message",
    "/api/flow/send": "flow",
    "/api/feedback/send": "feedback",
    "/api/report": "report",
    "/api/media/upload": "media_upload",
    "/api/push/fcm/register": "push_register",
    "/api/privacy/optional_identity": "optional_identity",
}


def _auth_shadow_action_for_path(path: str) -> str:
    p = str(path or "")
    for prefix, action in AUTH_SHADOW_PATHS.items():
        if p.startswith(prefix):
            return action
    if p.startswith("/api/"):
        return "api_write"
    return "other"


def _auth_shadow_init_db():
    execute("""
    CREATE TABLE IF NOT EXISTS auth_shadow_events(
        event_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL DEFAULT '',
        ip_hash TEXT NOT NULL DEFAULT '',
        device_hint TEXT NOT NULL DEFAULT '',
        action TEXT NOT NULL DEFAULT '',
        path TEXT NOT NULL DEFAULT '',
        method TEXT NOT NULL DEFAULT '',
        status_code INTEGER NOT NULL DEFAULT 0,
        risk_score INTEGER NOT NULL DEFAULT 0,
        reasons TEXT NOT NULL DEFAULT '',
        user_agent TEXT NOT NULL DEFAULT '',
        created_at REAL NOT NULL
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_auth_shadow_time ON auth_shadow_events(created_at)")
    execute("CREATE INDEX IF NOT EXISTS idx_auth_shadow_ip_action_time ON auth_shadow_events(ip_hash, action, created_at)")
    execute("CREATE INDEX IF NOT EXISTS idx_auth_shadow_device_time ON auth_shadow_events(room_id, device_hint, created_at)")


def _auth_shadow_cleanup():
    try:
        _auth_shadow_init_db()
        execute("DELETE FROM auth_shadow_events WHERE created_at < ?", (now() - AUTH_SHADOW_RETENTION_SECONDS,))
    except Exception:
        LOG.exception("auth shadow cleanup failed")


def _auth_shadow_count(ip_hash: str, action: str, window_seconds: int) -> int:
    if not ip_hash:
        return 0
    _auth_shadow_init_db()
    r = one("SELECT COUNT(*) AS n FROM auth_shadow_events WHERE ip_hash=? AND action=? AND created_at>=?", (ip_hash[:80], action[:80], now() - int(window_seconds)))
    return int(r["n"] if r else 0)


def _auth_shadow_log(room_id: str, ip_hash: str, device_hint: str, action: str, path: str, method: str, status_code: int, risk_score: int, reasons: list[str], user_agent: str):
    try:
        _auth_shadow_init_db()
        execute("""
        INSERT INTO auth_shadow_events(event_id,room_id,ip_hash,device_hint,action,path,method,status_code,risk_score,reasons,user_agent,created_at)
        VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
        """, (
            nid("ash"), (room_id or "")[:120], (ip_hash or "")[:80], (device_hint or "")[:120],
            (action or "")[:80], (path or "")[:240], (method or "")[:12], int(status_code or 0),
            int(risk_score or 0), " | ".join([str(x)[:120] for x in (reasons or [])])[:700],
            (user_agent or "")[:240], now()
        ))
    except Exception:
        LOG.exception("auth shadow log failed")


def _auth_shadow_device_hint(request: Request) -> tuple[str, str]:
    """Return (room_id, device_hint) using headers/query only; body is never read."""
    q = request.query_params
    room_id = str(q.get("room_id") or request.headers.get("x-ithaki-room-id") or "")[:120]
    device = str(
        q.get("device_id") or q.get("sender_id") or q.get("user_id") or
        request.headers.get("x-ithaki-device-id") or request.headers.get("x-device-id") or ""
    )[:120]
    return room_id, device


@app.middleware("http")
async def ithaki_auth_shadow_middleware(request: Request, call_next):
    """Shadow-only auth/spam telemetry. Never blocks or changes responses."""
    if not AUTH_SHADOW_ENABLED:
        return await call_next(request)
    path = request.url.path or ""
    method = (request.method or "GET").upper()
    should_watch = method in {"POST", "PUT", "PATCH", "DELETE"} and path.startswith("/api/")
    if not should_watch:
        return await call_next(request)
    status_code = 0
    risk_score = 0
    reasons: list[str] = []
    try:
        ip_hash = _security_hash(_security_client_ip(request)) if '_security_hash' in globals() else ""
        ua = (request.headers.get("user-agent") or "")[:240]
        room_id, device_hint = _auth_shadow_device_hint(request)
        action = _auth_shadow_action_for_path(path)
        if not ua:
            risk_score += 1; reasons.append("missing_user_agent")
        if not device_hint and action in {"register", "message", "flow", "feedback", "report", "media_upload"}:
            risk_score += 1; reasons.append("missing_device_hint")
        try:
            clen = int(request.headers.get("content-length") or 0)
        except Exception:
            clen = 0
        if clen > 2 * 1024 * 1024 and action not in {"media_upload"}:
            risk_score += 2; reasons.append("large_api_body")
        c60 = _auth_shadow_count(ip_hash, action, 60)
        c3600 = _auth_shadow_count(ip_hash, action, 3600)
        if c60 >= 20:
            risk_score += 3; reasons.append(f"burst_60s:{c60}")
        elif c60 >= 10:
            risk_score += 2; reasons.append(f"fast_60s:{c60}")
        if c3600 >= 250:
            risk_score += 3; reasons.append(f"burst_1h:{c3600}")
        elif c3600 >= 100:
            risk_score += 1; reasons.append(f"many_1h:{c3600}")
        response = await call_next(request)
        status_code = int(getattr(response, "status_code", 0) or 0)
        if status_code in {401, 403, 429}:
            risk_score += 2; reasons.append(f"server_status:{status_code}")
        elif status_code >= 400:
            risk_score += 1; reasons.append(f"server_status:{status_code}")
        _auth_shadow_log(room_id, ip_hash, device_hint, action, path, method, status_code, risk_score, reasons, ua)
        return response
    except Exception as exc:
        try:
            LOG.exception("auth shadow middleware failed but request continues: %s", exc)
        except Exception:
            pass
        return await call_next(request)


@app.get("/api/security/auth_shadow/status")
def api_auth_shadow_status():
    return {
        "ok": True,
        "enabled": bool(AUTH_SHADOW_ENABLED),
        "mode": "shadow_only_non_blocking",
        "blocks_users": False,
        "reads_request_body": False,
        "retention_days": round(AUTH_SHADOW_RETENTION_SECONDS / 86400, 2),
        "high_risk_score": AUTH_SHADOW_HIGH_RISK_SCORE,
        "note_el": "Μετρά ύποπτη δραστηριότητα για moderation χωρίς να κόβει χρήστες.",
    }


@app.get("/api/admin/auth_shadow/summary")
def api_admin_auth_shadow_summary(admin_key: str = Query(""), hours: int = Query(24, ge=1, le=168), limit: int = Query(80, ge=1, le=500)):
    _require_admin(admin_key)
    _auth_shadow_init_db(); _auth_shadow_cleanup()
    cutoff = now() - int(hours) * 3600
    totals = one("SELECT COUNT(*) AS total, COALESCE(MAX(risk_score),0) AS max_score, COALESCE(AVG(risk_score),0) AS avg_score FROM auth_shadow_events WHERE created_at>=?", (cutoff,))
    by_action = [dict(r) for r in rows("SELECT action, COUNT(*) AS count, COALESCE(MAX(risk_score),0) AS max_score, COALESCE(AVG(risk_score),0) AS avg_score FROM auth_shadow_events WHERE created_at>=? GROUP BY action ORDER BY count DESC LIMIT 40", (cutoff,))]
    high_risk = [dict(r) for r in rows("SELECT room_id, substr(ip_hash,1,12) AS ip_hash_short, device_hint, action, path, method, status_code, risk_score, reasons, created_at FROM auth_shadow_events WHERE created_at>=? AND risk_score>=? ORDER BY created_at DESC LIMIT ?", (cutoff, AUTH_SHADOW_HIGH_RISK_SCORE, int(limit)))]
    recent = [dict(r) for r in rows("SELECT room_id, substr(ip_hash,1,12) AS ip_hash_short, device_hint, action, path, method, status_code, risk_score, reasons, created_at FROM auth_shadow_events WHERE created_at>=? ORDER BY created_at DESC LIMIT ?", (cutoff, int(limit)))]
    return {
        "ok": True,
        "mode": "shadow_only_non_blocking",
        "hours": int(hours),
        "summary": {
            "total": int(totals["total"] if totals else 0),
            "max_score": int(totals["max_score"] if totals else 0),
            "avg_score": round(float(totals["avg_score"] if totals else 0), 2),
        },
        "by_action": by_action,
        "high_risk": high_risk,
        "recent": recent,
        "privacy": "IP is shown only as short hash; request bodies are not read by shadow mode.",
    }


# ──────────────────────────────────────────────
# V249220 diagnostics: notifications + video sound
# ──────────────────────────────────────────────
@app.get("/api/diagnostics/v249220")
def api_diag_v249220(room_id: str = "ithaki-phone-test"):
    init_fcm_db()
    cfg = _fcm_cfg()
    tok_count = 0
    try:
        r = one("SELECT COUNT(*) AS c FROM fcm_tokens WHERE room_id=? AND enabled=1", (room_id,))
        tok_count = int(r["c"] if r else 0)
    except Exception:
        tok_count = 0
    return {
        "ok": True,
        "version": VERSION,
        "v249220_notifications_video_sound_fix": True,
        "room_id": room_id,
        "video_sound": {
            "feed_videos_have_controls": True,
            "tap_to_unmute_enabled": True,
            "autoplay_starts_muted_then_user_can_enable_sound": True,
            "viewer_autoplay_with_sound_after_user_tap": True,
        },
        "notifications": {
            "web_open_app_notifications": True,
            "android_bridge_permission_request": True,
            "fcm_server_enabled": bool(cfg.get("enabled")),
            "active_fcm_tokens": tok_count,
            "closed_app_push_requires_firebase_service_account_and_new_apk": True,
        },
        "expected": {
            "no_silent_feed_videos_after_user_tap": True,
            "notification_permission_prompt_available": True,
            "foreground_message_notifications": True,
            "fcm_status_visible": True,
        }
    }

# ──────────────────────────────────────────────
# FCM push routes (v1.9.5)
# ──────────────────────────────────────────────
@app.post("/api/push/fcm/register")
def api_push_fcm_register(payload: dict):
    init_fcm_db()
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    device_id = str(payload.get("device_id") or "")[:120]
    token = str(payload.get("token") or "").strip()
    nickname = str(payload.get("nickname") or "")[:80]
    enabled = 1 if payload.get("enabled", True) else 0
    if not device_id or not token:
        raise HTTPException(400, "device_id and token required")
    # V249255: keep the current device token fresh at app boot; disable older tokens
    # for the same device to avoid duplicate closed-app notifications.
    execute("UPDATE fcm_tokens SET enabled=0, updated_at=? WHERE room_id=? AND device_id=? AND token!=?", (now(), room_id, device_id, token))
    execute("""
    INSERT INTO fcm_tokens(room_id,device_id,nickname,token,enabled,platform,updated_at) VALUES(?,?,?,?,?,?,?)
    ON CONFLICT(room_id,device_id,token) DO UPDATE SET nickname=excluded.nickname, enabled=excluded.enabled, updated_at=excluded.updated_at
    """, (room_id, device_id, nickname, token, enabled, str(payload.get("platform") or "android")[:40], now()))
    return {"ok": True, "registered": True, "fcm_enabled_on_server": bool(_fcm_cfg().get("enabled")), "version": "V249255-fcm-autoreg-token-refresh"}

@app.post("/api/admin/push/fcm/test")
def api_admin_push_fcm_test(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    init_fcm_db()
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    device_id = str(payload.get("device_id") or "")[:120]
    if device_id:
        rs = rows("SELECT token FROM fcm_tokens WHERE room_id=? AND device_id=? AND enabled=1", (room_id, device_id))
    else:
        rs = rows("SELECT token FROM fcm_tokens WHERE room_id=? AND enabled=1", (room_id,))
    tokens = [str(r["token"]) for r in rs]
    return _send_fcm_message(tokens, "OK ITHAKI test", "Δοκιμαστική ειδοποίηση FCM", {"kind": "test"})


# ──────────────────────────────────────────────
# Privacy-first identity / recovery / legal docs (v2.0.3)
# ──────────────────────────────────────────────
LEGAL_DOCS = {
    "privacy": {
        "el": """ΠΟΛΙΤΙΚΗ ΑΠΟΡΡΗΤΟΥ ΙΘΑΚΗ

Η ΙΘΑΚΗ μπορεί να λειτουργεί χωρίς υποχρεωτικό email ή τηλέφωνο. Το πλήρες προφίλ, το ιστορικό και οι προσωπικές ρυθμίσεις φυλάσσονται κυρίως στη συσκευή και στον φάκελο ανάκτησης που επιλέγει ο χρήστης. Ο server κρατά μόνο τα ελάχιστα λειτουργικά δεδομένα που χρειάζονται για ροή, μηνύματα, ειδοποιήσεις, όρια MB, reports και ασφάλεια. Ο χρήστης πρέπει να αποθηκεύει αντίγραφο του φακέλου ανάκτησης εκτός τηλεφώνου για να μπορεί να επαναφέρει το προφίλ μετά από απεγκατάσταση, απώλεια ή αλλαγή συσκευής. Email και κωδικός είναι προαιρετικά και χρησιμοποιούνται μόνο ως πρόσθετη ταυτοποίηση/ανάκτηση.

ΑΣΦΑΛΕΙΑ / ANTI-SPAM: Για προστασία από bots, flood, ψεύτικες εγγραφές, μαζικά μηνύματα και κακόβουλη χρήση, ο server μπορεί να κρατά ελάχιστα τεχνικά στοιχεία ασφάλειας: hash διεύθυνσης IP, device id εφαρμογής, ώρα αιτήματος, τύπο ενέργειας και μετρητές ορίων. Δεν κρατάμε πραγματική IP σε καθαρή μορφή, email ή τηλέφωνο για αυτή την προστασία. Τα τεχνικά στοιχεία χρησιμοποιούνται για προσωρινό μπλοκάρισμα, ξεμπλοκάρισμα, έλεγχο παραπόνων και προστασία της κοινότητας.""",
        "en": """ITHAKI PRIVACY POLICY

ITHAKI can operate without mandatory email or phone number. The full profile, history and personal settings are primarily kept on the device and in the recovery folder selected by the user. The server keeps only minimal operational data required for feed, messages, notifications, MB limits, reports and safety. The user should keep a copy of the recovery folder outside the phone to restore the profile after reinstalling, losing or changing the device. Email and password are optional and used only as additional identity/recovery information.

SECURITY / ANTI-SPAM: To protect against bots, flood, fake registrations, mass messages and abusive use, the server may keep minimal technical security metadata: hashed IP address, app device id, request time, action type and rate-limit counters. We do not store the raw IP address, email or phone number for this protection. This technical data is used for temporary blocking, unblocking, complaint review and community safety."""
    },
    "eu_policy": {
        "el": """ΕΥΡΩΠΑΪΚΗ ΠΟΛΙΤΙΚΗ ΙΘΑΚΗ / GDPR

Η ΙΘΑΚΗ σχεδιάζεται με αρχές ελαχιστοποίησης δεδομένων, τοπικής αποθήκευσης, διαφάνειας και δυνατότητας διαγραφής. Δεν απαιτείται email ή τηλέφωνο για βασική χρήση. Τα στοιχεία ανάκτησης είναι ευθύνη του χρήστη όταν επιλέγει αρχείο ανάκτησης. Ο χρήστης μπορεί να ζητήσει ενημέρωση, πρόσβαση, διόρθωση ή διαγραφή των ελάχιστων δεδομένων server που συνδέονται με τη συσκευή του. Το κείμενο είναι λειτουργική πολιτική εφαρμογής και πρέπει να ελεγχθεί νομικά πριν από δημόσια εμπορική κυκλοφορία.""",
        "en": """ITHAKI EUROPEAN POLICY / GDPR

ITHAKI is designed around data minimisation, local storage, transparency and deletion capability. Email or phone number is not required for basic use. Recovery data is the user's responsibility when a recovery file is chosen. The user may request information, access, correction or deletion of the minimal server data linked to their device. This text is an operational app policy and should be legally reviewed before public commercial release."""
    },
    "terms": {
        "el": """ΟΡΟΙ ΧΡΗΣΗΣ ΙΘΑΚΗ

Η χρήση επιτρέπεται μόνο σε άτομα 16+ σύμφωνα με την πολιτική της εφαρμογής και τις ρυθμίσεις συγκατάθεσης. Ο χρήστης συμφωνεί ότι είναι υπεύθυνος για το περιεχόμενο που ανεβάζει, για τη διατήρηση αντιγράφου του φακέλου ανάκτησης και για τη νόμιμη χρήση της εφαρμογής. Απαγορεύεται παρενόχληση, απειλές, παράνομο ή προσβλητικό περιεχόμενο, παραβίαση δικαιωμάτων και κατάχρηση των υπηρεσιών. Ο διαχειριστής μπορεί να περιορίσει MB, να μπλοκάρει, να διαγράψει ή να ερευνήσει αναφορές συμπεριφοράς.""",
        "en": """ITHAKI TERMS OF USE

Use is allowed only for 16+ users according to the app policy and consent settings. The user agrees to be responsible for uploaded content, keeping a backup of the recovery folder and using the app lawfully. Harassment, threats, illegal or abusive content, rights violations and service abuse are prohibited. The administrator may limit MB, block, delete or review behavior reports."""
    },
    "instructions": {
        "el": """ΟΔΗΓΙΕΣ ΛΕΙΤΟΥΡΓΙΑΣ

1. Αποθήκευσε ψευδώνυμο στο προφίλ. 2. Διάλεξε φάκελο ανάκτησης. 3. Πάτα τακτικά Αποθήκευση ιστορικού. 4. Κράτησε αντίγραφο του αρχείου ανάκτησης εκτός τηλεφώνου. 5. Αν ξαναεγκαταστήσεις την εφαρμογή, κάνε εισαγωγή του αρχείου ανάκτησης. 6. Για ειδοποιήσεις με κλειστή συσκευή ο ιδιοκτήτης πρέπει να έχει ενεργοποιήσει Firebase/FCM στο APK και στον server.""",
        "en": """OPERATING INSTRUCTIONS

1. Save a nickname in the profile. 2. Choose a recovery folder. 3. Regularly press Save history. 4. Keep a copy of the recovery file outside the phone. 5. After reinstalling the app, import the recovery file. 6. For closed-device notifications, the owner must enable Firebase/FCM in the APK and on the server."""
    }
}

@app.get("/api/legal/docs")
def api_legal_docs(kind: str = Query("privacy")):
    k = str(kind or "privacy").lower().strip()
    return {"ok": True, "kind": k, **LEGAL_DOCS.get(k, LEGAL_DOCS["privacy"])}

@app.post("/api/privacy/optional_identity")
def api_privacy_optional_identity(payload: dict):
    init_optional_identity_db()
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    device_id = str(payload.get("device_id") or "")[:120]
    nickname = str(payload.get("nickname") or "")[:80]
    email = str(payload.get("email") or "").strip().lower()
    password = str(payload.get("password") or "")
    if not device_id:
        raise HTTPException(400, "device_id required")
    old = one("SELECT password_salt FROM optional_identities WHERE room_id=? AND device_id=?", (room_id, device_id))
    salt_hex = str(old["password_salt"]) if old and old["password_salt"] else None
    if password:
        salt_hex, pass_hash = _pbkdf2_password_hash(password, salt_hex)
    else:
        salt_hex, pass_hash = (salt_hex or "", "")
    execute("""
    INSERT INTO optional_identities(room_id,device_id,nickname,email_hash,password_salt,password_hash,created_at,updated_at)
    VALUES(?,?,?,?,?,?,?,?)
    ON CONFLICT(room_id,device_id) DO UPDATE SET nickname=excluded.nickname,email_hash=excluded.email_hash,
      password_salt=CASE WHEN excluded.password_hash!='' THEN excluded.password_salt ELSE optional_identities.password_salt END,
      password_hash=CASE WHEN excluded.password_hash!='' THEN excluded.password_hash ELSE optional_identities.password_hash END,
      updated_at=excluded.updated_at
    """, (room_id, device_id, nickname, _minimal_email_hash(email), salt_hex, pass_hash, now(), now()))
    return {"ok": True, "stored": True, "plain_email_stored": False, "plain_password_stored": False,
            "note": "Only hashed optional identity data is stored on the server."}

@app.post("/api/privacy/delete_device_data")
def api_privacy_delete_device_data(payload: dict):
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    device_id = str(payload.get("device_id") or "")[:120]
    if not device_id or not payload.get("confirm"):
        raise HTTPException(400, "device_id and confirm required")
    deleted = {}
    for table, col in [
        ("messages", "sender_id"), ("flow_posts", "sender_id"), ("people", "device_id"),
        ("user_profiles", "device_id"), ("fcm_tokens", "device_id"), ("push_subscriptions", "device_id"),
        ("member_quota_overrides", "device_id"), ("media_usage", "device_id"),
        ("media_metadata", "device_id"), ("optional_identities", "device_id")
    ]:
        try:
            deleted[table] = execute(f"DELETE FROM {table} WHERE room_id=? AND {col}=?", (room_id, device_id)).rowcount
        except Exception:
            deleted[table] = 0
    try:
        deleted["reports"] = execute("DELETE FROM reports WHERE room_id=? AND reporter_id=?", (room_id, device_id)).rowcount
    except Exception:
        deleted["reports"] = 0
    return {"ok": True, "deleted": deleted, "note": "Minimal server-side data for this device was removed where present. Local recovery files remain under user control."}




# ITHAKI V24764 - privacy overview endpoint
@app.get("/api/privacy/what_server_keeps")
def api_privacy_what_server_keeps(room_id: str = "ithaki-phone-test", device_id: str = ""):
    room_id = str(room_id or "ithaki-phone-test")[:120]
    device_id = str(device_id or "")[:120]
    if not device_id:
        raise HTTPException(400, "device_id required")
    tables = [
        ("messages", "sender_id", "Μηνύματα που έστειλε αυτή η συσκευή"),
        ("flow_posts", "sender_id", "Δημοσιεύσεις Ροής αυτής της συσκευής"),
        ("people", "device_id", "Παρουσία / online heartbeat"),
        ("user_profiles", "device_id", "Ελάχιστο προφίλ χρήστη"),
        ("fcm_tokens", "device_id", "Push notification token αν υπάρχει"),
        ("push_subscriptions", "device_id", "Συνδρομές ειδοποιήσεων αν υπάρχουν"),
        ("member_quota_overrides", "device_id", "Όρια χρήσης μέλους αν έχουν οριστεί"),
        ("media_usage", "device_id", "Μετρητές χρήσης media"),
        ("media_metadata", "device_id", "Μεταδεδομένα media που ανέβηκαν"),
        ("optional_identities", "device_id", "Προαιρετική ταυτότητα μόνο hash, όχι απλό email/password"),
    ]
    counts = []
    for table, col, label in tables:
        try:
            row = one(f"SELECT COUNT(*) AS c FROM {table} WHERE room_id=? AND {col}=?", (room_id, device_id))
            counts.append({"table": table, "label": label, "count": int(row["c"] if row else 0)})
        except Exception:
            counts.append({"table": table, "label": label, "count": 0, "available": False})
    try:
        row = one("SELECT COUNT(*) AS c FROM reports WHERE room_id=? AND reporter_id=?", (room_id, device_id))
        counts.append({"table": "reports", "label": "Αναφορές που έστειλε αυτή η συσκευή", "count": int(row["c"] if row else 0)})
    except Exception:
        counts.append({"table": "reports", "label": "Αναφορές που έστειλε αυτή η συσκευή", "count": 0, "available": False})
    return {
        "ok": True,
        "room_id": room_id,
        "device_id": device_id,
        "keeps_plain_email": False,
        "keeps_plain_phone": False,
        "keeps_plain_password": False,
        "note": "Ο server κρατά μόνο ελάχιστα λειτουργικά δεδομένα για μηνύματα, ροή, επαφές, ειδοποιήσεις, ασφάλεια, όρια χρήσης και διαχείριση κατάχρησης.",
        "counts": counts
    }


# ITHAKI V24803 - Account recovery notice, EU policy and real member server snapshot
LEGAL_DOCS["privacy"]["el"] = """ΠΟΛΙΤΙΚΗ ΑΠΟΡΡΗΤΟΥ OK ΙΘΑΚΗ — έκδοση V24803

1. Βασική αρχή
Η ΙΘΑΚΗ λειτουργεί με ψευδώνυμο και ελαχιστοποίηση δεδομένων. Δεν απαιτείται υποχρεωτικά email ή τηλέφωνο για βασική χρήση. Το πλήρες προσωπικό προφίλ, οι προσωπικές προτιμήσεις και το αρχείο ανάκτησης κρατούνται κυρίως στη συσκευή του μέλους.

2. Αποθήκευση και ανάκτηση λογαριασμού
Η αποθήκευση λογαριασμού γίνεται τοπικά στο τηλέφωνο ως αρχείο ανάκτησης. Για λόγους ασφάλειας και ιδιωτικότητας, ο server δεν κρατά πλήρες αντίγραφο του λογαριασμού που να μπορεί να επαναφέρει μόνος του το μέλος. Αν χαθεί το κινητό, γίνει απεγκατάσταση ή σβηστούν τα δεδομένα της εφαρμογής και το μέλος δεν έχει κρατήσει το αρχείο ανάκτησης σε άλλο ασφαλές σημείο, όπως USB, υπολογιστή ή δικό του cloud, η πλήρης ανάκτηση δεν είναι τεχνικά δυνατή. Αυτό προστατεύει το μέλος από μη εξουσιοδοτημένη ανάκτηση από τρίτους.

3. Τι κρατά ο server
Ο server κρατά μόνο τα απαραίτητα λειτουργικά και τεχνικά δεδομένα που χρειάζονται για να δουλεύουν η Ροή, τα προσωπικά μηνύματα, οι επαφές, οι ειδοποιήσεις, τα όρια MB, οι αναφορές και η ασφάλεια της κοινότητας. Ενδεικτικά μπορεί να κρατά: device id εφαρμογής, ψευδώνυμο, γλώσσα, κατάσταση παρουσίας, μηνύματα που στάλθηκαν στον server, δημοσιεύσεις, μεταδεδομένα αρχείων/media, reports, τεχνικούς μετρητές και όρια χρήσης. Δεν κρατά απλό κωδικό πρόσβασης. Όπου υπάρχει προαιρετική ταυτότητα, αποθηκεύεται με κωδικοποίηση/hash και όχι ως καθαρό μυστικό.

4. Ασφάλεια / κωδικοποίηση
Τα απαραίτητα στοιχεία ασφαλείας αποθηκεύονται με τεχνικά μέτρα προστασίας, όπως hashing/κωδικοποίηση όπου εφαρμόζεται, περιορισμό πρόσβασης και καταγραφή μόνο όσων χρειάζονται για λειτουργία, κατάχρηση, spam, quota και ασφάλεια. Τα media μπορεί να αποθηκεύονται στον server όταν το μέλος τα ανεβάζει ή τα στέλνει, μαζί με τεχνικά μεταδεδομένα, ώστε να μπορούν να προβληθούν στη Ροή ή στα μηνύματα.

5. Δικαιώματα μέλους
Το μέλος μπορεί να ενημερωθεί για τα δεδομένα που υπάρχουν στον server από το “Τι κρατά ο server”, να ζητήσει διόρθωση ή διαγραφή όπου εφαρμόζεται, να διαγράψει τοπικά δεδομένα από τη συσκευή και να ζητήσει διαγραφή των ελάχιστων server δεδομένων που συνδέονται με το device id του. Η άσκηση δικαιωμάτων πρέπει να γίνεται με τρόπο που δεν παραβιάζει την ασφάλεια άλλων μελών.

6. Νομική βάση
Η επεξεργασία βασίζεται στη συγκατάθεση του μέλους για προφίλ/όρους/ειδοποιήσεις όπου απαιτείται, στην εκτέλεση της υπηρεσίας για μηνύματα και Ροή, και στο έννομο συμφέρον ασφάλειας για anti-spam, reports, quota και προστασία κοινότητας.

7. Περιορισμός ευθύνης
Το κείμενο είναι λειτουργική πολιτική εφαρμογής για ανάπτυξη της OK ΙΘΑΚΗ. Πριν από δημόσια εμπορική κυκλοφορία πρέπει να ελεγχθεί και να προσαρμοστεί από νομικό σύμβουλο."""
LEGAL_DOCS["privacy"]["en"] = """OK ITHAKI PRIVACY POLICY — V24803

ITHAKI uses a nickname-first, data-minimisation design. The account recovery file is primarily stored on the member's phone. For security reasons, the server does not keep a full recoverable copy of the account. If the phone is lost, the app is removed, or app data is deleted and the member has not kept the recovery file in another safe place such as a USB drive, PC or private cloud, full recovery is not technically possible.

The server keeps only the minimum operational and technical data required for feed, direct messages, contacts, notifications, MB limits, reports, moderation and safety. Optional identity secrets are stored only in hashed/encoded form where used, not as plain passwords. Members can view server-side information from the profile control centre and request deletion where applicable. This policy should be legally reviewed before public commercial release."""
LEGAL_DOCS["eu_policy"]["el"] = LEGAL_DOCS["privacy"]["el"]
LEGAL_DOCS["eu_policy"]["en"] = LEGAL_DOCS["privacy"]["en"]
LEGAL_DOCS["terms"]["el"] = """ΟΡΟΙ ΧΡΗΣΗΣ OK ΙΘΑΚΗ — έκδοση V24803

1. Η ΙΘΑΚΗ χρησιμοποιείται με ψευδώνυμο. Το μέλος δηλώνει ότι είναι 16+ και αποδέχεται τους όρους, την πολιτική απορρήτου και την τοπική αποθήκευση στη συσκευή.

2. Το μέλος είναι υπεύθυνο να κρατά ασφαλές αντίγραφο του αρχείου ανάκτησης λογαριασμού. Η αποθήκευση λογαριασμού γίνεται στο τηλέφωνο. Αν χαθεί το κινητό ή σβηστούν τα δεδομένα και δεν υπάρχει αντίγραφο του αρχείου, η ανάκτηση δεν μπορεί να γίνει για λόγους ασφάλειας και προστασίας ιδιωτικότητας.

3. Ο server κρατά μόνο τα απαραίτητα λειτουργικά δεδομένα με τεχνικά μέτρα προστασίας: ψευδώνυμο/device id, παρουσία, μηνύματα/δημοσιεύσεις που στάλθηκαν, μεταδεδομένα media, ειδοποιήσεις, quota/MB, reports και στοιχεία ασφάλειας. Δεν κρατά πλήρες μυστικό αρχείο ανάκτησης λογαριασμού.

4. Απαγορεύεται παρενόχληση, απειλές, παράνομο περιεχόμενο, κατάχρηση, spam, παραβίαση δικαιωμάτων, πλαστοπροσωπία και χρήση που βλάπτει άλλα μέλη ή τη λειτουργία της υπηρεσίας.

5. Ο διαχειριστής μπορεί να περιορίσει χρήση/MB, να μπλοκάρει, να σβήσει περιεχόμενο ή να εξετάσει αναφορές όταν υπάρχει λόγος ασφάλειας, νόμιμης λειτουργίας ή προστασίας της κοινότητας.

6. Το μέλος μπορεί να δει τι κρατά ο server από το Κέντρο Ελέγχου Προφίλ και να ζητήσει διαγραφή όπου εφαρμόζεται."""
LEGAL_DOCS["terms"]["en"] = """OK ITHAKI TERMS OF USE — V24803

The member is responsible for keeping a safe copy of the account recovery file. Account backup is stored on the phone. If the phone is lost or app data is deleted and there is no external recovery file, full account recovery is not possible for security and privacy reasons. The server keeps only minimum operational data required for the service and does not keep a full account recovery secret."""

@app.get("/api/privacy/profile_server_snapshot")
def api_privacy_profile_server_snapshot(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    """Real, member-facing view of what this server currently stores for this device.
    No admin key and no secrets are returned. Counts + selected safe rows only.
    """
    init_user_profile_db(); init_media_db(); init_privacy_db()
    room_id = str(room_id or "ithaki-phone-test")[:120]
    device_id = str(device_id or "")[:120]
    if not device_id:
        raise HTTPException(400, "device_id required")
    def safe_row(sql, params=()):
        try:
            r = one(sql, params)
            return dict(r) if r else None
        except Exception:
            return None
    def safe_rows(sql, params=(), limit=100):
        try:
            return [dict(x) for x in rows(sql, params)[:limit]]
        except Exception:
            return []
    def count(table, col):
        try:
            r = one(f"SELECT COUNT(*) AS c FROM {table} WHERE room_id=? AND {col}=?", (room_id, device_id))
            return int(r["c"] if r else 0)
        except Exception:
            return 0
    profile = safe_row("SELECT room_id,device_id,nickname,language,interests,adult_confirmed,policy_accepted,policy_version,ai_host_enabled,first_seen,last_seen FROM user_profiles WHERE room_id=? AND device_id=?", (room_id, device_id))
    person = safe_row("SELECT room_id,device_id,nickname,language,mood,last_seen FROM people WHERE room_id=? AND device_id=?", (room_id, device_id))
    media = safe_rows("SELECT meta_id,media_kind,filename,mime_type,size_bytes,note,created_at FROM media_metadata WHERE room_id=? AND device_id=? ORDER BY created_at DESC LIMIT 20", (room_id, device_id), 20)
    msgs = safe_rows("SELECT message_id,channel,nickname,language,created_at FROM messages WHERE room_id=? AND sender_id=? ORDER BY created_at DESC LIMIT 20", (room_id, device_id), 20)
    posts = safe_rows("SELECT post_id,nickname,language,post_kind,visibility,created_at FROM flow_posts WHERE room_id=? AND sender_id=? ORDER BY created_at DESC LIMIT 20", (room_id, device_id), 20)
    opt = safe_row("SELECT room_id,device_id,nickname,created_at,updated_at,CASE WHEN email_hash!='' THEN 1 ELSE 0 END AS has_email_hash,CASE WHEN password_hash!='' THEN 1 ELSE 0 END AS has_password_hash FROM optional_identities WHERE room_id=? AND device_id=?", (room_id, device_id))
    try:
        quota = quota_status(room_id, device_id)
    except Exception:
        quota = None
    counts = {
        "messages": count("messages", "sender_id"),
        "flow_posts": count("flow_posts", "sender_id"),
        "people": count("people", "device_id"),
        "user_profiles": count("user_profiles", "device_id"),
        "fcm_tokens": count("fcm_tokens", "device_id"),
        "push_subscriptions": count("push_subscriptions", "device_id"),
        "member_quota_overrides": count("member_quota_overrides", "device_id"),
        "media_usage": count("media_usage", "device_id"),
        "media_metadata": count("media_metadata", "device_id"),
        "optional_identities": count("optional_identities", "device_id"),
    }
    try:
        rr = one("SELECT COUNT(*) AS c FROM reports WHERE room_id=? AND reporter_id=?", (room_id, device_id)); counts["reports"] = int(rr["c"] if rr else 0)
    except Exception:
        counts["reports"] = 0
    return {
        "ok": True,
        "version": "V24803",
        "room_id": room_id,
        "device_id": device_id,
        "recovery_model": {
            "account_backup_location": "phone/local file chosen by member",
            "server_has_full_recovery_secret": False,
            "warning_el": "Αν χαθεί το κινητό και δεν υπάρχει αρχείο ανάκτησης εκτός τηλεφώνου, δεν μπορεί να γίνει πλήρης ανάκτηση για λόγους ασφάλειας.",
            "warning_en": "If the phone is lost and no recovery file exists outside the phone, full recovery is not possible for security reasons."
        },
        "server_security": {
            "plain_password_stored": False,
            "plain_email_required": False,
            "plain_phone_required": False,
            "optional_identity_hashed": True,
            "minimal_operational_data_only": True,
        },
        "profile": profile,
        "presence": person,
        "optional_identity": opt,
        "quota": quota,
        "counts": counts,
        "recent_media_metadata": media,
        "recent_message_headers": msgs,
        "recent_flow_post_headers": posts,
        "note_el": "Τα κείμενα μηνυμάτων/δημοσιεύσεων δεν εμφανίζονται εδώ ολόκληρα για να μην εκθέτονται άσκοπα. Εμφανίζονται πραγματικά στοιχεία server και μετρητές.",
    }
# END ITHAKI V24803

# END ITHAKI V24764


# ──────────────────────────────────────────────
# Server Pro / Cost Guard public/admin routes
# (must be registered after app = FastAPI(...))
# ──────────────────────────────────────────────
@app.get("/api/cost_guard/public")
def api_cost_guard_public(room_id: str = Query("ithaki-phone-test")):
    """Public safe limits for APK. No admin secrets returned."""
    cfg = _cost_guard_cfg(room_id)
    usage = _cost_guard_usage(room_id)
    return {
        "ok": True,
        "room_id": room_id,
        "limits": {
            "guard_enabled": bool(cfg.get("enabled", True)),
            "uploads_disabled": bool(cfg.get("emergency_disable_uploads", False)),
            "video_disabled": bool(cfg.get("emergency_disable_video", False)),
            "server_readonly": bool(cfg.get("emergency_readonly", False)),
            "max_upload_mb": int(cfg.get("max_upload_mb", 500)),
            "max_photo_mb": int(cfg.get("max_photo_mb", 100)),
            "max_video_mb": int(cfg.get("max_video_mb", 1000)),
            "max_audio_mb": int(cfg.get("max_audio_mb", 100)),
            "daily_server_mb": int(cfg.get("daily_server_mb", 5000)),
            "daily_device_mb": int(cfg.get("daily_device_mb", 20000)),
            "monthly_device_mb": int(cfg.get("monthly_device_mb", 500000)),
            "quota_cooldown_minutes": int(cfg.get("quota_cooldown_minutes", 1440)),
            "max_online_users": int(cfg.get("max_online_users", 200)),
            "rate_limit_per_minute": int(cfg.get("rate_limit_per_minute", 120)),
        },
        "usage": usage,
        "privacy": {
            "server_stores_files": True,
            "server_stores_encrypted_metadata": True,
            "client_stores_files": True,
            "ttl_hours": round(ttl_seconds()/3600, 2),
        },
    }

@app.get("/api/quota/me")
def api_quota_me(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    if not device_id:
        return {"ok": False, "error": "device_id required"}
    ensure_uploads_open_default(room_id)
    return quota_status(room_id, device_id)


@app.get("/api/quota/pricing")
def api_quota_pricing(room_id: str = Query("ithaki-phone-test")):
    return {"ok": True, "cards": [], "note": "No purchase prices are shown in this build. Server owner controls MB limits."}


@app.post("/api/admin/quota/update")
def api_admin_quota_update(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    room_id = str(payload.get("room_id") or "ithaki-phone-test")
    init_cost_guard_db(); ensure_uploads_open_default(room_id)
    fields = {
        "daily_device_mb": int(payload.get("daily_device_mb", 100000)),
        "monthly_device_mb": int(payload.get("monthly_device_mb", 500000)),
        "max_upload_mb": min(int(payload.get("max_upload_mb", 500)), 500),
        "max_photo_mb": min(int(payload.get("max_photo_mb", 100)), 100),
        "max_video_mb": min(int(payload.get("max_video_mb", 1000)), 1000),
        "max_audio_mb": min(int(payload.get("max_audio_mb", 100)), 100),
        "quota_cooldown_minutes": int(payload.get("quota_cooldown_minutes", 1440)),
        "emergency_disable_video": 1 if bool(payload.get("video_disabled", False)) else 0,
        "emergency_disable_uploads": 1 if bool(payload.get("uploads_disabled", False)) else 0,
        "show_upgrade_cards": 1 if bool(payload.get("show_upgrade_cards", True)) else 0,
    }
    execute("""
    UPDATE cost_guard_settings SET daily_device_mb=?, monthly_device_mb=?, max_upload_mb=?,
      max_photo_mb=?, max_video_mb=?, max_audio_mb=?, quota_cooldown_minutes=?,
      emergency_disable_video=?, emergency_disable_uploads=?, show_upgrade_cards=?, updated_at=?
    WHERE room_id=?
    """, (fields["daily_device_mb"], fields["monthly_device_mb"], fields["max_upload_mb"],
          fields["max_photo_mb"], fields["max_video_mb"], fields["max_audio_mb"], fields["quota_cooldown_minutes"],
          fields["emergency_disable_video"], fields["emergency_disable_uploads"], fields["show_upgrade_cards"], now(), room_id))
    try:
        cfgfile = ROOT / "media_quota_config.json"
        existing = json.loads(cfgfile.read_text(encoding="utf-8")) if cfgfile.exists() else {}
        existing.update({k: v for k, v in fields.items() if k not in ("emergency_disable_video", "emergency_disable_uploads", "show_upgrade_cards")})
        existing["video_disabled"] = bool(fields["emergency_disable_video"])
        existing["uploads_disabled"] = bool(fields["emergency_disable_uploads"])
        existing["show_upgrade_cards"] = bool(fields["show_upgrade_cards"])
        cfgfile.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding="utf-8")
    except Exception:
        LOG.exception("Failed writing media_quota_config.json")
    return {"ok": True, "room_id": room_id, "quota": quota_status(room_id, "admin-preview")}


@app.post("/api/admin/uploads/unlock")
def api_admin_uploads_unlock(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    max_upload_mb = min(int(payload.get("max_upload_mb", 500)), 500)
    room_id = str(payload.get("room_id") or "ithaki-phone-test")
    res = unlock_uploads_all_rooms(max_upload_mb=max_upload_mb)
    ensure_uploads_open_default(room_id)
    res["room_id"] = room_id
    res["policy"] = _media_policy(room_id)
    return res

@app.post("/api/admin/uploads/lock")
def api_admin_uploads_lock(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    room_id = str(payload.get("room_id") or "ithaki-phone-test")
    init_cost_guard_db()
    ensure_uploads_open_default(room_id)
    execute("UPDATE cost_guard_settings SET emergency_disable_uploads=1, updated_at=? WHERE room_id=?", (now(), room_id))
    return {"ok": True, "room_id": room_id, "uploads_disabled": True, "policy": _media_policy(room_id)}

@app.get("/api/admin/server/stats_detailed")
def api_admin_server_stats_detailed(admin_key: str = Query(""), room_id: str = Query("ithaki-phone-test")):
    _require_admin(admin_key)
    init_media_db(); init_admin_db(); init_cost_guard_db(); cleanup_demo_users_and_stats()
    cfg = _cost_guard_cfg(room_id)
    usage = _cost_guard_usage(room_id)
    def q(sql, params=()):
        try: return one(sql, params)["n"]
        except Exception: return 0
    return {
        "ok": True, "version": VERSION, "room_id": room_id, "db_path": str(DB_PATH),
        "ttl_hours": round(ttl_seconds()/3600, 2),
        "counts": {
            "online_real_users": q("SELECT COUNT(*) n FROM people WHERE room_id=? AND device_id NOT IN ('ithaki_auto_seed','ithaki_auto_host','ithaki_host') AND lower(device_id) NOT LIKE 'demo%' AND lower(nickname) NOT LIKE 'demo%'", (room_id,)),
            "messages": q("SELECT COUNT(*) n FROM messages WHERE room_id=?", (room_id,)),
            "flow_posts": q("SELECT COUNT(*) n FROM flow_posts WHERE room_id=?", (room_id,)),
            "media_metadata": q("SELECT COUNT(*) n FROM media_metadata WHERE room_id=?", (room_id,)),
            "active_bans": q("SELECT COUNT(*) n FROM admin_bans WHERE room_id=? AND active=1", (room_id,)),
            "reports": q("SELECT COUNT(*) n FROM reports WHERE room_id=?", (room_id,)),
        },
        "usage": usage,
        "guard": cfg,
        "media_policy": _media_policy(room_id),
        "privacy": "Server stores only encrypted metadata for media. Files remain on mobile device.",
    }

@app.post("/api/admin/maintenance/cleanup")
def api_admin_maintenance_cleanup(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    cleanup_demo_users_and_stats()
    return {"ok": True, "message": "Cleanup done: demo/host fake members removed and expired message/media/feed TTL rows purged. Real people rows are preserved; offline is computed from last_seen, not deleted."}

@app.get("/api/media/policy")
def api_media_policy(room_id: str = Query("ithaki-phone-test")):
    init_media_db()
    ensure_uploads_open_default(room_id)
    return _media_policy(room_id)


# BEGIN V249394_PREVIEW_UPLOAD_TEST_LIMITS
@app.get("/api/media/test_limits_v249394")
def api_media_test_limits_v249394(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    """Safe public diagnostics for preview-upload testing. No tokens or private data."""
    ensure_uploads_open_default(room_id)
    q = quota_status(room_id, device_id or "diagnostic")
    return {
        "ok": True,
        "version": "V249394-preview-upload-test-limits",
        "room_id": room_id,
        "device_id_present": bool(device_id),
        "limits": q.get("limits", {}),
        "usage": q.get("usage", {}),
        "nginx_required_client_max_body_size_mb": 1200,
        "preview_upload_endpoint": "/api/media/upload",
        "server_plaintext_preview_cache": False,
    }
# END V249394_PREVIEW_UPLOAD_TEST_LIMITS

@app.get("/api/media/upload_health")
def api_media_upload_health(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    init_media_db(); init_cost_guard_db(); ensure_uploads_open_default(room_id)
    q = quota_status(room_id, device_id or "health-check")
    return {"ok": True, "room_id": room_id, "uploads_ready": True, "quota": q, "max_upload_mb": q.get("limits", {}).get("max_upload_mb", 500)}


@app.get("/api/media/upload_guard_status")
def api_media_upload_guard_status(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    """V249141 visible diagnostics: BotGuard must not sabotage normal video tests."""
    init_media_db(); init_cost_guard_db(); ensure_uploads_open_default(room_id)
    return {
        "ok": True,
        "version": "249141",
        "botguard_media_upload_limit": "60/min, 300/hour",
        "member_media_upload_limit": "60/min, 300/hour",
        "flow_send_limit": "60/min, 300/hour",
        "video_direct_limit_mb": FFMPEG_FALLBACK_UNCOMPRESSED_MB,
        "photos_max_mb": 20,
        "videos_max_mb": 200,
        "message": "BotGuard is relaxed for local beta media tests. Videos under 100MB publish directly and are logged on the server."
    }


@app.post("/api/media/metadata")
def api_media_metadata(payload: MediaMetaIn, request: Request):
    init_media_db()
    init_member_control_db()
    init_social_v166_db()
    if not _token_ok(request):
        raise HTTPException(403, "public app token required")
    mb = float(payload.size_bytes or 0) / 1024.0 / 1024.0
    ok, reason, cfg = cost_guard_check(payload.room_id, payload.device_id, mb, "upload")
    if not ok:
        raise HTTPException(429, reason)
    mid = nid("media")
    execute("""
    INSERT INTO media_metadata(meta_id,room_id,device_id,local_media_id,media_kind,filename,mime_type,size_bytes,sha256,encrypted_meta,iv,note,created_at)
    VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
    """, (mid, payload.room_id, payload.device_id, payload.local_media_id, payload.media_kind,
          payload.filename, payload.mime_type, int(payload.size_bytes or 0), payload.sha256,
          payload.encrypted_meta, payload.iv, payload.note, now()))
    return {"ok": True, "meta_id": mid, "server_stored_file": False, "ttl_seconds": ttl_seconds(), "policy": _media_policy(payload.room_id)}


@app.get("/api/media/metadata")
def api_media_metadata_list(room_id: str = Query("ithaki-phone-test"), limit: int = Query(100, ge=1, le=300)):
    init_media_db()
    init_member_control_db()
    init_social_v166_db()
    return {"ok": True, "items": [dict(r) for r in rows("SELECT * FROM media_metadata WHERE room_id=? ORDER BY created_at DESC LIMIT ?", (room_id, limit))], "policy": _media_policy(room_id)}


# ──────────────────────────────────────────────
# Server-stored media for Feed / DM / Rooms (v1.9.3)
# ──────────────────────────────────────────────
MEDIA_FILES = DATA / "media_files"
MEDIA_FILES.mkdir(exist_ok=True)


# V249124: professional video compression pipeline.
# Upload policy:
#   - photos: default max 20 MB
#   - videos: default max original upload 200 MB
#   - feed video output: MP4/H.264/AAC, max 720p, about 2.5 Mbps for faster first frame on mobile data
#   - original source video is deleted after successful FFmpeg compression
FFMPEG_TARGET_VIDEO_BITRATE = os.getenv("ITHAKI_FFMPEG_VIDEO_BITRATE", "2500k")
FFMPEG_MAXRATE = os.getenv("ITHAKI_FFMPEG_MAXRATE", "3000k")
FFMPEG_BUFSIZE = os.getenv("ITHAKI_FFMPEG_BUFSIZE", "6000k")
FFMPEG_AUDIO_BITRATE = os.getenv("ITHAKI_FFMPEG_AUDIO_BITRATE", "128k")
FFMPEG_PRESET = os.getenv("ITHAKI_FFMPEG_PRESET", "veryfast")
FFMPEG_TIMEOUT_SECONDS = int(os.getenv("ITHAKI_FFMPEG_TIMEOUT_SECONDS", "1800"))
try:
    _ffmpeg_workers = max(1, min(2, int(os.getenv("ITHAKI_FFMPEG_WORKERS", "1"))))
except Exception:
    _ffmpeg_workers = 1
FFMPEG_SEMAPHORE = threading.BoundedSemaphore(_ffmpeg_workers)
VIDEO_BACKGROUND_COMPRESSION = os.getenv("ITHAKI_VIDEO_BACKGROUND_COMPRESSION", "1").strip().lower() not in {"0", "false", "no"}
FFMPEG_FALLBACK_UNCOMPRESSED_MB = int(os.getenv("ITHAKI_FFMPEG_FALLBACK_UNCOMPRESSED_MB", "100"))
FFMPEG_FALLBACK_UNCOMPRESSED_BYTES = max(1, FFMPEG_FALLBACK_UNCOMPRESSED_MB) * 1024 * 1024


def ithaki_video_fallback_allowed(size_bytes: int) -> bool:
    return int(size_bytes or 0) <= FFMPEG_FALLBACK_UNCOMPRESSED_BYTES


def ithaki_small_or_precompressed_direct_allowed(size_bytes: int, filename: str = '', mime: str = '') -> bool:
    """Publish already phone-compressed small videos immediately in local tests.

    Large videos still use FFmpeg. This prevents the user from seeing
    "uploaded to server / 0 file" while a small video waits for FFmpeg.
    """
    return ithaki_video_fallback_allowed(size_bytes)


def ithaki_publish_uncompressed_video_fallback(meta_id: str, room_id: str, device_id: str, src_path: str | Path, original_name: str, original_size: int, original_sha: str, reason: str, background: bool = False) -> dict:
    """Publish the original video only as a safe emergency fallback.

    This is intentionally capped at 100MB by default. It prevents the UI from
    appearing stuck when FFmpeg is missing, has no H.264 encoder, is busy, times
    out, or fails. Larger videos are never kept uncompressed.
    """
    src = Path(src_path)
    if not src.exists():
        raise RuntimeError("fallback_source_missing")
    original_size = int(original_size or src.stat().st_size)
    if not ithaki_video_fallback_allowed(original_size):
        raise RuntimeError("ffmpeg_fallback_blocked_over_" + str(FFMPEG_FALLBACK_UNCOMPRESSED_MB) + "MB")
    mime = ithaki_media_mime_v24691c(original_name, str(src), "video/mp4")
    rel = src.relative_to(MEDIA_FILES).as_posix()
    safe_reason = str(reason or "ffmpeg_unavailable")[:700].replace(";", ",")
    note = (
        "server_file:" + rel +
        "; ffmpeg_fallback_uncompressed" +
        "; fallback_limit_mb=" + str(FFMPEG_FALLBACK_UNCOMPRESSED_MB) +
        "; server_alert=1" +
        "; reason=" + safe_reason +
        ("; background=1" if background else "")
    )
    execute("""
    UPDATE media_metadata
    SET media_kind=?, filename=?, mime_type=?, size_bytes=?, sha256=?, note=?, created_at=?
    WHERE meta_id=?
    """, ("video", original_name, mime, original_size, original_sha, note, now(), meta_id))
    record_media_usage(room_id, device_id, "video", original_size, "server_file:fallback_uncompressed:" + original_name[:100])
    LOG.warning("ITHAKI_VIDEO_FALLBACK_UNCOMPRESSED meta_id=%s size_mb=%.2f reason=%s", meta_id, original_size/1024/1024, safe_reason)
    return {
        "ok": True, "fallback": True, "status": "fallback_uncompressed",
        "reason": safe_reason, "limit_mb": FFMPEG_FALLBACK_UNCOMPRESSED_MB,
        "url": f"/api/media/file/{meta_id}", "file_url": f"/api/media/file/{meta_id}",
        "mime_type": mime, "size_bytes": original_size,
        "message": "FFmpeg was not available/usable. Video was published uncompressed because it is under 100MB. Server logged this fallback."
    }


def ithaki_ffmpeg_best_h264_encoder() -> str | None:
    """Pick an installed H.264 encoder. Some Windows builds do not include libx264."""
    ffmpeg = ithaki_ffmpeg_path()
    if not ffmpeg:
        return None
    try:
        p = subprocess.run([ffmpeg, "-hide_banner", "-encoders"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=10)
        txt = p.stdout or ""
    except Exception:
        txt = ""
    for enc in ("libx264", "libopenh264", "h264_amf", "h264_nvenc", "h264_qsv", "h264_mf"):
        if enc in txt:
            return enc
    return None


def _media_sha256_file(path: Path) -> str:
    hh = hashlib.sha256()
    with path.open("rb") as fh:
        for chunk in iter(lambda: fh.read(1024 * 1024), b""):
            hh.update(chunk)
    return hh.hexdigest()




def ithaki_server_file_rel_from_note(note: str) -> str:
    """Extract the stored relative media path from a media_metadata.note.

    Older/newer notes look like:
      server_file:room/yyyy/file.mp4
      server_file:room/yyyy/file.mp4; ffmpeg_compressed_1080p...

    The file endpoint must use only the path before the first semicolon.
    If it uses the full note, videos saved with FFmpeg metadata become
    fake paths and return 404 forever.
    """
    txt = str(note or "")
    prefix = "server_file:"
    if not txt.startswith(prefix):
        return ""
    rel = txt[len(prefix):].split(";", 1)[0].strip()
    rel = rel.replace("\\", "/").lstrip("/")
    return rel


def ithaki_media_path_from_note(note: str) -> Path | None:
    rel = ithaki_server_file_rel_from_note(note)
    if not rel:
        return None
    path = (MEDIA_FILES / rel).resolve()
    try:
        if MEDIA_FILES.resolve() not in path.parents and path != MEDIA_FILES.resolve():
            return None
    except Exception:
        return None
    return path

def _set_media_processing_failed(meta_id: str, reason: str) -> None:
    reason = str(reason or "processing_failed")[:900]
    try:
        execute("UPDATE media_metadata SET media_kind=?, mime_type=?, note=?, created_at=? WHERE meta_id=?",
                ("video_failed", "video/failed", "processing_failed:" + reason, now(), meta_id))
    except Exception:
        LOG.exception("Could not update failed video processing status for %s", meta_id)


def ithaki_background_video_worker(meta_id: str, room_id: str, device_id: str, src_path: str, out_path: str, original_name: str, original_size: int, original_sha: str) -> None:
    src = Path(src_path)
    dst = Path(out_path)
    try:
        info = ithaki_ffmpeg_compress_video_sync(src, dst)
        try:
            src.unlink(missing_ok=True)
        except Exception:
            pass
        final_name = (Path(original_name).stem[:90] or "video") + "_720p.mp4"
        final_size = int(dst.stat().st_size)
        final_sha = _media_sha256_file(dst)
        rel = dst.relative_to(MEDIA_FILES).as_posix()
        note = "server_file:" + rel + "; ffmpeg_compressed_720p_2500k; original_deleted; original_mb=" + str(round(float(original_size)/1024/1024, 2)) + "; background=1"
        execute("""
        UPDATE media_metadata
        SET media_kind=?, filename=?, mime_type=?, size_bytes=?, sha256=?, note=?, created_at=?
        WHERE meta_id=?
        """, ("video", final_name, "video/mp4", final_size, final_sha, note, now(), meta_id))
        record_media_usage(room_id, device_id, "video", final_size, "server_file:" + final_name[:120])
        LOG.info("Background FFmpeg finished for %s in %ss", meta_id, info.get("seconds"))
    except Exception as exc:
        reason = str(exc)
        try:
            if ithaki_video_fallback_allowed(original_size):
                try:
                    dst.unlink(missing_ok=True)
                except Exception:
                    pass
                ithaki_publish_uncompressed_video_fallback(meta_id, room_id, device_id, src, original_name, original_size, original_sha, reason, background=True)
                return
        except Exception as fallback_exc:
            reason = reason + "; fallback_failed:" + str(fallback_exc)
        try:
            src.unlink(missing_ok=True)
        except Exception:
            pass
        try:
            dst.unlink(missing_ok=True)
        except Exception:
            pass
        _set_media_processing_failed(meta_id, reason)


def ithaki_start_background_video_job(meta_id: str, room_id: str, device_id: str, src_path: Path, out_path: Path, original_name: str, original_size: int, original_sha: str) -> None:
    th = threading.Thread(
        target=ithaki_background_video_worker,
        args=(meta_id, room_id, device_id, str(src_path), str(out_path), original_name, int(original_size), original_sha),
        daemon=True,
        name="ithaki-ffmpeg-" + meta_id[:12],
    )
    th.start()


def ithaki_ffmpeg_path() -> str | None:
    """Find FFmpeg without bundling it into the ZIP."""
    candidates = [
        os.getenv("ITHAKI_FFMPEG_PATH", ""),
        str(ROOT / "ffmpeg.exe"),
        str(ROOT / "bin" / "ffmpeg.exe"),
        str(ROOT / "ffmpeg"),
        str(ROOT / "bin" / "ffmpeg"),
        shutil.which("ffmpeg") or "",
    ]
    for c in candidates:
        c = str(c or "").strip().strip('"')
        if c and Path(c).exists():
            return c
    return None


def ithaki_is_video_media(media_kind: str, filename: str = "", mime: str = "") -> bool:
    k = (media_kind or "").lower()
    m = (mime or "").lower()
    ext = Path(filename or "").suffix.lower()
    return k.startswith("video") or m.startswith("video/") or ext in {".mp4", ".mov", ".m4v", ".webm", ".3gp", ".3gpp", ".avi", ".mkv"}


def ithaki_ffmpeg_compress_video_sync(src: Path, dst: Path) -> dict:
    """Compress one uploaded video to feed-safe 1080p MP4.

    This intentionally runs behind a small semaphore so the server does not start
    many FFmpeg jobs at once on a 6-core / 12GB VPS.
    """
    ffmpeg = ithaki_ffmpeg_path()
    if not ffmpeg:
        raise RuntimeError("ffmpeg_missing")
    dst.parent.mkdir(parents=True, exist_ok=True)
    tmp = dst.with_suffix(".tmp.mp4")
    try:
        tmp.unlink(missing_ok=True)
    except Exception:
        pass
    encoder = ithaki_ffmpeg_best_h264_encoder()
    if not encoder:
        raise RuntimeError("ffmpeg_h264_encoder_missing")
    cmd = [
        ffmpeg, "-y", "-hide_banner", "-loglevel", "error",
        "-i", str(src),
        "-vf", "scale='min(1280,iw)':-2",
        "-c:v", encoder,
    ]
    if encoder == "libx264":
        cmd += ["-preset", FFMPEG_PRESET]
    cmd += [
        "-b:v", FFMPEG_TARGET_VIDEO_BITRATE, "-maxrate", FFMPEG_MAXRATE, "-bufsize", FFMPEG_BUFSIZE,
        "-pix_fmt", "yuv420p",
        "-c:a", "aac", "-b:a", FFMPEG_AUDIO_BITRATE,
        "-movflags", "+faststart",
        str(tmp),
    ]
    acquired = FFMPEG_SEMAPHORE.acquire(timeout=5)
    if not acquired:
        raise RuntimeError("ffmpeg_busy")
    try:
        started = time.time()
        proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=FFMPEG_TIMEOUT_SECONDS)
        elapsed = time.time() - started
    finally:
        try:
            FFMPEG_SEMAPHORE.release()
        except Exception:
            pass
    if proc.returncode != 0 or not tmp.exists() or tmp.stat().st_size < 1024:
        err = (proc.stderr or proc.stdout or "ffmpeg_failed")[-1000:]
        try:
            tmp.unlink(missing_ok=True)
        except Exception:
            pass
        raise RuntimeError("ffmpeg_failed:" + err)
    tmp.replace(dst)
    return {"ok": True, "seconds": round(elapsed, 2), "output_bytes": dst.stat().st_size}


async def ithaki_ffmpeg_compress_video(src: Path, dst: Path) -> dict:
    return await asyncio.to_thread(ithaki_ffmpeg_compress_video_sync, src, dst)


def _safe_upload_name(name: str, fallback: str = "media") -> str:
    name = (name or fallback).strip().replace("\\", "_").replace("/", "_")
    name = re.sub(r"[^A-Za-z0-9._ -]+", "_", name)
    return (name[:140] or fallback)

def _media_max_allowed_bytes(room_id: str, device_id: str, kind: str) -> int:
    """Return hard single-file limit for streaming upload.

    v1.9.5: Use the configured per-kind limit directly. Do not use stale
    remaining quota and do not shrink video to old max_upload rows. This prevents
    the false "Μεγάλο αρχείο" block for normal videos such as 17.5 MB.
    """
    # v2.3.2: member-specific MB overrides are applied before streaming starts.
    # This prevents the upload stream from using stale/global limits while the UI shows per-member server values.
    cfg = _apply_member_quota_override(_cost_guard_cfg(room_id), room_id, device_id)
    if cfg.get("emergency_disable_uploads", False):
        return 0
    k = (kind or "file").lower()
    if k.startswith("video") and cfg.get("emergency_disable_video", False):
        return 0
    if k.startswith("video"):
        mb = min(float(cfg.get("max_video_mb", 1000)), 1000.0)
    elif k in ("photo", "image", "photos"):
        mb = min(float(cfg.get("max_photo_mb", 100)), 100.0)
    elif k in ("audio", "sound", "recording"):
        mb = min(float(cfg.get("max_audio_mb", 100)), 100.0)
    else:
        mb = min(float(cfg.get("max_upload_mb", 500)), 500.0)
    return int(max(1.0, mb) * 1024 * 1024)


# V24691C: robust media MIME correction for Android/WebView playback.
def ithaki_media_mime_v24691c(filename: str = '', stored_path: str = '', content_type: str = '') -> str:
    import mimetypes
    ct = (content_type or '').split(';', 1)[0].strip().lower()
    bad = ('', 'application/octet-stream', 'binary/octet-stream', 'video', 'file')
    name = (filename or stored_path or '').lower()
    suffix = Path(name).suffix.lower()
    by_ext = {
        '.mp4': 'video/mp4', '.m4v': 'video/mp4', '.mov': 'video/quicktime',
        '.webm': 'video/webm', '.3gp': 'video/3gpp', '.3gpp': 'video/3gpp',
        '.avi': 'video/x-msvideo', '.mkv': 'video/x-matroska',
        '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif',
        '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
        '.pdf': 'application/pdf',
        '.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
        '.xls': 'application/vnd.ms-excel', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
        '.ppt': 'application/vnd.ms-powerpoint', '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
        '.txt': 'text/plain', '.zip': 'application/zip'
    }
    if ct and ct not in bad and '/' in ct:
        # Fix common wrong Android/gallery types where extension is more reliable.
        if suffix in by_ext and (ct == 'application/octet-stream' or (suffix in ('.mp4','.m4v','.mov','.webm','.3gp','.3gpp') and not ct.startswith('video/'))):
            return by_ext[suffix]
        return ct
    if suffix in by_ext:
        return by_ext[suffix]
    guessed = mimetypes.guess_type(name)[0]
    return guessed or 'application/octet-stream'


@app.get("/api/media/ffmpeg_health")
def api_media_ffmpeg_health():
    ff = ithaki_ffmpeg_path()
    return {
        "ok": bool(ff),
        "ffmpeg_found": bool(ff),
        "ffmpeg_path": ff or "",
        "video_output": "720p H.264/AAC MP4 ~2.5 Mbps + faststart",
        "limits": {"photo_mb": 20, "video_upload_original_mb": 200, "generic_upload_mb": 20},
        "original_policy": "delete original after successful compression",
        "workers": _ffmpeg_workers,
        "background_compression": bool(VIDEO_BACKGROUND_COMPRESSION),
        "fallback_uncompressed_mb": FFMPEG_FALLBACK_UNCOMPRESSED_MB,
        "h264_encoder": ithaki_ffmpeg_best_h264_encoder() if ff else None,
        "message": "FFmpeg ready." if ff else "FFmpeg missing. Videos up to 100MB can publish as uncompressed fallback; larger videos need FFmpeg.",
    }

@app.post("/api/media/upload")
async def api_media_upload(
    request: Request,
    room_id: str = Form(...),
    device_id: str = Form(...),
    media_kind: str = Form("file"),
    file: UploadFile = File(...),
):
    init_media_db(); init_cost_guard_db(); ensure_uploads_open_default(room_id)
    security_member_guard(room_id, device_id, "media_upload_member", max_per_minute=60, max_per_hour=300)
    if not _token_ok(request):
        raise HTTPException(403, "public app token required")
    if is_banned(room_id, device_id):
        raise HTTPException(403, "member blocked")
    max_bytes = _media_max_allowed_bytes(room_id, device_id, media_kind)
    if max_bytes <= 0:
        raise HTTPException(status_code=402, detail={"ok": False, "reason": "no_media_mb_left_or_disabled", "quota": quota_status(room_id, device_id)})
    meta_id = nid("media")
    original = _safe_upload_name(file.filename or (meta_id + ".bin"))
    ext = Path(original).suffix[:12]
    folder = MEDIA_FILES / _safe_upload_name(room_id, "room") / time.strftime("%Y%m")
    folder.mkdir(parents=True, exist_ok=True)
    stored_name = meta_id + ext
    path = folder / stored_name
    size = 0
    h = hashlib.sha256()
    try:
        with path.open("wb") as out:
            while True:
                chunk = await file.read(256 * 1024)
                if not chunk:
                    break
                size += len(chunk)
                if size > max_bytes:
                    try:
                        out.close(); path.unlink(missing_ok=True)
                    except Exception:
                        pass
                    q = quota_status(room_id, device_id)
                    raise HTTPException(status_code=402, detail={"ok": False, "reason": "media_too_large_or_quota", "size_mb": round(size/1024/1024,2), "max_allowed_mb": round(max_bytes/1024/1024,2), "quota": q})
                h.update(chunk)
                out.write(chunk)
    finally:
        try:
            await file.close()
        except Exception:
            pass
    if size <= 0:
        try:
            path.unlink(missing_ok=True)
        except Exception:
            pass
        raise HTTPException(400, "empty upload")
    # V249124: Enforce original upload limit first; then store the feed-safe version.
    check_media_quota_or_raise(room_id, device_id, media_kind, size)
    original_size = size
    original_sha = h.hexdigest()
    compression = {"enabled": False, "status": "not_video"}
    final_path = path
    final_original_name = original
    final_kind = media_kind
    final_mime = ithaki_media_mime_v24691c(original, str(path), file.content_type)
    final_size = size
    final_sha = original_sha

    if ithaki_is_video_media(media_kind, original, file.content_type):
        final_kind = "video"
        # V249141: small / already phone-compressed videos under 100MB publish immediately.
        # They do not wait for FFmpeg, so they do not disappear or show 0 file in the UI.
        if ithaki_small_or_precompressed_direct_allowed(original_size, original, file.content_type):
            final_path = path
            final_original_name = original
            final_mime = ithaki_media_mime_v24691c(original, str(path), file.content_type)
            final_size = original_size
            final_sha = original_sha
            compression = {
                "enabled": False, "status": "direct_small_or_precompressed", "fallback": True,
                "reason": "under_100mb_direct_publish_no_ffmpeg_wait",
                "fallback_limit_mb": FFMPEG_FALLBACK_UNCOMPRESSED_MB, "server_alert": True,
            }
            LOG.warning("ITHAKI_VIDEO_DIRECT_SMALL_OR_PRECOMPRESSED meta_id=%s size_mb=%.2f", meta_id, original_size/1024/1024)
        else:
            ffmpeg_available = bool(ithaki_ffmpeg_path())
            if not ffmpeg_available:
                # This branch is normally over 100MB because small videos returned above.
                try:
                    path.unlink(missing_ok=True)
                except Exception:
                    pass
                raise HTTPException(status_code=503, detail={
                    "ok": False,
                    "reason": "ffmpeg_required_over_100mb",
                    "message": "FFmpeg is missing on this server/laptop. Videos up to 100MB can publish directly; this video is larger, so it was deleted safely.",
                    "fallback_limit_mb": FFMPEG_FALLBACK_UNCOMPRESSED_MB,
                })
            elif VIDEO_BACKGROUND_COMPRESSION:
                # Background mode: return after bytes arrive, then let FFmpeg finish in a server worker.
                out_path = folder / (meta_id + "_720p.mp4")
                execute("""
                INSERT INTO media_metadata(meta_id,room_id,device_id,local_media_id,media_kind,filename,mime_type,size_bytes,sha256,encrypted_meta,iv,note,created_at)
                VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
                """, (meta_id, room_id, device_id, meta_id, "video_processing", original, "video/processing", int(original_size), original_sha, "", "", "processing_video:original_uploaded; uploaded_bytes=" + str(int(original_size)) + "; original_mb=" + str(round(original_size/1024/1024, 2)), now()))
                ithaki_start_background_video_job(meta_id, room_id, device_id, path, out_path, original, original_size, original_sha)
                return {
                    "ok": True, "processing": True, "status": "processing", "meta_id": meta_id,
                    "status_url": f"/api/media/status/{meta_id}",
                    "message": "Upload complete. Video compression is running in the background. If FFmpeg fails, videos up to 100MB publish directly; larger videos need FFmpeg.",
                    "uploaded_mb": round(original_size/1024/1024, 2),
                    "fallback_uncompressed_mb": FFMPEG_FALLBACK_UNCOMPRESSED_MB,
                    "compression": {"enabled": True, "status": "processing", "profile": "720p_h264_aac_2500k_faststart", "background": True},
                }
            else:
                # Synchronous fallback if background mode is disabled.
                out_path = folder / (meta_id + "_720p.mp4")
                try:
                    info = await ithaki_ffmpeg_compress_video(path, out_path)
                except RuntimeError as exc:
                    reason = str(exc)[:1200]
                    if ithaki_video_fallback_allowed(original_size):
                        final_path = path
                        final_original_name = original
                        final_mime = ithaki_media_mime_v24691c(original, str(path), file.content_type)
                        final_size = original_size
                        final_sha = original_sha
                        compression = {
                            "enabled": False, "status": "fallback_uncompressed", "fallback": True,
                            "reason": reason, "fallback_limit_mb": FFMPEG_FALLBACK_UNCOMPRESSED_MB,
                            "server_alert": True,
                        }
                        LOG.warning("ITHAKI_VIDEO_FALLBACK_UNCOMPRESSED meta_id=%s size_mb=%.2f reason=%s", meta_id, original_size/1024/1024, reason[:300])
                    else:
                        try:
                            path.unlink(missing_ok=True)
                        except Exception:
                            pass
                        raise HTTPException(status_code=503, detail={
                            "ok": False,
                            "reason": "ffmpeg_required_over_100mb",
                            "message": "Video compression failed and the video is over 100MB. Original was deleted safely; install/fix FFmpeg and upload again.",
                            "ffmpeg": reason,
                            "fallback_limit_mb": FFMPEG_FALLBACK_UNCOMPRESSED_MB,
                        })
                else:
                    try:
                        path.unlink(missing_ok=True)
                    except Exception:
                        pass
                    final_path = out_path
                    final_original_name = (Path(original).stem[:90] or "video") + "_1080p.mp4"
                    final_kind = "video"
                    final_mime = "video/mp4"
                    final_size = int(final_path.stat().st_size)
                    final_sha = _media_sha256_file(final_path)
                    compression = {
                        "enabled": True,
                        "status": "compressed",
                        "profile": "720p_h264_aac_2500k_faststart",
                        "original_bytes": int(original_size),
                        "compressed_bytes": int(final_size),
                        "saved_bytes": max(0, int(original_size) - int(final_size)),
                        "ffmpeg_seconds": info.get("seconds"),
                        "original_deleted": True,
                    }

    # Count storage/quota against the final server file; original was only transient for video.
    record_media_usage(room_id, device_id, final_kind, final_size, "server_file:" + final_original_name[:120])
    rel = final_path.relative_to(MEDIA_FILES).as_posix()
    note = "server_file:" + rel
    if compression.get("enabled"):
        note += "; ffmpeg_compressed_720p_2500k; original_deleted; original_mb=" + str(round(original_size/1024/1024, 2))
    elif compression.get("fallback"):
        note += "; ffmpeg_fallback_uncompressed; fallback_limit_mb=" + str(FFMPEG_FALLBACK_UNCOMPRESSED_MB) + "; server_alert=1; reason=" + str(compression.get("reason") or "ffmpeg_unavailable")[:300].replace(";", ",")
    execute("""
    INSERT INTO media_metadata(meta_id,room_id,device_id,local_media_id,media_kind,filename,mime_type,size_bytes,sha256,encrypted_meta,iv,note,created_at)
    VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
    """, (meta_id, room_id, device_id, meta_id, final_kind, final_original_name, final_mime, final_size, final_sha, "", "", note, now()))
    return {"ok": True, "meta_id": meta_id, "url": f"/api/media/file/{meta_id}", "file_url": f"/api/media/file/{meta_id}", "filename": final_original_name, "media_kind": final_kind, "kind": final_kind, "mime_type": final_mime, "size_bytes": final_size, "size_mb": round(final_size/1024/1024, 2), "uploaded_mb": round(original_size/1024/1024, 2), "compressed_mb": round(final_size/1024/1024, 2), "original_mb": round(original_size/1024/1024, 2), "compression": compression, "message": "Video compressed to 720p OK" if compression.get("enabled") else ("Video published directly under 100MB. BotGuard did not block it; server logged the fallback/direct publish." if compression.get("fallback") else f"Uploaded {round(final_size/1024/1024, 1)}MB OK"), "serverStored": True, "guard": {"botguard_blocked": False, "media_upload_limit": "60/min"}, "quota": quota_status(room_id, device_id)}

@app.get("/api/media/status/{meta_id}")
def api_media_status(meta_id: str):
    init_media_db()
    r = one("SELECT * FROM media_metadata WHERE meta_id=?", (meta_id,))
    if not r:
        raise HTTPException(404, "media status not found")
    d = dict(r)
    note = str(d.get("note") or "")
    if note.startswith("server_file:"):
        return {
            "ok": True, "ready": True, "processing": False, "failed": False,
            "meta_id": meta_id, "url": f"/api/media/file/{meta_id}", "file_url": f"/api/media/file/{meta_id}",
            "filename": d.get("filename") or "video_720p.mp4", "media_kind": d.get("media_kind") or "video", "kind": d.get("media_kind") or "video",
            "mime_type": d.get("mime_type") or "video/mp4", "size_bytes": int(d.get("size_bytes") or 0),
            "message": "Video ready." if "ffmpeg_fallback_uncompressed" not in note else "Video published without compression fallback under 100MB. Server logged FFmpeg warning."
        }
    if note.startswith("processing_failed:") or str(d.get("media_kind") or "") == "video_failed":
        return {"ok": False, "ready": False, "processing": False, "failed": True, "meta_id": meta_id, "reason": note, "message": "Video compression failed or FFmpeg is missing. Original was deleted safely."}
    uploaded_mb = round(float(d.get('size_bytes') or 0)/1024/1024, 2) if d.get('size_bytes') else 0
    return {"ok": True, "ready": False, "processing": True, "failed": False, "meta_id": meta_id, "uploaded_mb": uploaded_mb, "message": "Video is uploaded on the server; compression/processing is still running."}


def _iter_media_file_range(path: Path, start: int, end: int, chunk_size: int = 256 * 1024):
    """Stream byte ranges so Android WebView can seek/play video reliably."""
    with path.open("rb") as fh:
        fh.seek(start)
        remaining = end - start + 1
        while remaining > 0:
            data = fh.read(min(chunk_size, remaining))
            if not data:
                break
            remaining -= len(data)
            yield data


@app.get("/api/media/file/{meta_id}")
def api_media_file(meta_id: str, request: Request):
    # ΕΛΛΗΝΙΚΟ ΣΧΟΛΙΟ:
    # Σερβίρει αποθηκευμένα media (βίντεο/φωτογραφίες/αρχεία) από /api/media/file/{meta_id}.
    # Υποστηρίζει Range headers ώστε τα βίντεο να μπορούν να κάνουν seek/play σε κινητό και browser.
    init_media_db()
    r = one("SELECT * FROM media_metadata WHERE meta_id=?", (meta_id,))
    if not r or not str(r["note"] or "").startswith("server_file:"):
        raise HTTPException(404, "media not found")
    path = ithaki_media_path_from_note(str(r["note"] or ""))
    if path is None:
        raise HTTPException(403, "invalid media path")
    if not path.exists():
        raise HTTPException(404, "file missing")

    filename = str(r["filename"] or "media")
    media_type = ithaki_media_mime_v24691c(filename, str(path), str(r["mime_type"] or ""))
    total = path.stat().st_size
    common_headers = {
        "Accept-Ranges": "bytes",
        "Cache-Control": "public, max-age=86400",
        "Content-Encoding": "identity",
        "X-Content-Type-Options": "",
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Expose-Headers": "Accept-Ranges, Content-Range, Content-Length, Content-Type, Content-Disposition",
    }
    disp_type = "attachment" if str(request.query_params.get("download") or "").lower() in ("1", "true", "yes") else "inline"
    quoted_name = quote(filename.encode("utf-8")) if isinstance(filename, str) else "media"
    common_headers["Content-Disposition"] = f"{disp_type}; filename*=UTF-8''{quoted_name}"

    range_header = request.headers.get("range") or request.headers.get("Range")
    if range_header and range_header.startswith("bytes="):
        try:
            range_value = range_header.replace("bytes=", "", 1).split(",", 1)[0].strip()
            start_s, end_s = (range_value.split("-", 1) + [""])[:2]
            if start_s == "":
                suffix = int(end_s or "0")
                start = max(total - suffix, 0)
                end = total - 1
            else:
                start = int(start_s)
                end = int(end_s) if end_s else total - 1
            start = max(0, min(start, total - 1))
            end = max(start, min(end, total - 1))
        except Exception:
            return Response(status_code=416, headers={**common_headers, "Content-Range": f"bytes */{total}"})

        length = end - start + 1
        headers = {
            **common_headers,
            "Content-Range": f"bytes {start}-{end}/{total}",
            "Content-Length": str(length),
        }
        return StreamingResponse(
            _iter_media_file_range(path, start, end),
            status_code=206,
            media_type=media_type,
            headers=headers,
        )

    return FileResponse(
        str(path),
        media_type=media_type,
        headers={**common_headers, "Content-Length": str(total)},
    )



@app.head("/api/media/file/{meta_id}")
def api_media_file_head(meta_id: str, request: Request):
    # V24691: explicit HEAD headers for mobile video probes.
    init_media_db()
    r = one("SELECT * FROM media_metadata WHERE meta_id=?", (meta_id,))
    if not r or not str(r["note"] or "").startswith("server_file:"):
        raise HTTPException(404, "media not found")
    path = ithaki_media_path_from_note(str(r["note"] or ""))
    if path is None:
        raise HTTPException(403, "invalid media path")
    if not path.exists():
        raise HTTPException(404, "file missing")
    filename = str(r["filename"] or "media")
    media_type = ithaki_media_mime_v24691c(filename, str(path), str(r["mime_type"] or ""))
    total = path.stat().st_size
    disp_type = "attachment" if str(request.query_params.get("download") or "").lower() in ("1", "true", "yes") else "inline"
    quoted_name = quote(filename.encode("utf-8")) if isinstance(filename, str) else "media"
    headers = {
        "Accept-Ranges": "bytes",
        "Content-Length": str(total),
        "Content-Disposition": f"{disp_type}; filename*=UTF-8''{quoted_name}",
        "Cache-Control": "public, max-age=86400",
        "Content-Encoding": "identity",
        "X-Content-Type-Options": "",
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Expose-Headers": "Accept-Ranges, Content-Range, Content-Length, Content-Type, Content-Disposition",
    }
    return Response(status_code=200, media_type=media_type, headers=headers)





def ensure_people_avatar_column_v24614():
    try:
        cols = [str(r[1]) for r in rows("PRAGMA table_info(people)", ())]
        if "avatar" not in cols:
            execute("ALTER TABLE people ADD COLUMN avatar TEXT NOT NULL DEFAULT ''", ())
    except Exception:
        pass

class HeartbeatIn(BaseModel):
    room_id:   str = Field(..., max_length=120)
    device_id: str = Field(..., max_length=120)
    nickname:  str = Field(default="", max_length=80)
    language:  str = Field(default="el", max_length=20)
    mood:      str = Field(default="", max_length=80)
    avatar:    str = Field(default="", max_length=6_000_000)


def _people_visible_online(room_id: str, max_age_seconds: int = 180) -> list[dict]:
    ensure_people_avatar_column_v24614()
    """Return fresh real members, de-duplicated by device and profile name.
    Prevents the owner/self from appearing twice when old app patches inserted both
    "Εσύ" and the saved nickname, while still keeping the newest live contact.
    """
    cutoff = now() - max_age_seconds
    raw = [dict(r) for r in rows("""
        SELECT * FROM people
        WHERE room_id=? AND last_seen>=?
          AND device_id NOT IN ('ithaki_auto_seed','ithaki_auto_host','ithaki_host')
          AND lower(device_id) NOT LIKE 'demo%'
          AND lower(nickname) NOT LIKE 'demo%'
        ORDER BY last_seen DESC
    """, (room_id, cutoff))]
    seen_ids, seen_names, out = set(), set(), []
    visitor_names = {'', 'visitor', 'επισκέπτης', 'επισκεπτης', 'ξένος', 'ξενος', 'anonymous', 'ανώνυμος', 'ανωνυμος', 'εσύ', 'εγω', 'εγώ'}
    def norm_name(v: str) -> str:
        try:
            import unicodedata
            v = unicodedata.normalize('NFD', str(v or '').strip().lower())
            v = ''.join(ch for ch in v if unicodedata.category(ch) != 'Mn')
            return re.sub(r'[^0-9a-zα-ω]+', ' ', v).strip()
        except Exception:
            return str(v or '').strip().lower()
    for r in raw:
        did = str(r.get('device_id') or '').strip()
        if not did or did in seen_ids:
            continue
        nk = norm_name(r.get('nickname') or '')
        # Same real profile name = one visible person; keep the freshest row.
        if nk and nk not in visitor_names and nk in seen_names:
            continue
        seen_ids.add(did)
        if nk and nk not in visitor_names:
            seen_names.add(nk)
        out.append(r)
    return out


class MessageIn(BaseModel):
    room_id:      str  = Field(..., max_length=120)
    channel:      str  = Field(default="main", max_length=180)
    sender_id:    str  = Field(..., max_length=120)
    nickname:     str  = Field(default="", max_length=80)
    language:     str  = Field(default="el", max_length=20)
    text:         str  = Field(default="", max_length=50_000_000)
    is_encrypted: bool = False
    iv:           str  = Field(default="", max_length=300)
    cipher_text:  str  = Field(default="", max_length=50_000_000)
    zero_identity: bool = False




class MessageReadIn(BaseModel):
    room_id: str = Field(..., max_length=120)
    channel: str = Field(default="main", max_length=180)
    reader_id: str = Field(..., max_length=120)
    read_until: float = 0


class MessageTypingIn(BaseModel):
    room_id: str = Field(..., max_length=120)
    channel: str = Field(default="main", max_length=180)
    device_id: str = Field(..., max_length=120)
    nickname: str = Field(default="", max_length=80)
    is_typing: bool = False


class FlowIn(BaseModel):
    room_id:      str  = Field(..., max_length=120)
    sender_id:    str  = Field(..., max_length=120)
    nickname:     str  = Field(default="", max_length=80)
    language:     str  = Field(default="el", max_length=20)
    text:         str  = Field(default="", max_length=50_000_000)
    is_encrypted: bool = False
    iv:           str  = Field(default="", max_length=300)
    cipher_text:  str  = Field(default="", max_length=50_000_000)
    post_kind:    str  = Field(default="text", max_length=30)
    anonymous:    bool = True
    visibility:   str  = Field(default="public", max_length=30)


class RoomIn(BaseModel):
    room_id:     str = Field(..., max_length=120)
    owner_id:    str = Field(default="", max_length=120)
    title:       str = Field(..., max_length=120)
    description: str = Field(default="", max_length=1000)
    parent_id:   str = Field(default="", max_length=120)
    category:    str = Field(default="", max_length=80)
    access_type: str = Field(default="public", max_length=40)  # public / invite / private_code
    join_code:   str = Field(default="", max_length=120)
    owner_secret:str = Field(default="", max_length=120)


class ReactionIn(BaseModel):
    target_id: str = Field(..., max_length=160)
    device_id: str = Field(..., max_length=120)
    emoji:     str = Field(default="👍", max_length=20)
    nickname:  str = Field(default="", max_length=80)


class ReportIn(BaseModel):
    room_id:     str = ""
    reporter_id: str = ""
    target_type: str = ""
    target_id:   str = ""
    reason:      str = ""



# ──────────────────────────────────────────────
# Clean public stats + ΙΘΑΚΗ Host user for live flow
# ──────────────────────────────────────────────
def cleanup_demo_users_and_stats():
    """Remove old demo/sample rows so Admin stats and ban lists show only real users."""
    try:
        init_admin_db()
        patterns = ("%demo%", "%sample%", "%fake%", "%mock%", "%bot%", "%robot%", "%κανένας%", "%kaneis%")
        for pat in patterns:
            execute("DELETE FROM people WHERE lower(device_id) LIKE ? OR lower(nickname) LIKE ?", (pat, pat))
            execute("DELETE FROM admin_bans WHERE lower(device_id) LIKE ? OR lower(nickname) LIKE ?", (pat, pat))
            try:
                execute("DELETE FROM messages WHERE lower(sender_id) LIKE ? OR lower(nickname) LIKE ?", (pat, pat))
                execute("DELETE FROM flow_posts WHERE lower(sender_id) LIKE ? OR lower(nickname) LIKE ?", (pat, pat))
            except Exception:
                pass
        # Do not count host/seed robots as online members.
        execute("DELETE FROM people WHERE device_id IN ('ithaki_auto_seed','ithaki_auto_host','ithaki_host')", ())
    except Exception:
        try:
            execute("DELETE FROM people WHERE device_id IN ('ithaki_auto_seed','ithaki_auto_host','ithaki_host')", ())
        except Exception:
            pass

_AUTO_HOST_TEXTS = [
    {"community":"philosophy","title":"Επίκουρος","text":"Παρηγορητική σκέψη: ας ξεχωρίσουμε σήμερα το αναγκαίο από τον θόρυβο. Ποιο μικρό πράγμα θα έκανε τη μέρα πιο ελαφριά;"},
    {"community":"philosophy","title":"Σωκράτης","text":"Σωκρατική ερώτηση: τι χρειάζομαι αληθινά τώρα και τι απλώς με μπερδεύει;"},
    {"community":"philosophy","title":"Ηράκλειτος","text":"Ηρακλείτεια σκέψη: όλα αλλάζουν. Ποιο μέτρο κρατάμε μέσα μας όταν αλλάζει ο κόσμος;"},
    {"community":"philosophy","title":"Stoics","text":"Στωική σκέψη: δεν ελέγχουμε τα πάντα, αλλά μπορούμε να διαλέξουμε την επόμενη τίμια πράξη."},
    {"community":"philosophy","title":"Λάο Τσε","text":"Ανατολική σκέψη: το ήρεμο νερό προχωρά χωρίς να φωνάζει. Πού χρειάζεται λιγότερη ένταση σήμερα;"},
    {"community":"philosophy","title":"Βούδας","text":"Σκέψη ηρεμίας: ανάπνευσε, παρατήρησε, και άσε την καταιγίδα να περάσει χωρίς να γίνεις καταιγίδα."},
    {"community":"work","title":"Δουλειά και αξιοπρέπεια","text":"Ερώτηση εργασίας: τι χρειάζεται περισσότερο ο άνθρωπος στη δουλειά — χρήματα, σεβασμό, χρόνο ή ασφάλεια;"},
    {"community":"justice","title":"Δικαιοσύνη","text":"Ανθρώπινη ερώτηση: πώς θα έμοιαζε μια δικαιοσύνη που θεραπεύει αντί μόνο να τιμωρεί;"},
    {"community":"environment","title":"Περιβάλλον","text":"Η φύση δεν είναι αποθήκη. Ποια μικρή αλλαγή θα έκανε τη γειτονιά μας πιο ανθρώπινη;"},
    {"community":"education","title":"Σπουδές και μάθηση","text":"Παιδεία: τι πρέπει να μαθαίνει ένας νέος για να σταθεί ελεύθερος και χρήσιμος;"},
]

def auto_host_post_once(room_id: str = "ithaki-phone-test", force: bool = False, custom_text: str = "") -> dict:
    try:
        init_db()
        try:
            fc = _v249217_feed_control_settings(room_id)
            v_total = _v249217_count_public_videos(room_id)
            if v_total < int(fc.get("min_videos_before_cards", 5)):
                return {"ok": False, "guard": "min_videos_before_cards", "videos": v_total, "required": int(fc.get("min_videos_before_cards", 5))}
        except Exception:
            pass
        key = f"auto_host_last_ms::{room_id}"
        try:
            _fc_int = _v249217_feed_control_settings(room_id)
            interval = int(_fc_int.get("card_interval_minutes", 10)) * 60 * 1000
        except Exception:
            interval = int(_runtime_config_value("auto_host_interval_seconds", 600)) * 1000
        interval = max(60_000, min(interval, 24 * 3_600_000))
        if not force:
            ok, left = app_state_claim_cooldown(key, interval)
            if not ok:
                return {"ok": False, "guard": "cooldown", "seconds_left": left}
        slot = int(time.time() // max(600, interval // 1000))
        if custom_text:
            item = {"community": "casual", "title": "ΙΘΑΚΗ Host", "text": custom_text.strip()}
        else:
            item = _AUTO_HOST_TEXTS[slot % len(_AUTO_HOST_TEXTS)]
        body = str(item.get("text") or "").strip()
        community_id = str(item.get("community") or "casual")
        title = str(item.get("title") or "ΙΘΑΚΗ Host")
        pid = nid("flow")
        post_kind = "host"
        poll_id = ""
        collage = [
            {"icon":"⛵","title":title,"text":body},
            {"icon":"💭","title":"Σκέψου","text":"Τι σημαίνει αυτό για σένα σήμερα;"},
            {"icon":"🤝","title":"Μοιράσου","text":"Γράψε τη δική σου εμπειρία με σεβασμό."},
            {"icon":"🌍","title":"Κοινότητα","text":community_id},
        ]
        # Περίπου μία στις τέσσερις αυτόματες δημοσιεύσεις της ΙΘΑΚΗΣ είναι ψηφοφορία.
        if not custom_text and slot % 4 == 3:
            polls = [
                ("Τι χρειάζεται περισσότερο η καθημερινότητά μας σήμερα;", ["Περισσότερος χρόνος","Οικονομική ασφάλεια","Σεβασμός","Ανθρώπινη επαφή"]),
                ("Ποιο θέμα θέλεις να συζητήσει περισσότερο η ΙΘΑΚΗ;", ["Καθημερινότητα","Σχέσεις","Εργασία","Ιδέες και δημιουργία"]),
                ("Τι σε βοηθά περισσότερο να νιώθεις καλύτερα;", ["Μια συζήτηση","Μουσική","Βόλτα στη φύση","Ήρεμος προσωπικός χρόνος"]),
            ]
            question, options = polls[(slot // 4) % len(polls)]
            poll_id = nid("poll")
            execute("""
            INSERT INTO polls(poll_id,room_id,community_id,creator_id,nickname,question,options_json,anonymous,allow_stats,created_at,closed)
            VALUES(?,?,?,?,?,?,?,?,?,?,0)
            """, (poll_id, room_id, community_id, "ithaki_auto_host", "ΙΘΑΚΗ Host", question, json.dumps(options, ensure_ascii=False), 1, 1, now()))
            title, body, post_kind = "🗳️ Ψηφοφορία ΙΘΑΚΗΣ", question, "poll"
            collage = [
                {"icon":"⛵","title":"ΙΘΑΚΗ Host","text":"Η γνώμη σου μετράει."},
                {"icon":"🗳️","title":"Ψηφοφορία","text":question},
                {"icon":"🔒","title":"Ανώνυμα","text":"Η επιλογή καταγράφεται χωρίς να εμφανίζεται το όνομά σου."},
            ]
            payload_obj = {"title":title,"body":body,"text":body,"kind":"poll","pollId":poll_id,"question":question,"options":options,"layout":"collage","collage":collage,"community":community_id,"targetCommunity":community_id}
        else:
            payload_obj = {"title":title,"text":body,"body":body,"tags":["ithaki-host","live-flow",community_id],"kind":"host","layout":"collage","collage":collage,"anon":False,"audience":"community","targetCommunity":community_id,"seed_meta":{"engine":"ithaki_host","created_at":now(),"legal_note":"Original Greek discussion prompt generated by OK ITHAKI server."}}
        execute("""
        INSERT INTO flow_posts(post_id,room_id,sender_id,nickname,language,text,is_encrypted,iv,cipher_text,post_kind,anonymous,visibility,created_at)
        VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
        """, (pid, room_id, "ithaki_auto_host", "ΙΘΑΚΗ Host", "el", json.dumps(payload_obj, ensure_ascii=False), 0, "", "", post_kind, 0, "public", now()))
        return {"ok": True, "post_id": pid, "poll_id": poll_id, "nickname": "ΙΘΑΚΗ Host", "community_id": community_id, "interval_seconds": interval//1000}
    except Exception as exc:
        LOG.exception("auto_host_post_once failed")
        return {"ok": False, "error": str(exc)}


@app.post("/api/auto_host/cron")
async def api_auto_host_cron(request: Request):
    """Safe cron endpoint for Feed: creates at most one ITHAKI Host post per cooldown."""
    try:
        data = await request.json()
    except Exception:
        data = {}
    room_id = str((data or {}).get("room_id") or _runtime_config_value("auto_host_room_id", "ithaki-phone-test"))
    return auto_host_post_once(room_id, force=False)

@app.get("/api/auto_host/cron")
def api_auto_host_cron_get(room_id: str = Query("ithaki-phone-test")):
    return auto_host_post_once(room_id, force=False)

def _runtime_config_value(key: str, default: Any) -> Any:
    try:
        p = ROOT / "relay_runtime_config.json"
        if p.exists():
            return json.loads(p.read_text(encoding="utf-8")).get(key, default)
    except Exception:
        pass
    return default


def _runtime_config_update(values: dict[str, Any]) -> dict[str, Any]:
    p = ROOT / "relay_runtime_config.json"
    try:
        data = json.loads(p.read_text(encoding="utf-8")) if p.exists() else {}
    except Exception:
        data = {}
    allowed = {
        "active_api_url", "mobile_public_url", "message_ttl_hours",
        "auto_host_enabled", "auto_host_interval_seconds", "auto_host_room_id",
        "server_only_media_metadata", "updated_at"
    }
    for k, v in values.items():
        if k in allowed and v is not None:
            data[k] = v
    data["updated_at"] = now()
    p.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
    return data

def _auto_host_loop():
    room = str(_runtime_config_value("auto_host_room_id", "ithaki-phone-test"))
    while True:
        try:
            if bool(_runtime_config_value("auto_host_enabled", True)):
                auto_host_post_once(room, force=False)
        except Exception:
            LOG.exception("auto host loop error")
        time.sleep(max(60, int(_runtime_config_value("auto_host_interval_seconds", 600))))

def start_auto_host_thread():
    global _AUTO_HOST_THREAD_STARTED
    if _AUTO_HOST_THREAD_STARTED:
        return
    if not bool(_runtime_config_value("auto_host_enabled", True)):
        return
    _AUTO_HOST_THREAD_STARTED = True
    import threading
    threading.Thread(target=_auto_host_loop, daemon=True, name="ithaki_auto_host").start()

# ──────────────────────────────────────────────
# Startup
# ──────────────────────────────────────────────
@app.on_event("startup")
def startup():
    init_db()
    _app_state_ensure()
    init_seed_db()
    init_admin_db()
    init_privacy_db()
    init_push_db()
    init_conversation_state_db()
    init_cost_guard_db()
    init_media_db()
    init_member_control_db()
    init_social_v166_db()
    try:
        init_video_call_db()
        _video_cleanup("ithaki-phone-test")
        _close_duplicate_live_calls_v249260("ithaki-phone-test")
        _cleanup_call_timeline_messages_v249260("ithaki-phone-test")
    except Exception:
        LOG.exception("V249260 startup call cleanup failed")
    cleanup_demo_users_and_stats()
    ensure_uploads_open_default("ithaki-phone-test")
    try:
        # First visible flow must not look empty: create one legal Greek Host post when the room has no flow.
        n = one("SELECT COUNT(*) n FROM flow_posts WHERE room_id=?", ("ithaki-phone-test",))["n"]
        if not n and bool(_runtime_config_value("auto_host_enabled", True)):
            auto_host_post_once("ithaki-phone-test", force=True)
    except Exception:
        LOG.exception("initial auto host post failed")
    start_seed_thread()
    start_auto_host_thread()


# ──────────────────────────────────────────────
# Static pages
# ──────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
def root_page():
    # v1.9.9 PC FIX: serve the HTML with no-store headers so the Windows browser
    # does not keep an old broken copy after we patch the frontend.
    p = FRONTEND / "index.html"
    if p.exists():
        return HTMLResponse(
            p.read_text(encoding="utf-8", errors="replace"),
            headers={
                "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
                "Pragma": "no-cache",
                "Expires": "0",
            },
        )
    return HTMLResponse("<h1>ITHAKI Relay</h1><p>Frontend missing.</p>")


@app.get("/app", response_class=HTMLResponse)
def app_page():
    return root_page()




@app.get("/preclean.js")
def frontend_preclean_js():
    """Serve the preclean guard before legacy frontend modules load."""
    p = FRONTEND / "preclean.js"
    if p.exists():
        return FileResponse(
            str(p),
            media_type="application/javascript; charset=utf-8",
            headers={
                "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
                "Pragma": "no-cache",
                "Expires": "0",
            },
        )
    raise HTTPException(status_code=404, detail="preclean.js missing")


@app.get("/index.js")
def frontend_index_js():
    # v1.9.9 PC FIX: the PC web app was loading /index.js?v=198,
    # but the server did not expose /index.js. Result: HTML loaded, JS 404,
    # so all buttons looked dead. Do not touch APK assets here.
    p = FRONTEND / "index.js"
    if p.exists():
        return FileResponse(
            str(p),
            media_type="application/javascript; charset=utf-8",
            headers={
                "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
                "Pragma": "no-cache",
                "Expires": "0",
            },
        )
    raise HTTPException(status_code=404, detail="index.js missing")






@app.get("/clean_rebuild_v249342.js")
def frontend_clean_rebuild_v249342_js():
    """V249342: expose final clean UI layer publicly for browser/APK verification."""
    p = FRONTEND / "clean_rebuild_v249342.js"
    if p.exists():
        return FileResponse(
            str(p),
            media_type="application/javascript; charset=utf-8",
            headers={
                "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
                "Pragma": "no-cache",
                "Expires": "0",
            },
        )
    raise HTTPException(status_code=404, detail="clean_rebuild_v249342.js missing")

@app.get("/clean_rebuild_v249337.js")
def frontend_clean_rebuild_v249337_js():
    """Compatibility route so older V249337 HTML does not 404."""
    p = FRONTEND / "clean_rebuild_v249337.js"
    if p.exists():
        return FileResponse(
            str(p),
            media_type="application/javascript; charset=utf-8",
            headers={
                "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
                "Pragma": "no-cache",
                "Expires": "0",
            },
        )
    raise HTTPException(status_code=404, detail="clean_rebuild_v249337.js missing")


@app.get("/app.js")
def frontend_app_js():
    # V24793: clean consolidated layer, loaded after index.js. Mirrors /index.js.
    p = FRONTEND / "app.js"
    if p.exists():
        return FileResponse(
            str(p),
            media_type="application/javascript; charset=utf-8",
            headers={
                "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
                "Pragma": "no-cache",
                "Expires": "0",
            },
        )
    raise HTTPException(status_code=404, detail="app.js missing")


@app.get("/js/{filename}")
def frontend_js_file(filename: str):
    # V24692A FIX: expose modular frontend files (core/ui/chat/media/admin/legacy).
    # Without this route the clean loader can load /index.js but fails on js/core.js.
    if "/" in filename or "\\" in filename or ".." in filename:
        raise HTTPException(status_code=400, detail="bad js filename")
    p = FRONTEND / "js" / filename
    if p.exists() and p.is_file() and filename.lower().endswith(".js"):
        return FileResponse(
            str(p),
            media_type="application/javascript; charset=utf-8",
            headers={
                "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
                "Pragma": "no-cache",
                "Expires": "0",
            },
        )
    raise HTTPException(status_code=404, detail=f"js/{filename} missing")


@app.get("/app_connection_config.json")
def frontend_connection_config():
    p = FRONTEND / "app_connection_config.json"
    if p.exists():
        return FileResponse(
            str(p),
            media_type="application/json",
            headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"},
        )
    return JSONResponse(get_bootstrap(), headers={"Cache-Control": "no-store"})


@app.get("/ok_ithaki_ship_logo.png")
def ok_ithaki_ship_logo():
    p = FRONTEND / "ok_ithaki_ship_logo.png"
    if p.exists():
        return FileResponse(str(p), headers={"Cache-Control": "public, max-age=3600"})
    raise HTTPException(status_code=404, detail="logo missing")


@app.get("/ithaki_ship_real.png")
def ithaki_ship_real_png_v24659():
    # ΕΛΛΗΝΙΚΟ ΣΧΟΛΙΟ:
    # Ο browser ζητάει το καράβι από τη ρίζα: /ithaki_ship_real.png.
    # Πριν δεν υπήρχε τέτοιο route, άρα ο server απαντούσε 404 και το UI έκανε συνεχείς αποτυχημένες κλήσεις.
    # Εδώ δίνουμε ρητά την εικόνα από τον φάκελο frontend.
    p = FRONTEND / "ithaki_ship_real.png"
    if p.exists():
        return FileResponse(
            str(p),
            media_type="image/png",
            headers={"Cache-Control": "public, max-age=86400"},
        )
    # ΕΛΛΗΝΙΚΟ ΣΧΟΛΙΟ:
    # Αν λείπει το καινούργιο αρχείο, κρατάμε fallback στο παλιό logo για να μη σπάει το frontend.
    fallback = FRONTEND / "ok_ithaki_ship_logo.png"
    if fallback.exists():
        return FileResponse(str(fallback), media_type="image/png", headers={"Cache-Control": "public, max-age=86400"})
    raise HTTPException(status_code=404, detail="ithaki real ship image missing")

@app.get("/assets/ithaki_ship_real.png")
def ithaki_ship_real_asset_png_v24659():
    # ΕΛΛΗΝΙΚΟ ΣΧΟΛΙΟ:
    # Δεύτερη ασφαλής διαδρομή για APK/WebView ή παλιά CSS που ψάχνουν το αρχείο μέσα σε /assets/.
    return ithaki_ship_real_png_v24659()



# ΕΛΛΗΝΙΚΟ ΣΧΟΛΙΟ V24661:
# Ο browser ζητάει αυτόματα /favicon.ico.
# Αν δεν υπάρχει route, εμφανίζει 404 στο log. Δεν χαλάει μόνο του τη Ροή,
# αλλά το δίνουμε από το ίδιο καραβάκι για καθαρό log και σωστό brand.
@app.get("/favicon.ico")
def favicon_ico_v24661():
    p = FRONTEND / "ithaki_ship_real.png"
    if p.exists():
        return FileResponse(str(p), media_type="image/png", headers={"Cache-Control": "public, max-age=86400"})
    return JSONResponse({"ok": False, "error": "favicon missing"}, status_code=404)


@app.get("/server-admin", response_class=HTMLResponse)
def server_admin_page():
    p = ROOT / "admin" / "server_admin.html"
    if p.exists():
        return FileResponse(str(p))
    return HTMLResponse("<h1>OK ITHAKI Server Admin</h1><p>admin html missing</p>")


@app.get("/admin/server", response_class=HTMLResponse)
def server_admin_page_alias_v24782():
    return server_admin_page()



@app.get("/api/frontend/build_v249359")
def frontend_build_v249359():
    """Reports the exact frontend files used by the running Python process,
    and whether the V249359 real-fix layer (wall migration / feed search /
    shared poll voting) is actually present in what's being served."""
    def _sha(path: Path) -> str:
        try:
            return hashlib.sha256(path.read_bytes()).hexdigest()
        except Exception:
            return ""
    html = FRONTEND / "index.html"
    js = FRONTEND / "index.js"
    html_txt = html.read_text(encoding="utf-8", errors="replace") if html.exists() else ""
    js_txt = js.read_text(encoding="utf-8", errors="replace") if js.exists() else ""
    return JSONResponse({
        "ok": True,
        "version": VERSION,
        "process_root": str(ROOT),
        "frontend_dir": str(FRONTEND),
        "index_html_sha256": _sha(html),
        "index_js_sha256": _sha(js),
        "html_has_v249359": "V249359_WALL_SEARCH_POLL_REAL_FIX" in html_txt,
        "js_has_v249359": "__ITHAKI_V249359_REAL_FIX__" in js_txt,
        "backend_has_wall_migration": "_v249359_migrate_old_wall_posts" in Path(__file__).read_text(encoding="utf-8", errors="replace"),
        "backend_has_feed_search": "api_flow_search_v249359" in Path(__file__).read_text(encoding="utf-8", errors="replace"),
        "backend_has_shared_polls": "api_polls_get_or_create_v249359" in Path(__file__).read_text(encoding="utf-8", errors="replace"),
    }, headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"})


@app.get("/api/frontend/build_v249358")
def frontend_build_v249358():
    """Reports the exact frontend files used by the running Python process."""
    def _sha(path: Path) -> str:
        try:
            return hashlib.sha256(path.read_bytes()).hexdigest()
        except Exception:
            return ""
    html = FRONTEND / "index.html"
    js = FRONTEND / "index.js"
    return JSONResponse({
        "ok": True,
        "version": VERSION,
        "process_root": str(ROOT),
        "frontend_dir": str(FRONTEND),
        "index_html_exists": html.exists(),
        "index_js_exists": js.exists(),
        "index_html_sha256": _sha(html),
        "index_js_sha256": _sha(js),
        "html_has_v249358": ("V249358_SINGLE_ACTIVE_V324_RENDERER" in html.read_text(encoding="utf-8", errors="replace")) if html.exists() else False,
        "js_has_v249358": ("V249358 SINGLE ACTIVE V324" in js.read_text(encoding="utf-8", errors="replace")) if js.exists() else False,
        "ui_patch_v249358_present": ("ithFeedList324" in js.read_text(encoding="utf-8", errors="replace")) if js.exists() else False,
        "runtime_conflict_guard_v249358": ("V249342 INLINE FINAL CLEAN LAYER" not in js.read_text(encoding="utf-8", errors="replace")) if js.exists() else False,
        "old_index_renderer_v249358": ("athenaLexicon351" in js.read_text(encoding="utf-8", errors="replace")) if js.exists() else False,
    }, headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"})


@app.get("/frontend_build_v249358.txt")
def frontend_build_marker_v249358():
    p = FRONTEND / "frontend_build_v249358.txt"
    if p.exists():
        return FileResponse(str(p), media_type="text/plain; charset=utf-8", headers={
            "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
            "Pragma": "no-cache",
            "Expires": "0",
        })
    raise HTTPException(status_code=404, detail="V249358 frontend marker missing")

# ──────────────────────────────────────────────
# Public health
# ──────────────────────────────────────────────

# ──────────────────────────────────────────────
# ITHAKI V249141 Phone Microserver Wi-Fi Relay base
# Safe local-server layer: consent, peer radar and store-forward relay packets.
# True phone-to-phone Wi-Fi/Hotspot/Wi-Fi Direct relay uses the APK phone microserver module.
# ──────────────────────────────────────────────
def _mesh_init_db():
    execute("""
    CREATE TABLE IF NOT EXISTS mesh_peers(
        room_id TEXT NOT NULL,
        device_id TEXT NOT NULL,
        nickname TEXT,
        avatar TEXT,
        mode TEXT,
        base_url TEXT,
        consent INTEGER NOT NULL DEFAULT 0,
        capabilities TEXT,
        last_seen REAL NOT NULL,
        PRIMARY KEY(room_id, device_id)
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS mesh_packets(
        packet_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL,
        from_id TEXT NOT NULL,
        to_id TEXT NOT NULL,
        nickname TEXT,
        kind TEXT,
        text TEXT,
        file_meta TEXT,
        created_at REAL NOT NULL,
        delivered INTEGER NOT NULL DEFAULT 0
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_mesh_peers_room_seen ON mesh_peers(room_id,last_seen)")
    execute("CREATE INDEX IF NOT EXISTS idx_mesh_packets_inbox ON mesh_packets(room_id,to_id,created_at)")

@app.post("/api/mesh/announce")
def api_mesh_announce(payload: dict, request: Request):
    _mesh_init_db()
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    device_id = str(payload.get("device_id") or "")[:160]
    if not device_id:
        raise HTTPException(400, "device_id required")
    nickname = str(payload.get("nickname") or "Member")[:120]
    avatar = str(payload.get("avatar") or "")[:50000]
    mode = str(payload.get("mode") or "hotspot")[:40]
    base_url = str(payload.get("base_url") or "")[:300]
    consent = 1 if bool(payload.get("consent")) else 0
    caps = json.dumps(payload.get("capabilities") or [], ensure_ascii=False)[:2000]
    ts = time.time()
    execute("""
        INSERT INTO mesh_peers(room_id,device_id,nickname,avatar,mode,base_url,consent,capabilities,last_seen)
        VALUES(?,?,?,?,?,?,?,?,?)
        ON CONFLICT(room_id,device_id) DO UPDATE SET
          nickname=excluded.nickname, avatar=excluded.avatar, mode=excluded.mode,
          base_url=excluded.base_url, consent=excluded.consent,
          capabilities=excluded.capabilities, last_seen=excluded.last_seen
    """, (room_id, device_id, nickname, avatar, mode, base_url, consent, caps, ts))
    return {"ok": True, "room_id": room_id, "device_id": device_id, "last_seen": ts, "consent": bool(consent)}

@app.get("/api/mesh/radar")
def api_mesh_radar(room_id: str = Query("ithaki-phone-test"), device_id: str = Query(""), ttl: int = Query(900)):
    _mesh_init_db()
    cutoff = time.time() - max(60, min(int(ttl or 900), 7200))
    rs = rows("""
        SELECT room_id,device_id,nickname,avatar,mode,base_url,consent,capabilities,last_seen
        FROM mesh_peers WHERE room_id=? AND last_seen>=? ORDER BY last_seen DESC LIMIT 100
    """, (room_id[:120], cutoff))
    peers=[]
    for r in rs:
        d=dict(r)
        if device_id and d.get("device_id")==device_id:
            continue
        try: d["capabilities"] = json.loads(d.get("capabilities") or "[]")
        except Exception: d["capabilities"] = []
        d["consent"] = bool(d.get("consent"))
        peers.append(d)
    return {"ok": True, "peers": peers, "ttl": ttl, "note": "local-server radar; APK phone microserver on port 8099 is used for phone-node tests; Wi-Fi Direct routing is native"}

@app.post("/api/mesh/packet")
def api_mesh_packet(payload: dict):
    _mesh_init_db()
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    from_id = str(payload.get("from_id") or "")[:160]
    to_id = str(payload.get("to_id") or "")[:160]
    if not from_id or not to_id:
        raise HTTPException(400, "from_id and to_id required")
    if from_id != to_id:
        peer = one("SELECT consent FROM mesh_peers WHERE room_id=? AND device_id=?", (room_id, to_id))
        if not peer or int(peer["consent"] or 0) != 1:
            raise HTTPException(403, "target has not consented to emergency relay")
    pid = "mesh_" + uuid.uuid4().hex
    nickname = str(payload.get("nickname") or "Member")[:120]
    kind = str(payload.get("kind") or "text")[:40]
    text = str(payload.get("text") or "")[:20000]
    file_meta = json.dumps(payload.get("file_meta") or {}, ensure_ascii=False)[:4000]
    ts = time.time()
    execute("INSERT INTO mesh_packets(packet_id,room_id,from_id,to_id,nickname,kind,text,file_meta,created_at,delivered) VALUES(?,?,?,?,?,?,?,?,?,0)",
            (pid, room_id, from_id, to_id, nickname, kind, text, file_meta, ts))
    return {"ok": True, "packet_id": pid, "created_at": ts}

@app.get("/api/mesh/inbox")
def api_mesh_inbox(room_id: str = Query("ithaki-phone-test"), device_id: str = Query(...), mark_read: int = Query(1)):
    _mesh_init_db()
    rs = rows("""
        SELECT packet_id,room_id,from_id,to_id,nickname,kind,text,file_meta,created_at,delivered
        FROM mesh_packets WHERE room_id=? AND to_id=? ORDER BY created_at DESC LIMIT 100
    """, (room_id[:120], device_id[:160]))
    packets=[]
    for r in rs:
        d=dict(r)
        try: d["file_meta"] = json.loads(d.get("file_meta") or "{}")
        except Exception: d["file_meta"] = {}
        packets.append(d)
    if mark_read:
        execute("UPDATE mesh_packets SET delivered=1 WHERE room_id=? AND to_id=?", (room_id[:120], device_id[:160]))
    return {"ok": True, "packets": packets}

@app.post("/api/mesh/consent")
def api_mesh_consent(payload: dict):
    _mesh_init_db()
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    device_id = str(payload.get("device_id") or "")[:160]
    consent = 1 if bool(payload.get("consent")) else 0
    if not device_id:
        raise HTTPException(400, "device_id required")
    execute("UPDATE mesh_peers SET consent=?, last_seen=? WHERE room_id=? AND device_id=?", (consent, time.time(), room_id, device_id))
    return {"ok": True, "consent": bool(consent)}



# ──────────────────────────────────────────────
# ITHAKI V249291 - Περιστέρι real emergency mesh layer
# Keeps the old dove/pigeon idea, but adds explicit node status, TTL, duplicate guard,
# store-and-forward metadata and diagnostics for phone-server relay.
# ──────────────────────────────────────────────
def _mesh_v249291_init_db():
    _mesh_init_db()
    for ddl in [
        "ALTER TABLE mesh_packets ADD COLUMN ttl INTEGER NOT NULL DEFAULT 5",
        "ALTER TABLE mesh_packets ADD COLUMN hops INTEGER NOT NULL DEFAULT 0",
        "ALTER TABLE mesh_packets ADD COLUMN route TEXT",
        "ALTER TABLE mesh_packets ADD COLUMN expires_at REAL",
        "ALTER TABLE mesh_packets ADD COLUMN priority TEXT",
        "ALTER TABLE mesh_packets ADD COLUMN relay_count INTEGER NOT NULL DEFAULT 0",
        "ALTER TABLE mesh_packets ADD COLUMN ack_count INTEGER NOT NULL DEFAULT 0",
    ]:
        try:
            execute(ddl)
        except Exception:
            pass
    execute("""
    CREATE TABLE IF NOT EXISTS mesh_packet_seen_v249291(
        room_id TEXT NOT NULL,
        packet_id TEXT NOT NULL,
        device_id TEXT NOT NULL,
        seen_at REAL NOT NULL,
        PRIMARY KEY(room_id, packet_id, device_id)
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS mesh_media_index_v249291(
        media_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL,
        owner_id TEXT NOT NULL,
        kind TEXT,
        filename TEXT,
        size INTEGER DEFAULT 0,
        content_type TEXT,
        created_at REAL NOT NULL,
        expires_at REAL
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_mesh_seen_v249291_device ON mesh_packet_seen_v249291(room_id,device_id,seen_at)")
    execute("CREATE INDEX IF NOT EXISTS idx_mesh_packets_v249291_ttl ON mesh_packets(room_id,created_at,expires_at)")

@app.post("/api/mesh/pigeon/announce_v249291")
def api_mesh_pigeon_announce_v249291(payload: dict, request: Request):
    _mesh_v249291_init_db()
    payload = dict(payload or {})
    caps = payload.get("capabilities") or []
    if "pigeon-real-mesh-v249291" not in caps:
        caps.append("pigeon-real-mesh-v249291")
    if "ttl-store-forward" not in caps:
        caps.append("ttl-store-forward")
    if "duplicate-guard" not in caps:
        caps.append("duplicate-guard")
    payload["capabilities"] = caps
    r = api_mesh_announce(payload, request)
    return {**r, "pigeon": True, "features": ["phone_server", "ttl", "duplicate_guard", "store_forward", "small_media_metadata"]}

@app.post("/api/mesh/pigeon/send_v249291")
def api_mesh_pigeon_send_v249291(payload: dict):
    _mesh_v249291_init_db()
    payload = payload or {}
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    from_id = str(payload.get("from_id") or "")[:160]
    if not from_id:
        raise HTTPException(400, "from_id required")
    nickname = str(payload.get("nickname") or "Member")[:120]
    kind = str(payload.get("kind") or "text")[:40]
    text = str(payload.get("text") or "")[:20000]
    file_meta = payload.get("file_meta") or {}
    ttl = max(1, min(int(payload.get("ttl") or 5), 12))
    priority = str(payload.get("priority") or "normal")[:40]
    expires_at = time.time() + max(1800, min(int(payload.get("expires_seconds") or 86400), 604800))
    raw_targets = payload.get("to_ids") or []
    if isinstance(raw_targets, str):
        raw_targets = [raw_targets]
    target_ids = [str(x)[:160] for x in raw_targets if str(x or "").strip()]
    if not target_ids or "*" in target_ids:
        rs = rows("SELECT device_id,consent FROM mesh_peers WHERE room_id=? AND device_id<>? AND last_seen>=? ORDER BY last_seen DESC LIMIT 80", (room_id, from_id, time.time()-86400))
        target_ids = [str(r["device_id"]) for r in rs if int(r["consent"] or 0) == 1]
    if not target_ids:
        # keep one broadcast packet for late sync in the local room
        target_ids = ["*"]
    created=[]
    now=time.time()
    for to_id in target_ids[:80]:
        if to_id not in ("*", from_id):
            peer = one("SELECT consent FROM mesh_peers WHERE room_id=? AND device_id=?", (room_id, to_id))
            if peer and int(peer["consent"] or 0) != 1:
                continue
        pid = str(payload.get("packet_id") or "")[:120] or ("pigeon_" + uuid.uuid4().hex)
        if len(target_ids) > 1 or one("SELECT packet_id FROM mesh_packets WHERE packet_id=?", (pid,)):
            pid = "pigeon_" + uuid.uuid4().hex
        route = json.dumps([from_id], ensure_ascii=False)
        execute("""
            INSERT OR IGNORE INTO mesh_packets(packet_id,room_id,from_id,to_id,nickname,kind,text,file_meta,created_at,delivered,ttl,hops,route,expires_at,priority,relay_count,ack_count)
            VALUES(?,?,?,?,?,?,?,?,?,0,?,?,?,?,?,0,0)
        """, (pid, room_id, from_id, to_id, nickname, kind, text, json.dumps(file_meta, ensure_ascii=False)[:8000], now, ttl, 0, route, expires_at, priority))
        execute("INSERT OR IGNORE INTO mesh_packet_seen_v249291(room_id,packet_id,device_id,seen_at) VALUES(?,?,?,?)", (room_id, pid, from_id, now))
        created.append(pid)
    return {"ok": True, "pigeon": True, "created": created, "targets": len(created), "ttl": ttl, "expires_at": expires_at}

@app.get("/api/mesh/pigeon/inbox_v249291")
def api_mesh_pigeon_inbox_v249291(room_id: str = Query("ithaki-phone-test"), device_id: str = Query(...), mark_seen: int = Query(1), limit: int = Query(120)):
    _mesh_v249291_init_db()
    room_id = room_id[:120]; device_id = device_id[:160]
    lim = max(10, min(int(limit or 120), 300))
    now=time.time()
    rs = rows("""
        SELECT p.packet_id,p.room_id,p.from_id,p.to_id,p.nickname,p.kind,p.text,p.file_meta,p.created_at,p.delivered,
               COALESCE(p.ttl,5) AS ttl,COALESCE(p.hops,0) AS hops,p.route,p.expires_at,p.priority,COALESCE(p.relay_count,0) AS relay_count,COALESCE(p.ack_count,0) AS ack_count
        FROM mesh_packets p
        LEFT JOIN mesh_packet_seen_v249291 s ON s.room_id=p.room_id AND s.packet_id=p.packet_id AND s.device_id=?
        WHERE p.room_id=? AND (p.to_id=? OR p.to_id='*') AND (p.expires_at IS NULL OR p.expires_at>=?)
        ORDER BY p.created_at DESC LIMIT ?
    """, (device_id, room_id, device_id, now, lim))
    packets=[]
    for r in rs:
        d=dict(r)
        try: d["file_meta"] = json.loads(d.get("file_meta") or "{}")
        except Exception: d["file_meta"] = {}
        try: d["route"] = json.loads(d.get("route") or "[]")
        except Exception: d["route"] = []
        d["remaining_ttl"] = max(0, int(d.get("ttl") or 0) - int(d.get("hops") or 0))
        packets.append(d)
        if mark_seen:
            execute("INSERT OR IGNORE INTO mesh_packet_seen_v249291(room_id,packet_id,device_id,seen_at) VALUES(?,?,?,?)", (room_id, d["packet_id"], device_id, now))
    if mark_seen:
        execute("UPDATE mesh_packets SET delivered=1, ack_count=COALESCE(ack_count,0)+1 WHERE room_id=? AND (to_id=? OR to_id='*')", (room_id, device_id))
    return {"ok": True, "pigeon": True, "packets": packets, "count": len(packets)}

@app.post("/api/mesh/pigeon/relay_v249291")
def api_mesh_pigeon_relay_v249291(payload: dict):
    _mesh_v249291_init_db()
    payload = payload or {}
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    packet_id = str(payload.get("packet_id") or "")[:160]
    relay_from = str(payload.get("relay_from") or "")[:160]
    relay_to = str(payload.get("relay_to") or "")[:160]
    if not packet_id or not relay_from or not relay_to:
        raise HTTPException(400, "packet_id, relay_from and relay_to required")
    p = one("SELECT * FROM mesh_packets WHERE room_id=? AND packet_id=?", (room_id, packet_id))
    if not p:
        raise HTTPException(404, "packet not found")
    ttl = int(p["ttl"] or 5); hops = int(p["hops"] or 0)
    if hops >= ttl:
        raise HTTPException(409, "ttl exhausted")
    try: route = json.loads(p["route"] or "[]")
    except Exception: route=[]
    if relay_to in route:
        return {"ok": True, "duplicate_guard": True, "relayed": False, "reason": "target already in route"}
    peer = one("SELECT consent FROM mesh_peers WHERE room_id=? AND device_id=?", (room_id, relay_to))
    if peer and int(peer["consent"] or 0) != 1:
        raise HTTPException(403, "target has not consented")
    new_id = "pigeon_" + uuid.uuid4().hex
    new_route = route + [relay_from, relay_to]
    execute("""
      INSERT INTO mesh_packets(packet_id,room_id,from_id,to_id,nickname,kind,text,file_meta,created_at,delivered,ttl,hops,route,expires_at,priority,relay_count,ack_count)
      VALUES(?,?,?,?,?,?,?,?,?,0,?,?,?,?,?,0,0)
    """, (new_id, room_id, p["from_id"], relay_to, p["nickname"], p["kind"], p["text"], p["file_meta"], time.time(), ttl, hops+1, json.dumps(new_route, ensure_ascii=False), p["expires_at"], p["priority"]))
    execute("UPDATE mesh_packets SET relay_count=COALESCE(relay_count,0)+1 WHERE room_id=? AND packet_id=?", (room_id, packet_id))
    execute("INSERT OR IGNORE INTO mesh_packet_seen_v249291(room_id,packet_id,device_id,seen_at) VALUES(?,?,?,?)", (room_id, packet_id, relay_from, time.time()))
    return {"ok": True, "pigeon": True, "relayed": True, "packet_id": new_id, "hops": hops+1, "ttl": ttl, "route": new_route}

@app.get("/api/mesh/pigeon/status_v249291")
def api_mesh_pigeon_status_v249291(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    _mesh_v249291_init_db()
    room_id = room_id[:120]
    peers = rows("SELECT device_id,nickname,avatar,mode,base_url,consent,capabilities,last_seen FROM mesh_peers WHERE room_id=? AND last_seen>=? ORDER BY last_seen DESC LIMIT 100", (room_id, time.time()-86400))
    pending = one("SELECT COUNT(*) AS c FROM mesh_packets WHERE room_id=? AND delivered=0 AND (expires_at IS NULL OR expires_at>=?)", (room_id, time.time()))
    seen = one("SELECT COUNT(*) AS c FROM mesh_packet_seen_v249291 WHERE room_id=?", (room_id,))
    return {
        "ok": True,
        "version": "V249291-pigeon-real-mesh",
        "pigeon": True,
        "phone_server_port": 8099,
        "features": ["phone as small server", "hotspot/wifi-direct node discovery", "store and forward", "ttl", "duplicate guard", "text first", "small media metadata"],
        "peers": [dict(x) for x in peers],
        "pending_packets": int((pending or {}).get("c",0)),
        "seen_marks": int((seen or {}).get("c",0)),
        "limits": {"text":"safe", "photo":"small/retry", "file":"small/retry", "video":"small/compressed only"}
    }

@app.get("/api/diagnostics/v249291")
def diagnostics_v249291():
    _mesh_v249291_init_db()
    return {"ok": True, "version": VERSION, "feature": "pigeon real mesh", "endpoints": ["/api/mesh/pigeon/announce_v249291", "/api/mesh/pigeon/send_v249291", "/api/mesh/pigeon/inbox_v249291", "/api/mesh/pigeon/relay_v249291", "/api/mesh/pigeon/status_v249291"]}

@app.get("/health")
def health():
    return {
        "ok": True, "app": APP_NAME, "version": VERSION,
        "ttl_seconds": ttl_seconds(),
        "messages":      one("SELECT COUNT(*) n FROM messages")["n"],
        "flow_posts":    one("SELECT COUNT(*) n FROM flow_posts")["n"],
        "rooms":         one("SELECT COUNT(*) n FROM rooms")["n"],
        "media_metadata": one("SELECT COUNT(*) n FROM media_metadata")["n"] if table_cols("media_metadata") else 0,
        "people_online": one("SELECT COUNT(*) n FROM people WHERE device_id NOT IN ('ithaki_auto_seed','ithaki_auto_host','ithaki_host') AND lower(device_id) NOT LIKE 'demo%' AND lower(nickname) NOT LIKE 'demo%'")["n"],
    }



# ──────────────────────────────────────────────
# v1.9.2 Professional server helpers: limits, users, blocks, spaces, moderation
# ──────────────────────────────────────────────
def init_professional_db():
    init_social_v166_db()
    init_admin_db()
    init_cost_guard_db()
    # Expand rooms into communities / sub-communities / locked spaces without breaking old rows.
    add_col("rooms", "access_type", "TEXT NOT NULL DEFAULT 'public'")
    add_col("rooms", "join_code", "TEXT NOT NULL DEFAULT ''")
    add_col("rooms", "owner_secret", "TEXT NOT NULL DEFAULT ''")
    add_col("rooms", "member_policy", "TEXT NOT NULL DEFAULT 'open'")
    add_col("rooms", "is_private", "INTEGER NOT NULL DEFAULT 0")
    add_col("rooms", "blocked_members", "TEXT NOT NULL DEFAULT '[]'")
    execute("""
    CREATE TABLE IF NOT EXISTS user_blocks(
        room_id TEXT NOT NULL,
        blocker_id TEXT NOT NULL,
        blocked_id TEXT NOT NULL,
        blocked_name TEXT NOT NULL DEFAULT '',
        reason TEXT NOT NULL DEFAULT '',
        created_at REAL NOT NULL,
        PRIMARY KEY(room_id, blocker_id, blocked_id)
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_user_blocks_blocker ON user_blocks(room_id, blocker_id)")
    execute("""
    CREATE TABLE IF NOT EXISTS space_invites(
        invite_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL,
        subroom_id TEXT NOT NULL,
        from_id TEXT NOT NULL,
        from_name TEXT NOT NULL DEFAULT '',
        to_id TEXT NOT NULL,
        to_name TEXT NOT NULL DEFAULT '',
        status TEXT NOT NULL DEFAULT 'pending',
        created_at REAL NOT NULL,
        updated_at REAL NOT NULL
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_space_invites_to ON space_invites(room_id, to_id, status)")


def _is_user_blocked_between(room_id: str, a: str, b: str) -> bool:
    init_professional_db()
    if not a or not b:
        return False
    return bool(one("SELECT 1 FROM user_blocks WHERE room_id=? AND ((blocker_id=? AND blocked_id=?) OR (blocker_id=? AND blocked_id=?)) LIMIT 1", (room_id, a, b, b, a)))


DEFAULT_SOFT_WORDS = [
    "μαλακ", "ηλιθ", "βλακ", "χαζ", "σκασ", "σκατ", "γαμ", "πουστ", "καριολ", "μουν", "παπαρ", "κοπαν",
    "idiot", "stupid", "moron", "shut up", "fuck", "shit", "asshole", "bitch", "bastard"
]

def init_moderation_words_db():
    init_admin_db()
    execute("""
    CREATE TABLE IF NOT EXISTS moderation_words(
        room_id TEXT NOT NULL,
        word TEXT NOT NULL,
        active INTEGER NOT NULL DEFAULT 1,
        created_at REAL NOT NULL,
        updated_at REAL NOT NULL,
        PRIMARY KEY(room_id, word)
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_moderation_words_room ON moderation_words(room_id, active)")

def seed_default_moderation_words(room_id: str):
    init_moderation_words_db()
    t = now()
    for w in DEFAULT_SOFT_WORDS:
        execute("""
        INSERT OR IGNORE INTO moderation_words(room_id,word,active,created_at,updated_at) VALUES(?,?,?,?,?)
        """, (room_id, w, 1, t, t))

def _soft_words(room_id: str = "ithaki-phone-test") -> list[str]:
    # Owner editable moderation list. It masks insults/profanity but does not block opinions.
    try:
        init_moderation_words_db()
        seed_default_moderation_words(room_id)
        out = []
        for r in rows("SELECT word FROM moderation_words WHERE room_id=? AND active=1 ORDER BY length(word) DESC, word ASC", (room_id,)):
            w = str(r["word"] or "").strip()
            if w and w.lower() not in [x.lower() for x in out]:
                out.append(w)
        return out or list(DEFAULT_SOFT_WORDS)
    except Exception:
        return list(DEFAULT_SOFT_WORDS)

def _mask_bad_token(token: str) -> str:
    token = token or ""
    if len(token) <= 2:
        return "@" * max(1, len(token))
    return token[0] + ("@" * (len(token)-2)) + token[-1]  # V246.1.9 first/last visible, middle @

def _mask_word_in_text(text: str, word: str) -> tuple[str, bool]:
    if not word:
        return text, False
    # For normal words/stems, mask the full visible token that contains the blocked stem.
    # For phrases, mask only the phrase as a phrase.
    changed = False
    try:
        if " " in word.strip():
            pattern = re.compile(re.escape(word.strip()), flags=re.IGNORECASE)
        else:
            # Greek + latin token chars. Keeps punctuation outside the mask.
            pattern = re.compile(r"(?iu)[\wΆ-ώ]*" + re.escape(word.strip()) + r"[\wΆ-ώ]*")
        def repl(m):
            nonlocal changed
            val = m.group(0)
            if not val:
                return val
            changed = True
            return _mask_bad_token(val)
        out = pattern.sub(repl, text)
        return out, changed
    except Exception:
        return text, False

def _ith735_fold(value: str) -> str:
    value = unicodedata.normalize("NFD", str(value or ""))
    value = "".join(ch for ch in value if unicodedata.category(ch) != "Mn").lower().replace("ς", "σ")
    value = value.replace("ει", "ι").replace("οι", "ι").replace("υι", "ι")
    value = value.replace("αι", "ε").replace("η", "ι").replace("υ", "ι").replace("ω", "ο")
    return re.sub(r"[^0-9a-zα-ω]+", "", value)


def _ith735_similar(a: str, b: str) -> float:
    a, b = _ith735_fold(a), _ith735_fold(b)
    if not a or not b:
        return 0.0
    if a == b or a in b or b in a:
        return 1.0
    return difflib.SequenceMatcher(None, a, b).ratio()


def _ith735_mask_public_text(raw: str, words: list[str]) -> tuple[str, list[str]]:
    hits: list[str] = []
    folded_words = [(w, _ith735_fold(w)) for w in words if _ith735_fold(w)]
    token_re = re.compile(r"[A-Za-zΑ-Ωα-ωΆ-ώΪΫϊϋΐΰ](?:[A-Za-zΑ-Ωα-ωΆ-ώΪΫϊϋΐΰ0-9@*!.#$_-]*[A-Za-zΑ-Ωα-ωΆ-ώΪΫϊϋΐΰ0-9])?", re.UNICODE)
    def repl(m):
        token = m.group(0)
        raw_token = unicodedata.normalize("NFD", token)
        raw_token = "".join(ch for ch in raw_token if unicodedata.category(ch) != "Mn").lower()
        variants = [_ith735_fold(token)]
        if re.search(r"[^0-9a-zα-ω]", raw_token, flags=re.IGNORECASE):
            for replacement in ("", "α", "ε", "ι", "ο", "ου", "a", "e", "i", "o", "u"):
                candidate = _ith735_fold(re.sub(r"[^0-9a-zα-ω]+", replacement, raw_token, flags=re.IGNORECASE))
                if candidate and candidate not in variants:
                    variants.append(candidate)
        for ft in variants:
            if len(ft) < 3:
                continue
            for original, fw in folded_words:
                near = (ft.startswith(fw[:1]) and abs(len(ft)-len(fw)) <= max(2, int(len(fw)*0.35)) and _ith735_similar(ft, fw) >= 0.70)
                if fw in ft or near:
                    if original not in hits:
                        hits.append(original)
                    return _mask_bad_token(token)
        return token
    return token_re.sub(repl, raw or ""), hits


def _moderate_text(room_id: str, sender_id: str, nickname: str, text: str, target_type: str, target_id: str) -> dict:
    # Personal/private messages remain completely unmasked.
    if str(target_id or "").startswith("dm:") or str(target_type or "").lower() in {"dm", "private", "personal"}:
        return {"text": text or "", "changed": False, "hits": [], "warning": ""}
    raw = text or ""
    masked, hits = _ith735_mask_public_text(raw, _soft_words(room_id))
    if not hits:
        return {"text": raw, "changed": False, "hits": [], "warning": ""}
    warning = ("⚠️ Μήνυμα συμμόρφωσης: χρησιμοποίησε λέξεις με σεβασμό. "
               "Ο μη αποδεκτός όρος εμφανίζεται με πρώτο και τελευταίο γράμμα και @@@ ενδιάμεσα. "
               "/ Compliance notice: please use respectful language.")
    try:
        init_admin_db()
        execute("""
        INSERT INTO admin_moderation_flags(flag_id,room_id,target_type,target_id,sender_id,nickname,reason,severity,created_at,resolved)
        VALUES(?,?,?,?,?,?,?,?,?,0)
        """, (nid("mod"), room_id, target_type, target_id, sender_id, nickname, "auto-soft-moderation:" + ",".join(hits)[:200], 2, now()))
        execute("""
        INSERT INTO reports(report_id,room_id,reporter_id,target_type,target_id,reason,created_at)
        VALUES(?,?,?,?,?,?,?)
        """, (nid("rep"), room_id, "ai_moderator", target_type, target_id, f"AI moderation notice for {nickname or sender_id}: " + ",".join(hits)[:200], now()))
    except Exception:
        LOG.exception("failed writing moderation flag")
    return {"text": masked, "changed": True, "hits": hits, "warning": warning}


# ──────────────────────────────────────────────
# Conversation presence: typing indicators + read receipts (v1.9.3)
# ──────────────────────────────────────────────
def init_conversation_state_db():
    execute("""
    CREATE TABLE IF NOT EXISTS typing_state(
        room_id TEXT NOT NULL,
        channel TEXT NOT NULL,
        device_id TEXT NOT NULL,
        nickname TEXT NOT NULL DEFAULT '',
        typing INTEGER NOT NULL DEFAULT 0,
        updated_at REAL NOT NULL,
        PRIMARY KEY(room_id, channel, device_id)
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS read_receipts(
        room_id TEXT NOT NULL,
        channel TEXT NOT NULL,
        device_id TEXT NOT NULL,
        nickname TEXT NOT NULL DEFAULT '',
        last_message_id TEXT NOT NULL DEFAULT '',
        last_created_at REAL NOT NULL DEFAULT 0,
        updated_at REAL NOT NULL,
        PRIMARY KEY(room_id, channel, device_id)
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_typing_ch ON typing_state(room_id, channel, updated_at)")
    execute("CREATE INDEX IF NOT EXISTS idx_read_ch ON read_receipts(room_id, channel, updated_at)")

@app.post("/api/conversation/typing")
def api_conversation_typing(payload: dict):
    init_conversation_state_db()
    room_id=str(payload.get("room_id") or "ithaki-phone-test")[:120]
    channel=str(payload.get("channel") or "main")[:180]
    device_id=str(payload.get("device_id") or "")[:120]
    nickname=str(payload.get("nickname") or "")[:80]
    typing=1 if payload.get("typing", True) else 0
    if not device_id:
        raise HTTPException(400,"device_id required")
    execute("""
    INSERT INTO typing_state(room_id,channel,device_id,nickname,typing,updated_at) VALUES(?,?,?,?,?,?)
    ON CONFLICT(room_id,channel,device_id) DO UPDATE SET nickname=excluded.nickname,typing=excluded.typing,updated_at=excluded.updated_at
    """, (room_id, channel, device_id, nickname, typing, now()))
    return {"ok": True}

@app.get("/api/conversation/typing")
def api_conversation_typing_get(room_id: str = Query(...), channel: str = Query("main"), exclude_id: str = Query("")):
    init_conversation_state_db()
    cutoff=now()-8
    items=[dict(r) for r in rows("""
        SELECT device_id,nickname,updated_at FROM typing_state
        WHERE room_id=? AND channel=? AND typing=1 AND updated_at>=? AND device_id != ?
        ORDER BY updated_at DESC LIMIT 8
    """, (room_id, channel, cutoff, exclude_id))]
    return {"ok": True, "typing": items}

@app.post("/api/conversation/read")
def api_conversation_read(payload: dict):
    init_conversation_state_db()
    room_id=str(payload.get("room_id") or "ithaki-phone-test")[:120]
    channel=str(payload.get("channel") or "main")[:180]
    device_id=str(payload.get("device_id") or "")[:120]
    nickname=str(payload.get("nickname") or "")[:80]
    if not device_id:
        raise HTTPException(400,"device_id required")
    execute("""
    INSERT INTO read_receipts(room_id,channel,device_id,nickname,last_message_id,last_created_at,updated_at) VALUES(?,?,?,?,?,?,?)
    ON CONFLICT(room_id,channel,device_id) DO UPDATE SET nickname=excluded.nickname,last_message_id=excluded.last_message_id,last_created_at=MAX(read_receipts.last_created_at,excluded.last_created_at),updated_at=excluded.updated_at
    """, (room_id, channel, device_id, nickname, str(payload.get("last_message_id") or "")[:160], float(payload.get("last_created_at") or 0), now()))
    return {"ok": True}

@app.get("/api/conversation/receipts")
def api_conversation_receipts(room_id: str = Query(...), channel: str = Query("main"), exclude_id: str = Query("")):
    init_conversation_state_db()
    items=[dict(r) for r in rows("""
        SELECT device_id,nickname,last_message_id,last_created_at,updated_at FROM read_receipts
        WHERE room_id=? AND channel=? AND device_id != ? ORDER BY updated_at DESC LIMIT 20
    """, (room_id, channel, exclude_id))]
    return {"ok": True, "receipts": items}

def _ith735_search_fold(value: str) -> str:
    value = unicodedata.normalize("NFD", str(value or ""))
    value = "".join(ch for ch in value if unicodedata.category(ch) != "Mn").lower().replace("ς", "σ")
    value = value.replace("ει", "ι").replace("οι", "ι").replace("υι", "ι")
    value = value.replace("αι", "ε").replace("η", "ι").replace("υ", "ι").replace("ω", "ο")
    return " ".join(re.findall(r"[0-9a-zα-ω]+", value))


def _ith735_search_score(query: str, *values) -> float:
    q = _ith735_search_fold(query)
    text = _ith735_search_fold(" ".join(str(v or "") for v in values))
    if not q or not text:
        return 0.0
    if q in text:
        return 1.0
    qwords, words = q.split(), text.split()
    if not qwords or not words:
        return 0.0
    scores=[]
    for qw in qwords:
        best=max((difflib.SequenceMatcher(None,qw,w).ratio() for w in words if w), default=0.0)
        scores.append(best)
    covered=sum(1 for x in scores if x>=0.70)/len(scores)
    avg=sum(scores)/len(scores)
    whole=difflib.SequenceMatcher(None,q,text).ratio()
    return max(whole, avg if covered>=0.70 else 0.0)


def _ith735_rank(items: list[dict], query: str, fields: tuple[str, ...], limit: int) -> list[dict]:
    ranked=[]
    for item in items:
        score=_ith735_search_score(query, *(item.get(f, "") for f in fields))
        if score>=0.70:
            ranked.append((score, float(item.get("last_seen") or item.get("created_at") or 0), item))
    ranked.sort(key=lambda x:(x[0],x[1]), reverse=True)
    return [x[2] for x in ranked[:limit]]


@app.get("/api/search")
def api_search(room_id: str = Query(...), q: str = Query(""), kind: str = Query("all"), limit: int = Query(30, ge=1, le=100)):
    init_professional_db(); seed_default_rooms(room_id)
    term=(q or "").strip()
    out={"ok": True, "query": q, "match_threshold": 0.70, "people": [], "rooms": [], "communities": [], "posts": []}
    if not term:
        return out
    if kind in ("all","people"):
        cand=[dict(r) for r in rows("SELECT room_id,device_id,nickname,language,last_seen FROM people WHERE room_id=? ORDER BY last_seen DESC LIMIT 800", (room_id,))]
        out["people"]=_ith735_rank(cand,term,("nickname","language"),limit)
    if kind in ("all","rooms","communities"):
        cand=[dict(r) for r in rows("SELECT subroom_id,title,description,category,parent_id,access_type,is_private,created_at FROM rooms WHERE room_id=? ORDER BY created_at DESC LIMIT 600", (room_id,))]
        rs=_ith735_rank(cand,term,("title","description","category"),limit)
        out["rooms"]=rs; out["communities"]=rs
    if kind in ("all","posts","flow"):
        cand=[dict(r) for r in rows("""
            SELECT post_id,room_id,sender_id,nickname,language,text,post_kind,visibility,created_at
            FROM flow_posts WHERE room_id=? AND visibility='public'
            ORDER BY created_at DESC LIMIT 1000
        """, (room_id,))]
        out["posts"]=_ith735_rank(cand,term,("nickname","text","post_kind"),limit)
    return out

@app.get("/api/pair/qr")
def api_pair_qr(text: str = Query(..., max_length=800)):
    try:
        import qrcode
        import io
        img=qrcode.make(text)
        bio=io.BytesIO(); img.save(bio, format="PNG"); bio.seek(0)
        return Response(content=bio.getvalue(), media_type="image/png")
    except Exception:
        # Fallback non-scannable SVG with visible text if qrcode package is missing.
        safe=(text or "").replace("&","&amp;").replace("<","&lt;").replace(">","&gt;")[:220]
        svg=f'<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512"><rect width="512" height="512" fill="white"/><text x="24" y="70" font-size="24" fill="black">QR package missing</text><text x="24" y="120" font-size="16" fill="black">{safe}</text></svg>'
        return Response(content=svg.encode("utf-8"), media_type="image/svg+xml")

# ──────────────────────────────────────────────
# People / heartbeat
# ──────────────────────────────────────────────
@app.post("/api/people/heartbeat")
def heartbeat(p: HeartbeatIn):
    ensure_people_avatar_column_v24614()
    security_member_guard(p.room_id, p.device_id, "heartbeat_member", max_per_minute=120, max_per_hour=1800)
    if is_banned(p.room_id, p.device_id):
        raise HTTPException(403, "member blocked")
    av = str(getattr(p, "avatar", "") or "")[:6_000_000]
    execute("""
    INSERT INTO people(room_id,device_id,nickname,language,mood,avatar,last_seen) VALUES(?,?,?,?,?,?,?)
    ON CONFLICT(room_id,device_id) DO UPDATE SET
      nickname=excluded.nickname, language=excluded.language,
      mood=excluded.mood, avatar=CASE WHEN excluded.avatar<>'' THEN excluded.avatar ELSE people.avatar END,
      last_seen=excluded.last_seen
    """, (p.room_id, p.device_id, p.nickname, p.language, p.mood, av, now()))
    return {"ok": True, "avatar_saved": bool(av)}


@app.get("/api/people/online")
def people_online(room_id: str = Query(...)):
    ensure_people_avatar_column_v24614()
    people = _people_visible_online(room_id, max_age_seconds=180)
    return {"ok": True, "room_id": room_id, "ttl_seconds": 180, "updated_at": now(), "people": people}


# ──────────────────────────────────────────────
# Messages
# ──────────────────────────────────────────────



# ──────────────────────────────────────────────
# V249274 — Message in a Bottle (anonymous random sea connection)
# ──────────────────────────────────────────────
class BottleDropIn(BaseModel):
    room_id: str = Field(default="ithaki-phone-test")
    owner_token: str = Field(default="")
    text: str = Field(default="")
    mode: str = Field(default="random")
    mesh: bool = Field(default=False)
    vanish: bool = Field(default=True)

class BottleReplyIn(BaseModel):
    room_id: str = Field(default="ithaki-phone-test")
    bottle_id: str = Field(default="")
    replier_token: str = Field(default="")
    text: str = Field(default="")
    continue_ok: bool = Field(default=False)

class BottleThreadIn(BaseModel):
    room_id: str = Field(default="ithaki-phone-test")
    bottle_id: str = Field(default="")
    token: str = Field(default="")
    role: str = Field(default="reply")
    text: str = Field(default="")

def _bottle_hash(token: str) -> str:
    token = str(token or "").strip()
    if not token:
        token = secrets.token_hex(16)
    return hashlib.sha256(("ok-ithaki-bottle-v249274:" + token).encode("utf-8")).hexdigest()

def ensure_bottle_tables_v249274():
    execute("""
    CREATE TABLE IF NOT EXISTS bottle_messages(
        bottle_id     TEXT PRIMARY KEY,
        room_id       TEXT NOT NULL,
        owner_hash    TEXT NOT NULL,
        mode          TEXT NOT NULL DEFAULT 'random',
        mesh          INTEGER NOT NULL DEFAULT 0,
        text          TEXT NOT NULL,
        status        TEXT NOT NULL DEFAULT 'floating',
        reply_text    TEXT NOT NULL DEFAULT '',
        reply_hash    TEXT NOT NULL DEFAULT '',
        continue_ok   INTEGER NOT NULL DEFAULT 0,
        vanish        INTEGER NOT NULL DEFAULT 1,
        created_at    REAL NOT NULL,
        found_at      REAL NOT NULL DEFAULT 0,
        replied_at    REAL NOT NULL DEFAULT 0
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS bottle_threads(
        thread_id     TEXT PRIMARY KEY,
        bottle_id     TEXT NOT NULL,
        room_id       TEXT NOT NULL,
        author_role   TEXT NOT NULL DEFAULT '',
        author_hash   TEXT NOT NULL DEFAULT '',
        text          TEXT NOT NULL,
        created_at    REAL NOT NULL
    )""")
    try:
        execute("CREATE INDEX IF NOT EXISTS idx_bottle_room_status ON bottle_messages(room_id,status,created_at)")
        execute("CREATE INDEX IF NOT EXISTS idx_bottle_owner ON bottle_messages(room_id,owner_hash,created_at)")
        execute("CREATE INDEX IF NOT EXISTS idx_bottle_thread ON bottle_threads(bottle_id,created_at)")
    except Exception:
        pass


# ──────────────────────────────────────────────
# V249277 — Pelagos of Whispers weather helper
# ──────────────────────────────────────────────
def pelagos_weather_v249277(day: str = ""):
    day = (day or time.strftime("%Y-%m-%d"))[:10]
    h = sum(ord(c) for c in day) % 3
    if h == 0:
        return {"id": "calm", "icon": "🌊", "title": "Η θάλασσα σήμερα είναι ήρεμη", "limit": 5, "lag": "γρήγορες επιστροφές"}
    if h == 1:
        return {"id": "storm", "icon": "🌪️", "title": "Η θάλασσα σήμερα είναι ταραγμένη", "limit": 2, "lag": "αργές επιστροφές"}
    return {"id": "currents", "icon": "🌀", "title": "Η θάλασσα σήμερα είναι γεμάτη ρεύματα", "limit": 3, "lag": "απρόβλεπτες επιστροφές"}

@app.get("/api/bottle/weather")
def api_bottle_weather(day: str = Query(default="")):
    return {"ok": True, "weather": pelagos_weather_v249277(day)}


# ──────────────────────────────────────────────
# V249278 — Lighthouse silent sky
# ──────────────────────────────────────────────
class LighthouseLightIn(BaseModel):
    room_id: str = Field(default="ithaki-phone-test")
    owner_token: str = Field(default="")

class LighthouseTouchIn(BaseModel):
    room_id: str = Field(default="ithaki-phone-test")
    owner_token: str = Field(default="")
    light_id: str = Field(default="")

def _lighthouse_hash_v249278(token: str) -> str:
    token = str(token or '').strip()
    if not token:
        token = secrets.token_hex(16)
    return hashlib.sha256(("ok-ithaki-lighthouse-v249278:" + token).encode("utf-8")).hexdigest()

def _lighthouse_night_v249278() -> str:
    return time.strftime("%Y-%m-%d")

def ensure_lighthouse_tables_v249278():
    execute("""
    CREATE TABLE IF NOT EXISTS lighthouse_lights(
        light_id      TEXT PRIMARY KEY,
        room_id       TEXT NOT NULL,
        night_id      TEXT NOT NULL,
        owner_hash    TEXT NOT NULL,
        x             REAL NOT NULL,
        y             REAL NOT NULL,
        touched_count INTEGER NOT NULL DEFAULT 0,
        created_at    REAL NOT NULL
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS lighthouse_touches(
        touch_id      TEXT PRIMARY KEY,
        light_id      TEXT NOT NULL,
        room_id       TEXT NOT NULL,
        night_id      TEXT NOT NULL,
        toucher_hash  TEXT NOT NULL,
        created_at    REAL NOT NULL
    )""")
    try:
        execute("CREATE INDEX IF NOT EXISTS idx_lighthouse_night ON lighthouse_lights(room_id,night_id,created_at)")
        execute("CREATE INDEX IF NOT EXISTS idx_lighthouse_owner ON lighthouse_lights(room_id,night_id,owner_hash)")
        execute("CREATE INDEX IF NOT EXISTS idx_lighthouse_touch ON lighthouse_touches(room_id,night_id,light_id,toucher_hash)")
    except Exception:
        pass

def _light_pos_v249278(owner_hash: str):
    h = int(hashlib.sha256((owner_hash + _lighthouse_night_v249278()).encode("utf-8")).hexdigest()[:12], 16)
    x = 10 + (h % 80)
    y = 24 + ((h // 101) % 34)
    return float(x), float(y)


# ──────────────────────────────────────────────
# V249280 — Group chats, broadcast lists, public profile, forward, webhooks/API
# ──────────────────────────────────────────────
def ensure_comm_tables_v249280():
    execute("""
    CREATE TABLE IF NOT EXISTS group_chats(
        group_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL,
        owner_id TEXT NOT NULL,
        title TEXT NOT NULL,
        description TEXT NOT NULL DEFAULT '',
        created_at REAL NOT NULL,
        updated_at REAL NOT NULL
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS group_members(
        room_id TEXT NOT NULL,
        group_id TEXT NOT NULL,
        device_id TEXT NOT NULL,
        nickname TEXT NOT NULL DEFAULT '',
        role TEXT NOT NULL DEFAULT 'member',
        joined_at REAL NOT NULL,
        PRIMARY KEY(room_id,group_id,device_id)
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS broadcast_lists(
        list_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL,
        owner_id TEXT NOT NULL,
        title TEXT NOT NULL,
        member_ids TEXT NOT NULL DEFAULT '[]',
        created_at REAL NOT NULL,
        updated_at REAL NOT NULL
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS public_profiles(
        room_id TEXT NOT NULL,
        device_id TEXT NOT NULL,
        display_name TEXT NOT NULL DEFAULT '',
        bio TEXT NOT NULL DEFAULT '',
        link TEXT NOT NULL DEFAULT '',
        cover_photo TEXT NOT NULL DEFAULT '',
        updated_at REAL NOT NULL,
        PRIMARY KEY(room_id,device_id)
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS api_webhooks(
        hook_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL,
        owner_id TEXT NOT NULL,
        title TEXT NOT NULL,
        url TEXT NOT NULL DEFAULT '',
        secret TEXT NOT NULL DEFAULT '',
        events TEXT NOT NULL DEFAULT '[]',
        enabled INTEGER NOT NULL DEFAULT 1,
        created_at REAL NOT NULL,
        updated_at REAL NOT NULL
    )""")
    try:
        execute("CREATE INDEX IF NOT EXISTS idx_group_chats_owner ON group_chats(room_id,owner_id,updated_at)")
        execute("CREATE INDEX IF NOT EXISTS idx_group_members_device ON group_members(room_id,device_id,joined_at)")
        execute("CREATE INDEX IF NOT EXISTS idx_broadcast_owner ON broadcast_lists(room_id,owner_id,updated_at)")
        execute("CREATE INDEX IF NOT EXISTS idx_public_profile_device ON public_profiles(room_id,device_id)")
        execute("CREATE INDEX IF NOT EXISTS idx_api_webhooks_owner ON api_webhooks(room_id,owner_id,updated_at)")
    except Exception:
        pass

def _json_list_v249280(v):
    if isinstance(v, list):
        return [str(x)[:120] for x in v if str(x or '').strip()]
    try:
        import json as _json
        return [str(x)[:120] for x in _json.loads(str(v or '[]')) if str(x or '').strip()]
    except Exception:
        return []

def _channel_group_v249280(gid: str) -> str:
    return 'group:' + str(gid or '')[:120]

def _dm_channel_v249280(a: str, b: str) -> str:
    ids = sorted([str(a or ''), str(b or '')])
    return 'dm:' + ':'.join(ids)

def _insert_plain_message_v249280(room_id, channel, sender_id, nickname, text):
    mid = nid('msg')
    execute("""
    INSERT INTO messages(message_id,room_id,channel,sender_id,nickname,language,text,is_encrypted,iv,cipher_text,created_at)
    VALUES(?,?,?,?,?,?,?,?,?,?,?)
    """, (mid, room_id, channel, sender_id, nickname, 'el', str(text or '').strip(), 0, '', '', now()))
    try:
        push_notify(room_id, channel, sender_id, nickname, str(text or '')[:160], mid)
    except Exception:
        pass
    return mid

@app.post('/api/groups/create')
def api_groups_create(payload: dict):
    ensure_comm_tables_v249280()
    room_id = str(payload.get('room_id') or 'ithaki-phone-test')[:120]
    owner_id = str(payload.get('owner_id') or '')[:120]
    title = str(payload.get('title') or 'Ομάδα')[:120].strip() or 'Ομάδα'
    description = str(payload.get('description') or '')[:1000]
    members = _json_list_v249280(payload.get('member_ids') or [])
    if owner_id and owner_id not in members:
        members.insert(0, owner_id)
    gid = nid('grp')
    execute("INSERT INTO group_chats(group_id,room_id,owner_id,title,description,created_at,updated_at) VALUES(?,?,?,?,?,?,?)", (gid, room_id, owner_id, title, description, now(), now()))
    for mid in members[:300]:
        execute("INSERT OR REPLACE INTO group_members(room_id,group_id,device_id,nickname,role,joined_at) VALUES(?,?,?,?,?,?)", (room_id, gid, mid, '', 'admin' if mid==owner_id else 'member', now()))
    try: ws_notify(room_id)
    except Exception: pass
    return {'ok': True, 'group_id': gid, 'channel': _channel_group_v249280(gid), 'members': len(members)}

@app.get('/api/groups/list')
def api_groups_list(room_id: str = Query(...), device_id: str = Query(default='')):
    ensure_comm_tables_v249280()
    if device_id:
        rs = rows("""
            SELECT g.*, COUNT(m.device_id) AS member_count
            FROM group_chats g JOIN group_members gm ON gm.room_id=g.room_id AND gm.group_id=g.group_id AND gm.device_id=?
            LEFT JOIN group_members m ON m.room_id=g.room_id AND m.group_id=g.group_id
            WHERE g.room_id=? GROUP BY g.group_id ORDER BY g.updated_at DESC
        """, (device_id, room_id))
    else:
        rs = rows("""
            SELECT g.*, COUNT(m.device_id) AS member_count FROM group_chats g
            LEFT JOIN group_members m ON m.room_id=g.room_id AND m.group_id=g.group_id
            WHERE g.room_id=? GROUP BY g.group_id ORDER BY g.updated_at DESC
        """, (room_id,))
    return {'ok': True, 'groups': [dict(r) for r in rs]}

@app.get('/api/groups/members')
def api_groups_members(room_id: str = Query(...), group_id: str = Query(...)):
    ensure_comm_tables_v249280()
    rs = rows("""
        SELECT gm.*, COALESCE(p.nickname, gm.nickname, gm.device_id) AS display_name, COALESCE(p.avatar,'') AS avatar
        FROM group_members gm LEFT JOIN people p ON p.room_id=gm.room_id AND p.device_id=gm.device_id
        WHERE gm.room_id=? AND gm.group_id=? ORDER BY gm.role='admin' DESC, gm.joined_at ASC
    """, (room_id, group_id))
    return {'ok': True, 'members': [dict(r) for r in rs]}

@app.post('/api/groups/add_members')
def api_groups_add_members(payload: dict):
    ensure_comm_tables_v249280()
    room_id=str(payload.get('room_id') or '')[:120]; group_id=str(payload.get('group_id') or '')[:120]
    member_ids=_json_list_v249280(payload.get('member_ids') or [])
    if not room_id or not group_id: raise HTTPException(400, 'room_id and group_id required')
    for mid in member_ids[:300]:
        execute("INSERT OR IGNORE INTO group_members(room_id,group_id,device_id,nickname,role,joined_at) VALUES(?,?,?,?,?,?)", (room_id, group_id, mid, '', 'member', now()))
    execute("UPDATE group_chats SET updated_at=? WHERE room_id=? AND group_id=?", (now(), room_id, group_id))
    try: ws_notify(room_id)
    except Exception: pass
    return {'ok': True, 'added': len(member_ids)}



# V249282 helper endpoint for message-screen group summary
@app.get('/api/groups/summary')
def api_groups_summary_v249282(room_id: str = Query(...), group_id: str = Query(...)):
    ensure_comm_tables_v249280()
    g = one("SELECT * FROM group_chats WHERE room_id=? AND group_id=?", (room_id, group_id))
    if not g:
        raise HTTPException(404, 'Group not found')
    members = rows("""
        SELECT gm.device_id, gm.role, COALESCE(p.nickname, gm.nickname, gm.device_id) AS display_name, COALESCE(p.avatar,'') AS avatar
        FROM group_members gm LEFT JOIN people p ON p.room_id=gm.room_id AND p.device_id=gm.device_id
        WHERE gm.room_id=? AND gm.group_id=? ORDER BY gm.role='admin' DESC, gm.joined_at ASC
    """, (room_id, group_id))
    msg_count = one("SELECT COUNT(*) AS c, MAX(created_at) AS last_at FROM messages WHERE room_id=? AND channel=?", (room_id, 'group:'+group_id))
    out = dict(g)
    out['members'] = [dict(m) for m in members]
    out['member_count'] = len(out['members'])
    out['message_count'] = int(msg_count['c'] or 0) if msg_count else 0
    out['last_message_at'] = float(msg_count['last_at'] or 0) if msg_count else 0
    return {'ok': True, 'group': out}



# ──────────────────────────────────────────────
# V249284 — Classic message group management
# ──────────────────────────────────────────────
def _group_role_v249284(room_id: str, group_id: str, device_id: str):
    r = one("SELECT role FROM group_members WHERE room_id=? AND group_id=? AND device_id=? LIMIT 1", (room_id, group_id, device_id))
    if r:
        return str(r["role"] or "member")
    g = one("SELECT owner_id FROM group_chats WHERE room_id=? AND group_id=? LIMIT 1", (room_id, group_id))
    if g and str(g["owner_id"] or "") == str(device_id or ""):
        return "admin"
    return ""

def _group_is_admin_v249284(room_id: str, group_id: str, device_id: str) -> bool:
    return _group_role_v249284(room_id, group_id, device_id).lower() == "admin"

def _group_promote_if_needed_v249284(room_id: str, group_id: str):
    admins = one("SELECT COUNT(*) AS c FROM group_members WHERE room_id=? AND group_id=? AND role='admin'", (room_id, group_id))
    if admins and int(admins["c"] or 0) > 0:
        return
    first = one("SELECT device_id FROM group_members WHERE room_id=? AND group_id=? ORDER BY joined_at ASC LIMIT 1", (room_id, group_id))
    if first:
        execute("UPDATE group_members SET role='admin' WHERE room_id=? AND group_id=? AND device_id=?", (room_id, group_id, first["device_id"]))
        execute("UPDATE group_chats SET owner_id=?, updated_at=? WHERE room_id=? AND group_id=?", (first["device_id"], now(), room_id, group_id))

@app.post('/api/groups/rename')
def api_groups_rename_v249284(payload: dict):
    ensure_comm_tables_v249280()
    room_id=str(payload.get('room_id') or '')[:120]; group_id=str(payload.get('group_id') or '')[:120]
    actor_id=str(payload.get('actor_id') or '')[:120]; title=str(payload.get('title') or '')[:120].strip()
    if not room_id or not group_id or not actor_id or not title:
        raise HTTPException(400, 'room_id, group_id, actor_id and title required')
    if not _group_is_admin_v249284(room_id, group_id, actor_id):
        raise HTTPException(403, 'Only group admin can rename')
    execute("UPDATE group_chats SET title=?, updated_at=? WHERE room_id=? AND group_id=?", (title, now(), room_id, group_id))
    try: ws_notify(room_id)
    except Exception: pass
    return {'ok': True, 'group_id': group_id, 'title': title}

@app.post('/api/groups/leave')
def api_groups_leave_v249284(payload: dict):
    ensure_comm_tables_v249280()
    room_id=str(payload.get('room_id') or '')[:120]; group_id=str(payload.get('group_id') or '')[:120]
    device_id=str(payload.get('device_id') or '')[:120]
    if not room_id or not group_id or not device_id:
        raise HTTPException(400, 'room_id, group_id and device_id required')
    execute("DELETE FROM group_members WHERE room_id=? AND group_id=? AND device_id=?", (room_id, group_id, device_id))
    left = one("SELECT COUNT(*) AS c FROM group_members WHERE room_id=? AND group_id=?", (room_id, group_id))
    if left and int(left['c'] or 0) <= 0:
        execute("DELETE FROM group_chats WHERE room_id=? AND group_id=?", (room_id, group_id))
    else:
        _group_promote_if_needed_v249284(room_id, group_id)
        execute("UPDATE group_chats SET updated_at=? WHERE room_id=? AND group_id=?", (now(), room_id, group_id))
    try: ws_notify(room_id)
    except Exception: pass
    return {'ok': True, 'left': True, 'remaining': int(left['c'] or 0) if left else 0}

@app.post('/api/groups/remove_member')
def api_groups_remove_member_v249284(payload: dict):
    ensure_comm_tables_v249280()
    room_id=str(payload.get('room_id') or '')[:120]; group_id=str(payload.get('group_id') or '')[:120]
    actor_id=str(payload.get('actor_id') or '')[:120]; member_id=str(payload.get('member_id') or '')[:120]
    if not room_id or not group_id or not actor_id or not member_id:
        raise HTTPException(400, 'room_id, group_id, actor_id and member_id required')
    if actor_id == member_id:
        raise HTTPException(400, 'Use leave group for yourself')
    if not _group_is_admin_v249284(room_id, group_id, actor_id):
        raise HTTPException(403, 'Only group admin can remove members')
    target_role = _group_role_v249284(room_id, group_id, member_id).lower()
    if target_role == 'admin':
        raise HTTPException(403, 'Cannot remove another admin')
    execute("DELETE FROM group_members WHERE room_id=? AND group_id=? AND device_id=?", (room_id, group_id, member_id))
    execute("UPDATE group_chats SET updated_at=? WHERE room_id=? AND group_id=?", (now(), room_id, group_id))
    try: ws_notify(room_id)
    except Exception: pass
    return {'ok': True, 'removed': member_id}

@app.post('/api/broadcast/create')
def api_broadcast_create(payload: dict):
    ensure_comm_tables_v249280()
    room_id=str(payload.get('room_id') or 'ithaki-phone-test')[:120]; owner_id=str(payload.get('owner_id') or '')[:120]
    title=str(payload.get('title') or 'Broadcast')[:120].strip() or 'Broadcast'
    member_ids=_json_list_v249280(payload.get('member_ids') or [])[:500]
    lid=nid('bcast')
    import json as _json
    execute("INSERT INTO broadcast_lists(list_id,room_id,owner_id,title,member_ids,created_at,updated_at) VALUES(?,?,?,?,?,?,?)", (lid,room_id,owner_id,title,_json.dumps(member_ids,ensure_ascii=False),now(),now()))
    return {'ok': True, 'list_id': lid, 'count': len(member_ids)}

@app.get('/api/broadcast/list')
def api_broadcast_list(room_id: str = Query(...), owner_id: str = Query(default='')):
    ensure_comm_tables_v249280()
    rs=rows("SELECT * FROM broadcast_lists WHERE room_id=? AND (?='' OR owner_id=?) ORDER BY updated_at DESC", (room_id, owner_id, owner_id))
    out=[]
    for r in rs:
        d=dict(r); d['member_count']=len(_json_list_v249280(d.get('member_ids') or '[]')); out.append(d)
    return {'ok': True, 'lists': out}

@app.post('/api/broadcast/send')
def api_broadcast_send(payload: dict):
    ensure_comm_tables_v249280()
    room_id=str(payload.get('room_id') or '')[:120]; owner_id=str(payload.get('owner_id') or '')[:120]
    nickname=str(payload.get('nickname') or 'OK ITHAKI')[:80]; text=str(payload.get('text') or '').strip()
    list_id=str(payload.get('list_id') or '')[:120]
    member_ids=_json_list_v249280(payload.get('member_ids') or [])
    if list_id:
        r=one("SELECT member_ids FROM broadcast_lists WHERE room_id=? AND list_id=? AND owner_id=?", (room_id,list_id,owner_id))
        if not r: raise HTTPException(404, 'broadcast list not found')
        member_ids=_json_list_v249280(r['member_ids'])
    if not text: raise HTTPException(400, 'empty broadcast')
    sent=[]
    for to_id in member_ids[:500]:
        if to_id and to_id != owner_id:
            ch=_dm_channel_v249280(owner_id,to_id)
            sent.append(_insert_plain_message_v249280(room_id,ch,owner_id,nickname,text))
    try: ws_notify(room_id)
    except Exception: pass
    return {'ok': True, 'sent': len(sent), 'message_ids': sent[:20]}

@app.post('/api/messages/forward')
def api_messages_forward_v249280(payload: dict):
    ensure_comm_tables_v249280()
    room_id=str(payload.get('room_id') or '')[:120]; sender_id=str(payload.get('sender_id') or '')[:120]
    nickname=str(payload.get('nickname') or 'OK ITHAKI')[:80]; text=str(payload.get('text') or '').strip()
    to_ids=_json_list_v249280(payload.get('to_ids') or [])
    group_ids=_json_list_v249280(payload.get('group_ids') or [])
    list_ids=_json_list_v249280(payload.get('list_ids') or [])
    if not text: raise HTTPException(400, 'empty forward')
    sent=[]
    for to_id in to_ids[:300]:
        if to_id and to_id != sender_id:
            sent.append(_insert_plain_message_v249280(room_id,_dm_channel_v249280(sender_id,to_id),sender_id,nickname,'↪ '+text))
    for gid in group_ids[:100]:
        sent.append(_insert_plain_message_v249280(room_id,_channel_group_v249280(gid),sender_id,nickname,'↪ '+text))
    for lid in list_ids[:50]:
        r=one("SELECT member_ids FROM broadcast_lists WHERE room_id=? AND list_id=? AND owner_id=?", (room_id,lid,sender_id))
        if r:
            for to_id in _json_list_v249280(r['member_ids'])[:500]:
                if to_id and to_id != sender_id:
                    sent.append(_insert_plain_message_v249280(room_id,_dm_channel_v249280(sender_id,to_id),sender_id,nickname,'↪ '+text))
    try: ws_notify(room_id)
    except Exception: pass
    return {'ok': True, 'sent': len(sent), 'message_ids': sent[:20]}

@app.post('/api/profile/public/save')
def api_public_profile_save(payload: dict):
    ensure_comm_tables_v249280()
    room_id=str(payload.get('room_id') or '')[:120]; device_id=str(payload.get('device_id') or '')[:120]
    display_name=str(payload.get('display_name') or '')[:120]; bio=str(payload.get('bio') or '')[:1000]
    link=str(payload.get('link') or '')[:400]; cover=str(payload.get('cover_photo') or '')[:2000]
    execute("""
        INSERT INTO public_profiles(room_id,device_id,display_name,bio,link,cover_photo,updated_at)
        VALUES(?,?,?,?,?,?,?)
        ON CONFLICT(room_id,device_id) DO UPDATE SET display_name=excluded.display_name,bio=excluded.bio,link=excluded.link,cover_photo=excluded.cover_photo,updated_at=excluded.updated_at
    """, (room_id,device_id,display_name,bio,link,cover,now()))
    return {'ok': True, 'profile': {'room_id': room_id, 'device_id': device_id, 'display_name': display_name, 'bio': bio, 'link': link, 'cover_photo': cover}}

@app.get('/api/profile/public')
def api_public_profile_get(room_id: str = Query(...), device_id: str = Query(...)):
    ensure_comm_tables_v249280()
    r=one("SELECT * FROM public_profiles WHERE room_id=? AND device_id=? LIMIT 1", (room_id,device_id))
    if not r:
        p=one("SELECT nickname,avatar FROM people WHERE room_id=? AND device_id=? LIMIT 1", (room_id,device_id))
        return {'ok': True, 'profile': {'room_id':room_id,'device_id':device_id,'display_name':(p['nickname'] if p else ''),'bio':'','link':'','cover_photo':'','avatar':(p['avatar'] if p else '')}}
    d=dict(r)
    try:
        p=one("SELECT avatar FROM people WHERE room_id=? AND device_id=? LIMIT 1", (room_id,device_id)); d['avatar']=p['avatar'] if p else ''
    except Exception: d['avatar']=''
    return {'ok': True, 'profile': d}

@app.post('/api/webhooks/save')
def api_webhooks_save(payload: dict):
    ensure_comm_tables_v249280()
    room_id=str(payload.get('room_id') or '')[:120]; owner_id=str(payload.get('owner_id') or '')[:120]
    title=str(payload.get('title') or 'Webhook')[:120]; url=str(payload.get('url') or '')[:1000]
    secret=str(payload.get('secret') or '')[:240] or secrets.token_urlsafe(24)
    events=payload.get('events') or ['incoming_message']
    import json as _json
    hook_id=str(payload.get('hook_id') or '')[:120] or nid('hook')
    old=one("SELECT hook_id FROM api_webhooks WHERE hook_id=? AND room_id=? AND owner_id=?", (hook_id, room_id, owner_id))
    if old:
        execute("UPDATE api_webhooks SET title=?,url=?,secret=?,events=?,enabled=?,updated_at=? WHERE hook_id=?", (title,url,secret,_json.dumps(events,ensure_ascii=False),1 if payload.get('enabled', True) else 0,now(),hook_id))
    else:
        execute("INSERT INTO api_webhooks(hook_id,room_id,owner_id,title,url,secret,events,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)", (hook_id,room_id,owner_id,title,url,secret,_json.dumps(events,ensure_ascii=False),1,now(),now()))
    return {'ok': True, 'hook_id': hook_id, 'secret': secret, 'incoming_url': '/api/integrations/incoming'}

@app.get('/api/webhooks/list')
def api_webhooks_list(room_id: str = Query(...), owner_id: str = Query(default='')):
    ensure_comm_tables_v249280()
    rs=rows("SELECT hook_id,room_id,owner_id,title,url,events,enabled,created_at,updated_at FROM api_webhooks WHERE room_id=? AND (?='' OR owner_id=?) ORDER BY updated_at DESC", (room_id,owner_id,owner_id))
    return {'ok': True, 'webhooks': [dict(r) for r in rs]}

@app.post('/api/integrations/incoming')
def api_integrations_incoming(payload: dict):
    ensure_comm_tables_v249280()
    room_id=str(payload.get('room_id') or '')[:120]; secret=str(payload.get('secret') or '')[:240]
    channel=str(payload.get('channel') or '')[:160]; text=str(payload.get('text') or '').strip()
    nickname=str(payload.get('nickname') or 'API')[:80]
    hook=one("SELECT * FROM api_webhooks WHERE room_id=? AND secret=? AND enabled=1 LIMIT 1", (room_id, secret))
    if not hook: raise HTTPException(403, 'bad secret')
    if not channel: channel='api:'+str(hook['owner_id'])
    if not text: raise HTTPException(400, 'empty message')
    mid=_insert_plain_message_v249280(room_id, channel, 'api:'+str(hook['hook_id']), nickname, text)
    try: ws_notify(room_id)
    except Exception: pass
    return {'ok': True, 'message_id': mid, 'channel': channel}

@app.get("/api/lighthouse/sky")
def api_lighthouse_sky(room_id: str = Query(...), owner_token: str = Query(default="")):
    ensure_lighthouse_tables_v249278()
    night = _lighthouse_night_v249278()
    owner_hash = _lighthouse_hash_v249278(owner_token)
    rs = rows("""
        SELECT light_id, x, y, owner_hash, touched_count, created_at
        FROM lighthouse_lights
        WHERE room_id=? AND night_id=?
        ORDER BY created_at ASC
        LIMIT 1200
    """, (room_id, night))
    mine = one("SELECT light_id, touched_count, created_at FROM lighthouse_lights WHERE room_id=? AND night_id=? AND owner_hash=? LIMIT 1", (room_id, night, owner_hash))
    lights = []
    for r in rs:
        lights.append({
            "light_id": r["light_id"],
            "x": float(r["x"]),
            "y": float(r["y"]),
            "own": r["owner_hash"] == owner_hash,
        })
    return {
        "ok": True,
        "night_id": night,
        "total_count": len(lights),
        "my_has_light": bool(mine),
        "my_touched_count": int(mine["touched_count"]) if mine else 0,
        "lights": lights,
    }

@app.post("/api/lighthouse/light")
def api_lighthouse_light(p: LighthouseLightIn):
    ensure_lighthouse_tables_v249278()
    night = _lighthouse_night_v249278()
    owner_hash = _lighthouse_hash_v249278(p.owner_token)
    existing = one("SELECT light_id, touched_count, x, y FROM lighthouse_lights WHERE room_id=? AND night_id=? AND owner_hash=? LIMIT 1", (p.room_id, night, owner_hash))
    if existing:
        return {"ok": True, "created": False, "light_id": existing["light_id"], "x": existing["x"], "y": existing["y"], "touched_count": int(existing["touched_count"])}
    x, y = _light_pos_v249278(owner_hash)
    lid = nid("light")
    execute("INSERT INTO lighthouse_lights(light_id,room_id,night_id,owner_hash,x,y,touched_count,created_at) VALUES(?,?,?,?,?,?,0,?)", (lid, p.room_id, night, owner_hash, x, y, now()))
    try:
        ws_notify(p.room_id)
    except Exception:
        pass
    return {"ok": True, "created": True, "light_id": lid, "x": x, "y": y, "touched_count": 0}

@app.post("/api/lighthouse/touch")
def api_lighthouse_touch(p: LighthouseTouchIn):
    ensure_lighthouse_tables_v249278()
    if not p.light_id:
        raise HTTPException(400, "Missing light_id")
    night = _lighthouse_night_v249278()
    toucher_hash = _lighthouse_hash_v249278(p.owner_token)
    light = one("SELECT light_id, owner_hash FROM lighthouse_lights WHERE room_id=? AND night_id=? AND light_id=? LIMIT 1", (p.room_id, night, p.light_id))
    if not light:
        raise HTTPException(404, "Light not found")
    if light["owner_hash"] == toucher_hash:
        return {"ok": True, "already": True, "self": True}
    old = one("SELECT touch_id FROM lighthouse_touches WHERE room_id=? AND night_id=? AND light_id=? AND toucher_hash=? LIMIT 1", (p.room_id, night, p.light_id, toucher_hash))
    if old:
        cnt = one("SELECT touched_count FROM lighthouse_lights WHERE light_id=? LIMIT 1", (p.light_id,))
        return {"ok": True, "already": True, "self": False, "touched_count": int(cnt["touched_count"]) if cnt else 0}
    execute("INSERT INTO lighthouse_touches(touch_id,light_id,room_id,night_id,toucher_hash,created_at) VALUES(?,?,?,?,?,?)", (nid("touch"), p.light_id, p.room_id, night, toucher_hash, now()))
    execute("UPDATE lighthouse_lights SET touched_count=touched_count+1 WHERE light_id=?", (p.light_id,))
    cnt = one("SELECT touched_count FROM lighthouse_lights WHERE light_id=? LIMIT 1", (p.light_id,))
    try:
        ws_notify(p.room_id)
    except Exception:
        pass
    return {"ok": True, "already": False, "self": False, "touched_count": int(cnt["touched_count"]) if cnt else 0}

@app.get("/api/bottle/info")
def api_bottle_info():
    return {
        "ok": True,
        "version": "V249274",
        "feature": "Μήνυμα στο Μπουκάλι",
        "identity": "bottle_messages stores owner_hash, not device_id or nickname",
        "note": "Replies return through a local random bottle token stored only on the user's device."
    }

@app.post("/api/bottle/drop")
def api_bottle_drop(p: BottleDropIn):
    ensure_bottle_tables_v249274()
    text = str(p.text or "").strip()
    if not text:
        raise HTTPException(400, "Empty bottle")
    if len(text) > 900:
        raise HTTPException(400, "Bottle message too long")
    mode = str(p.mode or "random").strip().lower()
    if mode not in ("random", "mesh", "nearby"):
        mode = "random"
    bid = nid("bottle")
    execute("""
    INSERT INTO bottle_messages(bottle_id,room_id,owner_hash,mode,mesh,text,status,vanish,created_at)
    VALUES(?,?,?,?,?,?,?,?,?)
    """, (bid, p.room_id, _bottle_hash(p.owner_token), mode, 1 if (p.mesh or mode in ("mesh","nearby")) else 0, text, "floating", 1 if p.vanish else 0, now()))
    try:
        ws_notify(p.room_id)
    except Exception:
        pass
    return {"ok": True, "bottle_id": bid, "status": "floating", "anonymous": True}

@app.get("/api/bottle/random")
def api_bottle_random(room_id: str = Query(...), owner_token: str = Query(default=""), mesh: int = Query(default=0)):
    ensure_bottle_tables_v249274()
    h = _bottle_hash(owner_token)
    params = [room_id, h]
    extra = ""
    if int(mesh or 0):
        extra = " AND mesh=1"
    r = one(f"""
        SELECT * FROM bottle_messages
        WHERE room_id=? AND status='floating' AND owner_hash<>? {extra}
        ORDER BY RANDOM()
        LIMIT 1
    """, tuple(params))
    if not r:
        return {"ok": True, "bottle": None}
    execute("UPDATE bottle_messages SET status='found', found_at=? WHERE bottle_id=?", (now(), r["bottle_id"]))
    return {"ok": True, "bottle": {"bottle_id": r["bottle_id"], "text": r["text"], "mode": r["mode"], "mesh": bool(r["mesh"]), "created_at": r["created_at"]}}

@app.post("/api/bottle/reply")
def api_bottle_reply(p: BottleReplyIn):
    ensure_bottle_tables_v249274()
    text = str(p.text or "").strip()
    if not p.bottle_id:
        raise HTTPException(400, "Missing bottle_id")
    if not text:
        raise HTTPException(400, "Empty reply")
    if len(text) > 900:
        raise HTTPException(400, "Reply too long")
    r = one("SELECT * FROM bottle_messages WHERE room_id=? AND bottle_id=?", (p.room_id, p.bottle_id))
    if not r:
        raise HTTPException(404, "Bottle not found")
    execute("""
    UPDATE bottle_messages
    SET reply_text=?, reply_hash=?, continue_ok=?, status='replied', replied_at=?
    WHERE room_id=? AND bottle_id=?
    """, (text, _bottle_hash(p.replier_token), 1 if p.continue_ok else 0, now(), p.room_id, p.bottle_id))
    try:
        ws_notify(p.room_id)
    except Exception:
        pass
    return {"ok": True, "bottle_id": p.bottle_id, "status": "replied", "anonymous": True}

@app.get("/api/bottle/mine")
def api_bottle_mine(room_id: str = Query(...), owner_token: str = Query(default=""), limit: int = Query(default=50)):
    ensure_bottle_tables_v249274()
    h = _bottle_hash(owner_token)
    rs = rows("""
        SELECT bottle_id,text,reply_text,status,mode,mesh,continue_ok,vanish,created_at,found_at,replied_at
        FROM bottle_messages
        WHERE room_id=? AND owner_hash=?
        ORDER BY created_at DESC
        LIMIT ?
    """, (room_id, h, max(1, min(int(limit or 50), 200))))
    return {"ok": True, "items": [dict(r) for r in rs]}

@app.post("/api/bottle/thread/send")
def api_bottle_thread_send(p: BottleThreadIn):
    ensure_bottle_tables_v249274()
    text = str(p.text or "").strip()
    if not p.bottle_id:
        raise HTTPException(400, "Missing bottle_id")
    if not text:
        raise HTTPException(400, "Empty thread message")
    if len(text) > 1200:
        raise HTTPException(400, "Thread message too long")
    r = one("SELECT * FROM bottle_messages WHERE room_id=? AND bottle_id=?", (p.room_id, p.bottle_id))
    if not r:
        raise HTTPException(404, "Bottle not found")
    role = "owner" if _bottle_hash(p.token) == r["owner_hash"] else "reply"
    tid = nid("bthread")
    execute("""
    INSERT INTO bottle_threads(thread_id,bottle_id,room_id,author_role,author_hash,text,created_at)
    VALUES(?,?,?,?,?,?,?)
    """, (tid, p.bottle_id, p.room_id, role, _bottle_hash(p.token), text, now()))
    try:
        ws_notify(p.room_id)
    except Exception:
        pass
    return {"ok": True, "thread_id": tid, "bottle_id": p.bottle_id}

@app.get("/api/bottle/thread")
def api_bottle_thread(room_id: str = Query(...), bottle_id: str = Query(...), token: str = Query(default="")):
    ensure_bottle_tables_v249274()
    r = one("SELECT * FROM bottle_messages WHERE room_id=? AND bottle_id=?", (room_id, bottle_id))
    if not r:
        raise HTTPException(404, "Bottle not found")
    h = _bottle_hash(token)
    allowed = h in (r["owner_hash"], r["reply_hash"]) or not r["reply_hash"]
    if not allowed:
        raise HTTPException(403, "Not this bottle connection")
    rs = rows("""
        SELECT thread_id,bottle_id,author_role,text,created_at
        FROM bottle_threads
        WHERE room_id=? AND bottle_id=?
        ORDER BY created_at ASC
        LIMIT 300
    """, (room_id, bottle_id))
    return {"ok": True, "bottle": {"bottle_id": r["bottle_id"], "text": r["text"], "reply_text": r["reply_text"], "status": r["status"], "continue_ok": bool(r["continue_ok"])}, "items": [dict(x) for x in rs]}

@app.post("/api/bottle/vanish")
def api_bottle_vanish(p: BottleThreadIn):
    ensure_bottle_tables_v249274()
    if not p.bottle_id:
        raise HTTPException(400, "Missing bottle_id")
    r = one("SELECT * FROM bottle_messages WHERE room_id=? AND bottle_id=?", (p.room_id, p.bottle_id))
    if not r:
        raise HTTPException(404, "Bottle not found")
    h = _bottle_hash(p.token)
    if h not in (r["owner_hash"], r["reply_hash"]):
        raise HTTPException(403, "Not this bottle")
    execute("DELETE FROM bottle_threads WHERE room_id=? AND bottle_id=?", (p.room_id, p.bottle_id))
    execute("UPDATE bottle_messages SET status='vanished', text='', reply_text='' WHERE room_id=? AND bottle_id=?", (p.room_id, p.bottle_id))
    try:
        ws_notify(p.room_id)
    except Exception:
        pass
    return {"ok": True, "bottle_id": p.bottle_id, "status": "vanished"}

@app.get("/api/support/zero_identity/info")
def api_support_zero_identity_info():
    return {"ok": True, "channel": "support:zero_calm_wall_v249270", "db_identity": "sender_id empty; nickname anonymous", "warning": "Absolute no-trace requires disabling or minimizing reverse-proxy/server access logs."}

@app.post("/api/messages/send")
def send_message(p: MessageIn):
    # V249270 zero identity support wall:
    # For support:zero_* channels, the messages table never stores device_id or nickname.
    # Note: full "no trace at all" also requires reverse-proxy/web-server log policy outside SQLite.
    zero_identity = bool(getattr(p, "zero_identity", False)) or str(p.channel or "").startswith("support:zero")
    if zero_identity:
        safe_text = str(p.text or "").strip()
        if not safe_text:
            raise HTTPException(400, "Empty message")
        if len(safe_text) > 4000:
            raise HTTPException(400, "Zero identity message too long")
        mid = nid("msg")
        execute("""
        INSERT INTO messages(message_id,room_id,channel,sender_id,nickname,language,text,is_encrypted,iv,cipher_text,created_at)
        VALUES(?,?,?,?,?,?,?,?,?,?,?)
        """, (mid, p.room_id, p.channel, "", "Ανώνυμος", p.language or "el",
              safe_text, 0, "", "", now()))
        try:
            ws_notify(p.room_id)
        except Exception:
            pass
        return {"ok": True, "message_id": mid, "zero_identity": True, "note": "messages table stores empty sender_id and anonymous nickname"}
    security_member_guard(p.room_id, p.sender_id, "message_member", max_per_minute=12, max_per_hour=80)
    if is_banned(p.room_id, p.sender_id):
        raise HTTPException(403, "member blocked")
    if p.is_encrypted:
        if not p.iv or not p.cipher_text:
            raise HTTPException(400, "Encrypted message requires iv/cipher_text")
        safe_text = ""
    else:
        safe_text = p.text.strip()
        if not safe_text:
            raise HTTPException(400, "Empty message")
        humanguard_check_or_raise(p.room_id, p.sender_id, p.nickname, "message", safe_text)
        if p.channel.startswith("dm:"):
            parts = p.channel.split(":")
            others = [x for x in parts[1:] if x and x != p.sender_id]
            if others and _is_user_blocked_between(p.room_id, p.sender_id, others[0]):
                raise HTTPException(403, "blocked by member")
            if others and not _are_friends_v249227(p.room_id, p.sender_id, others[0]):
                raise HTTPException(status_code=403, detail={"ok": False, "reason": "friend_required", "message": "Πρώτα χρειάζεται αποδοχή φιλίας για μηνύματα."})
        # V246.1.9: personal DM remains free. Public comments/rooms still use professional soft masking.
        if p.channel.startswith("dm:"):
            mod = {"changed": False, "text": safe_text, "warning": ""}
        else:
            mod = _moderate_text(p.room_id, p.sender_id, p.nickname, safe_text, "message", p.channel)
            safe_text = mod["text"]
            if mod.get("changed"):
                safe_text = safe_text + "\n\n⚠️ " + mod.get("warning", "")
    media_info = _extract_payload_media(safe_text, "message") if not p.is_encrypted else {"has_media": False, "bytes": 0, "kind": "message"}
    if media_info.get("has_media") and int(media_info.get("bytes") or 0) > 0:
        check_media_quota_or_raise(p.room_id, p.sender_id, str(media_info.get("kind") or "media"), int(media_info.get("bytes") or 0))
    mid = nid("msg")
    execute("""
    INSERT INTO messages(message_id,room_id,channel,sender_id,nickname,language,text,is_encrypted,iv,cipher_text,created_at)
    VALUES(?,?,?,?,?,?,?,?,?,?,?)
    """, (mid, p.room_id, p.channel, p.sender_id, p.nickname, p.language,
          safe_text, 1 if p.is_encrypted else 0, p.iv, p.cipher_text, now()))
    if media_info.get("has_media") and int(media_info.get("bytes") or 0) > 0:
        record_media_usage(p.room_id, p.sender_id, str(media_info.get("kind") or "media"), int(media_info.get("bytes") or 0), "message:" + str(p.channel)[:120])
    push_result = push_notify(p.room_id, p.channel, p.sender_id, p.nickname,
                              safe_text if not p.is_encrypted else "Κρυπτογραφημένο μήνυμα", mid)
    try:
        ws_notify(p.room_id)   # V24822: instant realtime push (0ms) to connected WS clients
    except Exception:
        pass
    return {"ok": True, "message_id": mid, "push": push_result}


@app.get("/api/messages")
def get_messages(room_id: str = Query(...), channel: str = Query("main"),
                 since: float = Query(0), limit: int = Query(300, ge=1, le=500)):
    result = []
    for r in rows("""
        SELECT * FROM messages WHERE room_id=? AND channel=? AND created_at>?
        ORDER BY created_at ASC LIMIT ?
    """, (room_id, channel, since, limit)):
        d = dict(r)
        d["is_encrypted"] = bool(d["is_encrypted"])
        # V248720: messages use the same saved avatar as contacts/feed.
        try:
            pr = one("SELECT avatar FROM people WHERE room_id=? AND device_id=? LIMIT 1", (room_id, str(d.get("sender_id") or "")))
            d["avatar"] = str((pr["avatar"] if pr else "") or "")
            d["photo"] = d["avatar"]
            d["profile_photo"] = d["avatar"]
        except Exception:
            d["avatar"] = str(d.get("avatar") or "")
        result.append(d)
    return {"ok": True, "messages": result}



@app.post("/api/messages/mark_read")
def api_messages_mark_read(p: MessageReadIn):
    ts = float(p.read_until or now())
    execute("""
    INSERT INTO message_reads(room_id,channel,reader_id,read_until,updated_at)
    VALUES(?,?,?,?,?)
    ON CONFLICT(room_id,channel,reader_id) DO UPDATE SET
      read_until=MAX(message_reads.read_until, excluded.read_until),
      updated_at=excluded.updated_at
    """, (p.room_id, p.channel, p.reader_id, ts, now()))
    try:
        ws_notify(p.room_id)
    except Exception:
        pass
    return {"ok": True, "read_until": ts}


@app.get("/api/messages/read_state")
def api_messages_read_state(room_id: str = Query(...), channel: str = Query("main")):
    out = {}
    for r in rows("SELECT reader_id, read_until, updated_at FROM message_reads WHERE room_id=? AND channel=?", (room_id, channel)):
        out[str(r["reader_id"])] = float(r["read_until"] or 0)
    return {"ok": True, "reads": out, "updated_at": now()}


@app.post("/api/messages/typing")
def api_messages_typing(p: MessageTypingIn):
    execute("""
    INSERT INTO message_typing(room_id,channel,device_id,nickname,is_typing,updated_at)
    VALUES(?,?,?,?,?,?)
    ON CONFLICT(room_id,channel,device_id) DO UPDATE SET
      nickname=excluded.nickname,
      is_typing=excluded.is_typing,
      updated_at=excluded.updated_at
    """, (p.room_id, p.channel, p.device_id, p.nickname, 1 if p.is_typing else 0, now()))
    return {"ok": True, "typing": bool(p.is_typing), "updated_at": now()}


@app.get("/api/messages/typing")
def api_messages_typing_get(room_id: str = Query(...), channel: str = Query("main"), viewer_id: str = Query("")):
    cutoff = now() - 12
    out = []
    for r in rows("SELECT device_id,nickname,is_typing,updated_at FROM message_typing WHERE room_id=? AND channel=? AND updated_at>=? ORDER BY updated_at DESC", (room_id, channel, cutoff)):
        if viewer_id and str(r["device_id"]) == str(viewer_id):
            continue
        out.append({"device_id": r["device_id"], "nickname": r["nickname"], "is_typing": bool(r["is_typing"]), "updated_at": float(r["updated_at"] or 0)})
    return {"ok": True, "typing": out, "updated_at": now()}


@app.post("/api/messages/delete")
def api_messages_delete(payload: dict):
    room_id = str(payload.get("room_id") or "ithaki-phone-test").strip()
    channel = str(payload.get("channel") or "").strip()
    message_id = str(payload.get("message_id") or "").strip()
    device_id = str(payload.get("device_id") or "").strip()
    if not message_id or not device_id:
        raise HTTPException(400, "message_id and device_id required")
    r = one("SELECT sender_id FROM messages WHERE room_id=? AND message_id=?", (room_id, message_id))
    if not r:
        return {"ok": True, "deleted": False, "reason": "not_found"}
    if str(r["sender_id"]) != device_id:
        raise HTTPException(403, "Only the original device can delete this message")
    if channel:
        execute("DELETE FROM messages WHERE room_id=? AND channel=? AND message_id=?", (room_id, channel, message_id))
    else:
        execute("DELETE FROM messages WHERE room_id=? AND message_id=?", (room_id, message_id))
    try:
        ws_notify(room_id)
    except Exception:
        pass
    return {"ok": True, "deleted": True, "message_id": message_id}

# ────────────────────────────────────────────────────────────
# V24821 — WebSocket realtime push (ADDITIVE; HTTP polling stays as fallback)
# Self-contained: reads NEW messages from DB and pushes them over ONE socket,
# so clients can drop dozens of HTTP polls. No existing endpoint is modified.
# Client protocol:
#   -> {"type":"hello","room_id":"...","device_id":"...","channels":[...]}
#   <- {"type":"ready"}                      (then) <- {"type":"messages","items":[...]}
#   -> {"type":"sub","channels":[...]}        -> {"type":"ping"}  <- {"type":"pong"}
# ────────────────────────────────────────────────────────────
def _ws_collect_new(room_id: str, channels, since_map: dict):
    out = []
    for ch in list(channels):
        since = since_map.get(ch, 0.0)
        top = since
        try:
            for r in rows("SELECT * FROM messages WHERE room_id=? AND channel=? AND created_at>? "
                          "ORDER BY created_at ASC LIMIT 60", (room_id, ch, since)):
                d = dict(r)
                d["is_encrypted"] = bool(d["is_encrypted"])
                out.append(d)
                if d["created_at"] > top:
                    top = d["created_at"]
        except Exception:
            pass
        since_map[ch] = top
    return out


# Instant-push registry: room_id -> set of asyncio.Event.
# Extended in V249312 with device socket registry for instant WebRTC signaling.
_ws_loop = None
_ws_room_events = {}
_ws_device_sockets = {}

def _ws_register(room_id, ev):
    _ws_room_events.setdefault(room_id, set()).add(ev)

def _ws_unregister(room_id, ev):
    s = _ws_room_events.get(room_id)
    if s:
        s.discard(ev)
        if not s:
            _ws_room_events.pop(room_id, None)

def _ws_register_device(device_id, websocket):
    if device_id:
        _ws_device_sockets.setdefault(device_id, set()).add(websocket)

def _ws_unregister_device(device_id, websocket):
    s = _ws_device_sockets.get(device_id)
    if s:
        s.discard(websocket)
        if not s:
            _ws_device_sockets.pop(device_id, None)

def _ws_fire(room_id):
    s = _ws_room_events.get(room_id)
    if s:
        for e in list(s):
            try:
                e.set()
            except Exception:
                pass

def ws_notify(room_id: str):
    """Wake WS pushers for this room INSTANTLY. Safe to call from any thread.
    No-op if no WS loop/clients yet (the 1.2s pusher fallback still covers it)."""
    loop = _ws_loop
    if loop is None or not room_id:
        return
    try:
        loop.call_soon_threadsafe(_ws_fire, room_id)
    except Exception:
        pass

async def _ws_push_call_signal_to_device(device_id: str, payload: dict):
    targets = list(_ws_device_sockets.get(device_id) or [])
    if not targets:
        return
    frame = {"type": "call_signal"}
    frame.update(payload or {})
    stale = []
    for ws in targets:
        try:
            await ws.send_json(frame)
        except Exception:
            stale.append(ws)
    for ws in stale:
        _ws_unregister_device(device_id, ws)

def ws_call_signal_notify(device_id: str, payload: dict):
    loop = _ws_loop
    if loop is None or not device_id:
        return
    try:
        loop.call_soon_threadsafe(lambda: asyncio.create_task(_ws_push_call_signal_to_device(device_id, payload or {})))
    except Exception:
        pass

@app.websocket("/ws")
async def ws_realtime(websocket: WebSocket):
    await websocket.accept()
    room_id = ""
    device_id = ""
    channels = set()
    since_map = {}
    loop = asyncio.get_event_loop()
    global _ws_loop
    _ws_loop = loop
    wake = asyncio.Event()
    try:
        hello = await asyncio.wait_for(websocket.receive_json(), timeout=25)
        room_id = str((hello or {}).get("room_id") or "")
        device_id = str((hello or {}).get("device_id") or "")
        for c in ((hello or {}).get("channels") or []):
            channels.add(str(c))
        start = max(0.0, now() - 3.0)
        for c in channels:
            since_map[c] = start
        _ws_register(room_id, wake)
        _ws_register_device(device_id, websocket)
        await websocket.send_json({"type": "ready", "ts": now(), "ws_call_signaling": True})

        async def reader():
            nonlocal device_id, room_id
            while True:
                m = await websocket.receive_json()
                t = (m or {}).get("type")
                if t == "sub":
                    for c in (m.get("channels") or []):
                        c = str(c)
                        if c and c not in channels:
                            channels.add(c)
                            since_map[c] = max(0.0, now() - 3.0)
                elif t == "ping":
                    await websocket.send_json({"type": "pong", "ts": now()})
                elif t == "hello_refresh":
                    new_room = str((m or {}).get("room_id") or room_id or "")
                    new_device = str((m or {}).get("device_id") or device_id or "")
                    if new_room != room_id:
                        _ws_unregister(room_id, wake)
                        room_id = new_room
                        _ws_register(room_id, wake)
                    if new_device != device_id:
                        _ws_unregister_device(device_id, websocket)
                        device_id = new_device
                        _ws_register_device(device_id, websocket)
                elif t == "call_signal":
                    try:
                        room = str((m or {}).get("room_id") or room_id or "ithaki-phone-test")
                        sid = str((m or {}).get("session_id") or "")
                        target_id = str((m or {}).get("target_id") or "")
                        signal_type = str((m or {}).get("signal_type") or "")
                        payload = (m or {}).get("payload") or {}
                        _video_store_signal(room, sid, device_id, target_id, signal_type, payload, notify_ws=True)
                    except Exception:
                        pass

        async def pusher():
            while True:
                try:
                    await asyncio.wait_for(wake.wait(), timeout=1.2)
                except asyncio.TimeoutError:
                    pass
                wake.clear()
                if channels and room_id:
                    new = await loop.run_in_executor(None, _ws_collect_new, room_id, channels, since_map)
                    if new:
                        await websocket.send_json({"type": "messages", "items": new})

        tasks = [asyncio.create_task(reader()), asyncio.create_task(pusher())]
        try:
            await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION)
        finally:
            for tk in tasks:
                tk.cancel()
            _ws_unregister(room_id, wake)
            _ws_unregister_device(device_id, websocket)
    except WebSocketDisconnect:
        _ws_unregister(room_id, wake)
        _ws_unregister_device(device_id, websocket)
    except Exception:
        try:
            _ws_unregister(room_id, wake)
            _ws_unregister_device(device_id, websocket)
        except Exception:
            pass
        try:
            await websocket.close()
        except Exception:
            pass


# ──────────────────────────────────────────────
# Flow (public feed)
# ──────────────────────────────────────────────

# ITHAKI V248713: canonical Feed fingerprint used by API dedupe and send idempotency.
_ITHAKI_V248713_VOLATILE_KEYS = {
    'post_id','message_id','id','created_at','updated_at','timestamp','time','ts',
    'client_id','request_id','nonce','temp_id','local_id','upload_started_at'
}

def ithaki_v248713_canonical_value(value):
    try:
        if isinstance(value, dict):
            out = {}
            for key in sorted(value.keys(), key=lambda x: str(x)):
                skey = str(key)
                if skey.lower() in _ITHAKI_V248713_VOLATILE_KEYS:
                    continue
                out[skey] = ithaki_v248713_canonical_value(value[key])
            return out
        if isinstance(value, list):
            return [ithaki_v248713_canonical_value(x) for x in value]
        if isinstance(value, str):
            return re.sub(r'\s+', ' ', value.strip())
        return value
    except Exception:
        return value

def ithaki_v248713_canonical_text(text: str) -> str:
    raw = str(text or '').strip()
    try:
        parsed = json.loads(raw)
        return json.dumps(ithaki_v248713_canonical_value(parsed), ensure_ascii=False, sort_keys=True, separators=(',', ':'))
    except Exception:
        return re.sub(r'\s+', ' ', raw)

def ithaki_v248713_post_signature(room_id, sender_id, post_kind, anonymous, visibility, text):
    return '|'.join([
        str(room_id or '').strip(), str(sender_id or '').strip(),
        str(post_kind or '').strip().lower(), '1' if bool(anonymous) else '0',
        str(visibility or 'public').strip().lower(),
        ithaki_v248713_canonical_text(text)
    ])

@app.post("/api/flow/send")
def send_flow(p: FlowIn):
    security_member_guard(p.room_id, p.sender_id, "flow_member", max_per_minute=60, max_per_hour=300)
    if is_banned(p.room_id, p.sender_id):
        raise HTTPException(403, "member blocked")
    if p.is_encrypted:
        if not p.iv or not p.cipher_text:
            raise HTTPException(400, "Encrypted flow requires iv/cipher_text")
        safe_text = ""
    else:
        safe_text = p.text.strip()
        if not safe_text:
            raise HTTPException(400, "Empty post")
        humanguard_check_or_raise(p.room_id, p.sender_id, p.nickname, "flow", safe_text)
        mod = _moderate_text(p.room_id, p.sender_id, p.nickname, safe_text, "flow", "public")
        safe_text = mod["text"]
        if mod.get("changed"):
            safe_text = safe_text + "\n\n⚠️ " + mod.get("warning", "")
    media_info = _extract_payload_media(safe_text, p.post_kind) if not p.is_encrypted else {"has_media": False, "bytes": 0, "kind": p.post_kind}
    if media_info.get("has_media") and int(media_info.get("bytes") or 0) > 0:
        check_media_quota_or_raise(p.room_id, p.sender_id, str(media_info.get("kind") or p.post_kind or "media"), int(media_info.get("bytes") or 0))
    # V248713: prevent a double click / duplicated frontend POST from creating a second row.
    # Compare canonical payloads and keep/return the newest existing post from the last 180 seconds.
    try:
        current_sig = ithaki_v248713_post_signature(
            p.room_id, p.sender_id, p.post_kind, p.anonymous, p.visibility, safe_text
        )
        recent = rows("""
            SELECT post_id,room_id,sender_id,post_kind,anonymous,visibility,text,created_at
            FROM flow_posts
            WHERE room_id=? AND sender_id=? AND created_at>=?
            ORDER BY created_at DESC LIMIT 25
        """, (p.room_id, p.sender_id, now() - 180.0))
        for rr in recent:
            if current_sig == ithaki_v248713_post_signature(
                rr['room_id'], rr['sender_id'], rr['post_kind'], rr['anonymous'], rr['visibility'], rr['text']
            ):
                return {
                    "ok": True,
                    "post_id": str(rr['post_id']),
                    "duplicate_prevented": True,
                    "kept": "latest",
                    "quota": quota_status(p.room_id, p.sender_id)
                }
    except Exception:
        pass

    pid = nid("flow")
    execute("""
    INSERT INTO flow_posts(post_id,room_id,sender_id,nickname,language,text,is_encrypted,iv,cipher_text,post_kind,anonymous,visibility,created_at)
    VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
    """, (pid, p.room_id, p.sender_id, p.nickname, p.language, safe_text,
          1 if p.is_encrypted else 0, p.iv, p.cipher_text, p.post_kind, 1 if p.anonymous else 0, p.visibility, now()))
    if media_info.get("has_media") and int(media_info.get("bytes") or 0) > 0:
        record_media_usage(p.room_id, p.sender_id, str(media_info.get("kind") or p.post_kind or "media"), int(media_info.get("bytes") or 0), "flow:" + str(p.visibility)[:40])
    try:
        notify_followers_post_v249223(p.room_id, p.sender_id, p.nickname, pid, bool(p.anonymous), str(media_info.get('kind') or p.post_kind or ''))
    except Exception:
        pass
    return {"ok": True, "post_id": pid, "quota": quota_status(p.room_id, p.sender_id)}


def _flow_text_for_mobile_lite(text: str, max_media_chars: int = 1600000) -> str:
    """Return safe post text for Android WebView feed lists.

    Old builds stored photos/videos inside JSON as huge data URLs. Desktop can often
    render them, but Android WebView may timeout or fail the whole Feed request.
    This keeps the post and comments visible and strips only oversized embedded
    media from lite responses. New photos are compressed by the frontend before
    publication, so normal photos still show.
    """
    if not text:
        return text
    try:
        obj = json.loads(text)
        if not isinstance(obj, dict):
            return text
        media = obj.get("media")
        if isinstance(media, dict):
            data_url = str(media.get("dataUrl") or "")
            if len(data_url) > int(max_media_chars):
                media["dataUrl"] = ""
                media["dataUrlRemovedForMobile"] = True
                media["mobileNote"] = "Large media was hidden on mobile feed to keep the app stable. Upload a smaller/compressed copy to show it in the feed."
                obj["media"] = media
        return json.dumps(obj, ensure_ascii=False)
    except Exception:
        return text



# V248703: Feed visible media guard. Keeps old database rows, but does not send posts whose media file is missing.
def ithaki_v248703_media_file_exists_for_feed(meta_id: str) -> bool:
    try:
        init_media_db()
        mid = str(meta_id or '').strip()
        if not mid:
            return False
        r = one("SELECT note FROM media_metadata WHERE meta_id=?", (mid,))
        if not r:
            return False
        note = str(r["note"] or "")
        if not note.startswith("server_file:"):
            return False
        path = ithaki_media_path_from_note(note)
        if path is None:
            return False
        return bool(path.exists() and path.is_file() and path.stat().st_size > 0)
    except Exception:
        return False


def ithaki_v248703_extract_feed_media_ids(text: str) -> list:
    ids = []
    def add(x):
        s = str(x or '').strip()
        if not s:
            return
        m = re.search(r'/api/media/file/([A-Za-z0-9_\-]+)', s)
        if m:
            s = m.group(1)
        if re.match(r'^media_[A-Za-z0-9_\-]+$', s) and s not in ids:
            ids.append(s)
    try:
        raw = str(text or '')
        for m in re.finditer(r'/api/media/file/([A-Za-z0-9_\-]+)', raw):
            add(m.group(1))
        obj = json.loads(raw)
        stack = [obj]
        while stack:
            v = stack.pop()
            if isinstance(v, dict):
                for k, val in v.items():
                    lk = str(k).lower()
                    if lk in ('media_id','meta_id','id'):
                        add(val)
                    elif lk in ('url','file_url','absolute_url','media_url','video_url','image_url','src'):
                        add(val)
                    if isinstance(val, (dict, list)):
                        stack.append(val)
            elif isinstance(v, list):
                stack.extend(v)
    except Exception:
        pass
    return ids


def ithaki_v248703_feed_post_has_missing_media(d: dict) -> bool:
    try:
        ids = ithaki_v248703_extract_feed_media_ids(str(d.get('text') or ''))
        # If no server media id exists, do not hide text/photo-data posts here.
        if not ids:
            return False
        return not any(ithaki_v248703_media_file_exists_for_feed(mid) for mid in ids)
    except Exception:
        return False



# V249286: temporary poll cleanup/hide for Feed debugging.
def ithaki_v249286_is_poll_post_text(text: str) -> bool:
    try:
        obj = json.loads(text or "")
        if isinstance(obj, dict):
            media = obj.get("media")
            if isinstance(media, dict):
                kind = str(media.get("kind") or "").lower()
                if kind == "poll" or media.get("poll_id") or media.get("pollId") or media.get("pollID"):
                    return True
            if str(obj.get("post_kind") or "").lower() in {"poll", "vote", "poll_card"}:
                return True
    except Exception:
        pass
    raw = str(text or "").lower().replace(" ", "")
    return ('"kind":"poll"' in raw or '"poll_id"' in raw or '"pollid"' in raw or "pollfeedcard" in raw or "professionalpollcard" in raw)

def ithaki_v249286_delete_poll_posts(room_id: str = "") -> dict:
    try:
        where = "WHERE (post_kind IN ('poll','vote','poll_card') OR lower(text) LIKE '%\"kind\":%poll%' OR lower(text) LIKE '%\"poll_id\"%' OR lower(text) LIKE '%pollfeedcard%' OR lower(text) LIKE '%professionalpollcard%')"
        params = ()
        if room_id:
            where += " AND room_id=?"
            params = (room_id,)
        before = one("SELECT COUNT(*) AS c FROM flow_posts " + where, params)
        n = int(before["c"] if before else 0)
        execute("DELETE FROM flow_posts " + where, params)
        try:
            if room_id:
                execute("DELETE FROM polls WHERE room_id=?", (room_id,))
                execute("DELETE FROM poll_votes WHERE room_id=?", (room_id,))
            else:
                execute("DELETE FROM polls")
                execute("DELETE FROM poll_votes")
        except Exception:
            pass
        return {"ok": True, "deleted_poll_posts": n}
    except Exception as e:
        LOG.exception("V249286 poll cleanup failed")
        return {"ok": False, "error": str(e)}

@app.get("/api/flow")
# ΕΛΛΗΝΙΚΟ ΣΧΟΛΙΟ: Το /api/flow επιστρέφει τις αναρτήσεις της Ροής. Αν αυτό δίνει 200 OK, το πρόβλημα είναι συνήθως στο frontend renderer ή στα media URLs, όχι στη βάση.
def get_flow(request: Request, room_id: str = Query(...), visibility: str = Query("public"),
             limit: int = Query(100, ge=1, le=500),
             lite: int = Query(0, ge=0, le=1),
             max_media_chars: int = Query(1600000, ge=0, le=12000000),
             viewer_id: str = Query(""),
             author_id: str = Query(""),
             no304: int = Query(0, ge=0, le=1)):
    author_id = str(author_id or "")[:160]
    where = ["room_id=?"]
    params_list = [room_id]
    if visibility != "all":
        where.append("visibility=?")
        params_list.append(visibility)
    if author_id:
        where.append("sender_id=?")
        params_list.append(author_id)
    sql = "SELECT * FROM flow_posts WHERE " + " AND ".join(where) + " ORDER BY created_at DESC LIMIT ?"
    params_list.append(limit)
    params = tuple(params_list)
    result = []
    viewer_id = str(viewer_id or "")[:120]
    for r in rows(sql, params):
        d = dict(r)
        if viewer_id and d.get("sender_id") and str(d.get("sender_id") or "") != viewer_id and _is_user_blocked_between(room_id, viewer_id, str(d.get("sender_id"))):
            continue
        d["is_encrypted"] = bool(d["is_encrypted"])
        d["anonymous"]    = bool(d["anonymous"])
        # V248703: Do not send Feed posts that point to missing server media files.
        # The database row stays untouched; it is only hidden from the live Feed to stop 404 loops.
        try:
            if ithaki_v248703_feed_post_has_missing_media(d) and str(d.get("sender_id") or "") != viewer_id:
                continue
            if ithaki_v248703_feed_post_has_missing_media(d) and str(d.get("sender_id") or "") == viewer_id:
                d["own_media_missing_visible"] = True
        except Exception:
            pass
        d["message_id"]   = d["post_id"]
        # V248720: one avatar source everywhere. Feed receives the saved people.avatar
        # for the post sender; the official ITHAKI profile is handled by the frontend ship rule.
        try:
            pr = one("SELECT avatar FROM people WHERE room_id=? AND device_id=? LIMIT 1", (room_id, str(d.get("sender_id") or "")))
            d["avatar"] = str((pr["avatar"] if pr else "") or "")
            d["photo"] = d["avatar"]
            d["profile_photo"] = d["avatar"]
        except Exception:
            d["avatar"] = str(d.get("avatar") or "")
        if lite:
            d["text"] = _flow_text_for_mobile_lite(str(d.get("text") or ""), max_media_chars=max_media_chars)
        result.append(d)
    # v246.0.4: hide exact duplicate feed rows in API output; keeps the newest one because SQL is DESC.
    try:
        seen_keys = set()
        deduped = []
        for d in result:
            key = ithaki_v248713_post_signature(
                d.get("room_id"), d.get("sender_id"), d.get("post_kind"),
                d.get("anonymous"), d.get("visibility"), d.get("text")
            )
            if key in seen_keys:
                continue
            seen_keys.add(key)
            deduped.append(d)
        result = deduped
    except Exception:
        pass
    # V249217: admin feed control. Selected posts can appear first for new/first-view feed.
    try:
        result = _v249217_apply_priority_order(room_id, result)
    except Exception:
        pass
    # V248734: include the real number of Feed comments/messages in every post.
    try:
        channels = ["comment:" + str(d.get("post_id") or d.get("message_id") or "") for d in result]
        channels = [ch for ch in channels if ch != "comment:"]
        counts = {}
        if channels:
            marks = ",".join(["?"] * len(channels))
            query = "SELECT channel, COUNT(*) AS total FROM messages WHERE room_id=? AND channel IN (" + marks + ") GROUP BY channel"
            for cr in rows(query, tuple([room_id] + channels)):
                counts[str(cr["channel"])] = int(cr["total"] or 0)
        for d in result:
            ch = "comment:" + str(d.get("post_id") or d.get("message_id") or "")
            d["comment_count"] = int(counts.get(ch, 0))
    except Exception:
        for d in result:
            d["comment_count"] = int(d.get("comment_count") or 0)
    # V249223: include real heart/like counts from the reactions table in every post.
    # This is additive and safe: if anything fails, the feed still renders with 0 likes.
    try:
        post_ids = [str(d.get("post_id") or d.get("message_id") or "") for d in result]
        post_ids = [x for x in post_ids if x]
        if post_ids:
            marks = ",".join(["?"] * len(post_ids))
            reaction_counts = {}
            for rr in rows("SELECT target_id, COUNT(*) AS n FROM reactions WHERE emoji='❤️' AND target_id IN (" + marks + ") GROUP BY target_id", tuple(post_ids)):
                reaction_counts[str(rr["target_id"])] = int(rr["n"] or 0)
            for d in result:
                pid0 = str(d.get("post_id") or d.get("message_id") or "")
                d["like_count"] = int(reaction_counts.get(pid0, 0))
                d["likes"] = int(reaction_counts.get(pid0, 0))
    except Exception:
        try:
            for d in result:
                d["like_count"] = int(d.get("like_count") or d.get("likes") or 0)
        except Exception:
            pass

    # v2.3.5: ETag support for feed polling. If nothing changed, return 304 with zero body.
    latest = result[0].get("created_at", 0) if result else 0
    oldest = result[-1].get("created_at", 0) if result else 0
    etag_src = json.dumps({
        "room_id": room_id, "visibility": visibility, "limit": int(limit),
        "lite": int(lite), "max_media_chars": int(max_media_chars),
        "viewer_id": viewer_id, "author_id": author_id, "count": len(result), "latest": latest, "oldest": oldest
    }, sort_keys=True, ensure_ascii=False)
    etag = hashlib.md5(etag_src.encode("utf-8")).hexdigest()[:16]
    # V246.1.2 FLOW NO-FLICKER:
    # New frontend calls ?no304=1 and always receives posts.
    # Old internal timers use If-None-Match and now receive JSON not_modified, not an empty 304.
    headers = {
        "ETag": etag,
        "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
        "Pragma": "no-cache",
    }
    try:
        inm = str(request.headers.get("if-none-match") or "").strip()
    except Exception:
        inm = ""
    if not int(no304 or 0) and inm:
        return JSONResponse({"ok": True, "not_modified": True, "posts": [], "lite": bool(lite), "flow_no_flicker_v24612": True}, headers=headers)
    _feed_control_payload = {}
    try:
        _feed_control_payload = _v249217_feed_control_settings(room_id)
        _feed_control_payload["published_video_count"] = _v249217_count_public_videos(room_id)
        _feed_control_payload["version"] = "249217_feed_control"
    except Exception:
        _feed_control_payload = {"version": "249217_feed_control", "error": "settings_unavailable"}
    return JSONResponse({"ok": True, "posts": result, "lite": bool(lite), "feed_control": _feed_control_payload, "flow_restore_v24611": True, "flow_no_flicker_v24612": True}, headers=headers)




# V249295: Clean Feed endpoint. It scans deeper than /api/flow so older valid videos can reappear
# after old poll/system cards are hidden. It does not delete database rows.
def _v249295_parse_feed_text(text: str):
    try:
        obj = json.loads(str(text or ""))
        if isinstance(obj, dict):
            media = obj.get("media") or (obj if obj.get("kind") else None)
            body = str(obj.get("body") or obj.get("description") or obj.get("caption") or obj.get("text") or "")
            return body, media if isinstance(media, dict) else None
    except Exception:
        pass
    return str(text or ""), None

def _v249295_is_poll_or_system_bar(d: dict) -> bool:
    try:
        body, media = _v249295_parse_feed_text(str(d.get("text") or ""))
        kind = str((media or {}).get("kind") or d.get("post_kind") or "").lower()
        if kind == "poll" or "ψηφοφορία" in body.lower():
            return True
        # Hide only old text-only official/system ITHAKI bars. Media from the server stays visible.
        if media:
            return False
        who = (str(d.get("sender_id") or "") + " " + str(d.get("nickname") or "")).lower()
        if any(x in who for x in ["ithaki", "ιθακη", "ok_ithaki", "system", "admin", "athina", "αθηνα", "sokratakis", "σωκρατακης"]):
            return True
    except Exception:
        return False
    return False

@app.get("/api/flow/clean_v249331")
def api_flow_clean_v249331(request: Request, room_id: str = Query(...), visibility: str = Query("public"),
                          limit: int = Query(500, ge=1, le=500),
                          scan_limit: int = Query(1400, ge=50, le=2000),
                          viewer_id: str = Query("")):
    viewer_id = str(viewer_id or "")[:120]
    where = ["room_id=?"]
    params_list = [room_id]
    if visibility != "all":
        where.append("visibility=?")
        params_list.append(visibility)
    sql = "SELECT * FROM flow_posts WHERE " + " AND ".join(where) + " ORDER BY created_at DESC LIMIT ?"
    params_list.append(int(scan_limit))
    result = []
    scanned = 0
    hidden_system = 0
    hidden_missing = 0
    seen_ids = set()
    seen_media = set()
    seen_sig = set()
    for r in rows(sql, tuple(params_list)):
        scanned += 1
        d = dict(r)
        try:
            if viewer_id and d.get("sender_id") and str(d.get("sender_id") or "") != viewer_id and _is_user_blocked_between(room_id, viewer_id, str(d.get("sender_id") or "")):
                continue
        except Exception:
            pass
        try:
            if ithaki_v248703_feed_post_has_missing_media(d):
                hidden_missing += 1
                continue
        except Exception:
            pass
        if _v249295_is_poll_or_system_bar(d):
            hidden_system += 1
            continue
        try:
            pid = str(d.get("post_id") or d.get("message_id") or "")
            if pid and pid in seen_ids:
                continue
            if pid:
                seen_ids.add(pid)
            body, media = _v249295_parse_feed_text(str(d.get("text") or ""))
            media_url = str((media or {}).get("url") or (media or {}).get("file_url") or (media or {}).get("media_url") or (media or {}).get("filename") or "")
            if media_url and media_url in seen_media:
                continue
            if media_url:
                seen_media.add(media_url)
            sig = ithaki_v248713_post_signature(d.get("room_id"), d.get("sender_id"), d.get("post_kind"), d.get("anonymous"), d.get("visibility"), d.get("text"))
            if sig in seen_sig:
                continue
            seen_sig.add(sig)
        except Exception:
            pass
        d["is_encrypted"] = bool(d.get("is_encrypted"))
        d["anonymous"] = bool(d.get("anonymous"))
        d["message_id"] = d.get("post_id")
        try:
            pr = one("SELECT avatar,nickname FROM people WHERE room_id=? AND device_id=? LIMIT 1", (room_id, str(d.get("sender_id") or "")))
            if pr:
                d["avatar"] = str(pr["avatar"] or "")
                if not str(d.get("nickname") or ""):
                    d["nickname"] = str(pr["nickname"] or "")
            d["photo"] = d.get("avatar") or ""
            d["profile_photo"] = d.get("avatar") or ""
        except Exception:
            d["avatar"] = str(d.get("avatar") or "")
        result.append(d)
        if len(result) >= int(limit):
            break
    try:
        post_ids = [str(d.get("post_id") or d.get("message_id") or "") for d in result]
        post_ids = [x for x in post_ids if x]
        if post_ids:
            marks = ",".join(["?"] * len(post_ids))
            channels = ["comment:" + x for x in post_ids]
            comment_counts = {}
            for cr in rows("SELECT channel, COUNT(*) AS total FROM messages WHERE room_id=? AND channel IN (" + ",".join(["?"] * len(channels)) + ") GROUP BY channel", tuple([room_id] + channels)):
                comment_counts[str(cr["channel"])] = int(cr["total"] or 0)
            reaction_counts = {}
            for rr in rows("SELECT target_id, COUNT(*) AS n FROM reactions WHERE emoji='❤️' AND target_id IN (" + marks + ") GROUP BY target_id", tuple(post_ids)):
                reaction_counts[str(rr["target_id"])] = int(rr["n"] or 0)
            for d in result:
                pid0 = str(d.get("post_id") or d.get("message_id") or "")
                d["comment_count"] = int(comment_counts.get("comment:" + pid0, 0))
                d["like_count"] = int(reaction_counts.get(pid0, 0))
                d["likes"] = int(reaction_counts.get(pid0, 0))
    except Exception:
        for d in result:
            d["comment_count"] = int(d.get("comment_count") or 0)
            d["like_count"] = int(d.get("like_count") or d.get("likes") or 0)
    return {"ok": True, "version": "V249331-professional-full-package-clean", "posts": result,
            "scanned": scanned, "returned": len(result), "hidden_system_or_poll": hidden_system,
            "hidden_missing_media": hidden_missing, "lite": False}

@app.get("/api/flow/video_quality_status_v249331")
def api_flow_video_quality_status_v249331(room_id: str = Query(default="ithaki-phone-test")):
    return {
        "ok": True,
        "version": "V249331-professional-full-package-clean",
        "room_id": room_id,
        "feed": "clean_v249331 scans deeper and returns lite=false so valid older videos can appear",
        "video_loading": "metadata first, then progressive auto preload near viewport, full quality when opened/played",
        "note": "No database deletion: old ITHAKI text bars and polls are hidden from live Feed only."
    }


# V249296 — Feed no duplicate toolbar/media overlay diagnostics and clean alias
@app.get("/api/flow/clean_v249296")
def api_flow_clean_v249296(request: Request, room_id: str = Query(...), visibility: str = Query("public"),
                          limit: int = Query(500, ge=1, le=500),
                          scan_limit: int = Query(1800, ge=50, le=2500),
                          viewer_id: str = Query("")):
    data = api_flow_clean_v249331(request=request, room_id=room_id, visibility=visibility, limit=limit,
                                  scan_limit=scan_limit, viewer_id=viewer_id)
    try:
        data["version"] = "V249296-feed-single-toolbar-full-media-comments"
        data["note"] = "Same database, no deletion: returns clean full feed for one-toolbar UI, old videos, and overlay comments."
    except Exception:
        pass
    return data

@app.get("/api/flow/video_quality_status_v249296")
def api_flow_video_quality_status_v249296(room_id: str = Query(default="ithaki-phone-test")):
    return {
        "ok": True,
        "version": "V249296-feed-single-toolbar-full-media-comments",
        "room_id": room_id,
        "feed": "single toolbar guard, duplicate DOM cleanup, full clean endpoint with scan_limit 1800",
        "media": "videos/images displayed object-fit contain, no blur/filter, metadata first and auto preload near viewport",
        "comments": "media preview gets a transparent comments/description overlay without changing feed geometry",
        "note": "No database deletion. Old broken ITHAKI bars/polls are hidden from live Feed only."
    }




# V249297 — Feed one fullscreen post per screen, classic search, no bare bars
def _v249297_is_bare_feed_bar(d: dict) -> bool:
    try:
        body, media = _v249295_parse_feed_text(str(d.get("text") or ""))
        kind = str((media or {}).get("kind") or d.get("post_kind") or "").lower()
        body_low = str(body or "").strip().lower()
        if kind == "poll" or "ψηφοφορία" in body_low or "ψηφοφορια" in body_low:
            return True
        # Keep real media, even if it was uploaded by old versions.
        if media:
            return False
        who = (str(d.get("sender_id") or "") + " " + str(d.get("nickname") or "") + " " + str(d.get("display_name") or "")).lower()
        if any(x in who for x in ["ithaki", "ιθακη", "ok_ithaki", "system", "admin", "athina", "αθηνα", "sokratakis", "σωκρατακης"]):
            return True
        starts = ("σοφά", "σοφα", "χρυσά", "χρυσα", "ροή", "ροη", "κέντρο", "κεντρο", "πέλαγος", "πελαγος", "φάρος", "φαρος", "μήνυμα στο μπουκάλι", "μηνυμα στο μπουκαλι")
        if body_low.startswith(starts):
            return True
        # Old empty icon bars / generated bars without real content.
        icon_count = sum(body_low.count(x) for x in ["❤️", "💬", "🧭", "🌊", "🕯", "🦉", "🆘", "📌", "🔎"])
        if icon_count >= 2 and len(body_low) < 80:
            return True
    except Exception:
        return False
    return False

@app.get("/api/flow/clean_v249297")
def api_flow_clean_v249297(request: Request, room_id: str = Query(...), visibility: str = Query("public"),
                          limit: int = Query(700, ge=1, le=700),
                          scan_limit: int = Query(2500, ge=50, le=2500),
                          viewer_id: str = Query("")):
    data = api_flow_clean_v249296(request=request, room_id=room_id, visibility=visibility,
                                  limit=min(int(limit), 700), scan_limit=min(int(scan_limit), 2500),
                                  viewer_id=viewer_id)
    out = []
    seen_ids = set()
    seen_media = set()
    seen_sig = set()
    hidden_bars = 0
    hidden_dupes = 0
    for d in data.get("posts", []) or []:
        try:
            if _v249297_is_bare_feed_bar(d):
                hidden_bars += 1
                continue
            pid = str(d.get("post_id") or d.get("message_id") or "")
            if pid and pid in seen_ids:
                hidden_dupes += 1
                continue
            if pid:
                seen_ids.add(pid)
            body, media = _v249295_parse_feed_text(str(d.get("text") or ""))
            media_url = str((media or {}).get("url") or (media or {}).get("file_url") or (media or {}).get("media_url") or (media or {}).get("filename") or "")
            if media_url:
                mk = media_url.split("?")[0]
                if mk in seen_media:
                    hidden_dupes += 1
                    continue
                seen_media.add(mk)
            sig = ithaki_v248713_post_signature(d.get("room_id"), d.get("sender_id"), d.get("post_kind"), d.get("anonymous"), d.get("visibility"), d.get("text"))
            if sig in seen_sig:
                hidden_dupes += 1
                continue
            seen_sig.add(sig)
            out.append(d)
            if len(out) >= int(limit):
                break
        except Exception:
            out.append(d)
            if len(out) >= int(limit):
                break
    data["version"] = "V249297-feed-one-screen-classic-search-no-bars"
    data["posts"] = out
    data["returned"] = len(out)
    data["hidden_bare_bars_v249297"] = hidden_bars
    data["hidden_duplicate_v249297"] = hidden_dupes
    data["lite"] = False
    data["note"] = "One full-screen post per screen. No deletion from database: old bare bars/polls are hidden from live Feed only, old videos remain visible when media exists."
    return data

@app.get("/api/flow/video_quality_status_v249297")
def api_flow_video_quality_status_v249297(room_id: str = Query(default="ithaki-phone-test")):
    return {
        "ok": True,
        "version": "V249297-feed-one-screen-classic-search-no-bars",
        "room_id": room_id,
        "feed": "one post per screen, single classic search/filter bar, no duplicate toolbars",
        "media": "old server videos are requested with lite=false; videos/photos display full screen with object-fit contain and no blur",
        "preview": "comments/description are shown as transparent one-line overlay, expandable without changing Feed geometry",
        "note": "No database deletion. Old bare ITHAKI bars and polls are hidden from live Feed only."
    }




# V249298 — Feed member text as golden cards; hide ITHAKI plain bars/polls, keep old media visible

def _v249298_is_poll_or_system_text(d: dict) -> bool:
    try:
        body, media = _v249295_parse_feed_text(str(d.get("text") or ""))
        body_low = (body or str(d.get("text") or "")).lower().strip()
        kind = str((media or {}).get("kind") or d.get("post_kind") or "").lower()
        if kind == "poll" or "ψηφοφο" in body_low or "anonymous poll" in body_low:
            return True
        # Keep all real media, including old server videos/photos/files.
        if media:
            return False
        who = (str(d.get("sender_id") or "") + " " + str(d.get("nickname") or "") + " " + str(d.get("display_name") or "")).lower()
        if any(x in who for x in ["ithaki", "ιθακη", "ok_ithaki", "system", "admin", "athina", "αθηνα", "sokratakis", "σωκρατακης"]):
            return True
        starts = ("σοφά", "σοφα", "χρυσά", "χρυσα", "ροή", "ροη", "κέντρο", "κεντρο", "πέλαγος", "πελαγος", "φάρος", "φαρος", "μήνυμα στο μπουκάλι", "μηνυμα στο μπουκαλι", "δημοσιεύσεις ιθάκης", "δημοσιευσεις ιθακης")
        if body_low.startswith(starts):
            return True
        icon_count = sum(body_low.count(x) for x in ["❤️", "💬", "🧭", "🌊", "🕯", "🦉", "🆘", "📌", "🔎"])
        if icon_count >= 2 and len(body_low) < 110:
            return True
    except Exception:
        return False
    return False

@app.get("/api/flow/clean_v249298")
def api_flow_clean_v249298(request: Request, room_id: str = Query(...), visibility: str = Query("public"),
                          limit: int = Query(900, ge=1, le=900),
                          scan_limit: int = Query(3000, ge=50, le=3000),
                          viewer_id: str = Query("")):
    try:
        data = api_flow_clean_v249297(request=request, room_id=room_id, visibility=visibility,
                                      limit=min(int(limit), 900), scan_limit=min(int(scan_limit), 2500),
                                      viewer_id=viewer_id)
    except Exception:
        data = api_flow(request=request, room_id=room_id, visibility=visibility,
                        limit=min(int(limit), 900), lite=False, hide_polls=True,
                        no304=True, viewer_id=viewer_id)
    out = []
    seen_ids = set()
    seen_media = set()
    seen_sig = set()
    hidden_system = 0
    hidden_dupes = 0
    for d in data.get("posts", []) or []:
        try:
            if _v249298_is_poll_or_system_text(d):
                hidden_system += 1
                continue
            pid = str(d.get("post_id") or d.get("message_id") or "")
            if pid and pid in seen_ids:
                hidden_dupes += 1
                continue
            if pid:
                seen_ids.add(pid)
            body, media = _v249295_parse_feed_text(str(d.get("text") or ""))
            media_url = str((media or {}).get("url") or (media or {}).get("file_url") or (media or {}).get("media_url") or (media or {}).get("filename") or "")
            if media_url:
                mk = media_url.split("?")[0]
                if mk in seen_media:
                    hidden_dupes += 1
                    continue
                seen_media.add(mk)
            sig = ithaki_v248713_post_signature(d.get("room_id"), d.get("sender_id"), d.get("post_kind"), d.get("anonymous"), d.get("visibility"), d.get("text"))
            if sig in seen_sig:
                hidden_dupes += 1
                continue
            seen_sig.add(sig)
            out.append(d)
            if len(out) >= int(limit):
                break
        except Exception:
            out.append(d)
            if len(out) >= int(limit):
                break
    data["version"] = "V249298-feed-gold-text-hide-system-bars"
    data["posts"] = out
    data["returned"] = len(out)
    data["hidden_system_text_poll_bars_v249298"] = hidden_system
    data["hidden_duplicate_v249298"] = hidden_dupes
    data["lite"] = False
    data["note"] = "Member plain text is rendered by the app as medium golden cards. ITHAKI system text bars and polls are hidden from live Feed only. Old media stays visible."
    return data

@app.get("/api/flow/video_quality_status_v249298")
def api_flow_video_quality_status_v249298(room_id: str = Query(default="ithaki-phone-test")):
    return {
        "ok": True,
        "version": "V249298-feed-gold-text-hide-system-bars",
        "room_id": room_id,
        "feed": "one post per full screen, one classic search bar, no plain ITHAKI bars/polls",
        "text_posts": "member text posts render as medium-size golden cards with heart/comments",
        "media": "old server videos/photos are kept visible with lite=false and progressive metadata/full playback",
        "preview": "description/comments overlay stays transparent and one-line, expandable without changing feed geometry"
    }



# V249301 — Feed whitelist endpoint: keep only media posts and real member text for golden cards.
# No database deletion. Old ITHAKI bars/polls remain in DB but do not enter the live feed.
def _v249301_has_real_media(d: dict) -> bool:
    try:
        body, media = _v249295_parse_feed_text(str(d.get("text") or ""))
        if isinstance(media, dict):
            url = str(media.get("url") or media.get("file_url") or media.get("media_url") or media.get("filename") or media.get("name") or "").strip()
            kind = str(media.get("kind") or media.get("mime_type") or media.get("type") or d.get("post_kind") or "").lower()
            if url or any(x in kind for x in ["video", "image", "photo", "audio", "file"]):
                return True
    except Exception:
        pass
    return False

def _v249301_allow_feed_row(d: dict) -> bool:
    try:
        body, media = _v249295_parse_feed_text(str(d.get("text") or ""))
        body_low = (body or str(d.get("text") or "")).lower().strip()
        kind = str((media or {}).get("kind") or d.get("post_kind") or "").lower()
        if kind == "poll" or "ψηφοφο" in body_low or "anonymous poll" in body_low:
            return False
        if _v249301_has_real_media(d):
            return True
        # Text-only is allowed only as real member text. It will render as a golden card.
        who = (str(d.get("sender_id") or "") + " " + str(d.get("nickname") or "") + " " + str(d.get("display_name") or "")).lower()
        if any(x in who for x in ["ithaki", "ιθακη", "ok_ithaki", "system", "admin", "athina", "αθηνα", "sokratakis", "σωκρατακης"]):
            return False
        if not body_low:
            return False
        starts = ("σοφά", "σοφα", "χρυσά", "χρυσα", "ροή", "ροη", "κέντρο", "κεντρο", "πέλαγος", "πελαγος", "φάρος", "φαρος", "μήνυμα στο μπουκάλι", "μηνυμα στο μπουκαλι", "δημοσιεύσεις ιθάκης", "δημοσιευσεις ιθακης")
        if body_low.startswith(starts):
            return False
        icon_count = sum(body_low.count(x) for x in ["❤️", "💬", "🧭", "🌊", "🕯", "🦉", "🆘", "📌", "🔎"])
        if icon_count >= 2 and len(body_low) < 130:
            return False
        return True
    except Exception:
        return False

@app.get("/api/flow/clean_v249301")
def api_flow_clean_v249301(request: Request, room_id: str = Query(...), visibility: str = Query("public"),
                          limit: int = Query(900, ge=1, le=900),
                          scan_limit: int = Query(3500, ge=50, le=3500),
                          viewer_id: str = Query("")):
    try:
        data = api_flow_clean_v249298(request=request, room_id=room_id, visibility=visibility,
                                      limit=min(int(limit), 900), scan_limit=min(int(scan_limit), 3000),
                                      viewer_id=viewer_id)
    except Exception:
        data = api_flow(request=request, room_id=room_id, visibility=visibility,
                        limit=min(int(limit), 900), lite=False, hide_polls=True,
                        no304=True, viewer_id=viewer_id)
    out = []
    seen_ids, seen_media, seen_sig = set(), set(), set()
    hidden_not_allowed = 0
    hidden_dupes = 0
    for d in data.get("posts", []) or []:
        try:
            if not _v249301_allow_feed_row(d):
                hidden_not_allowed += 1
                continue
            pid = str(d.get("post_id") or d.get("message_id") or "")
            if pid and pid in seen_ids:
                hidden_dupes += 1
                continue
            if pid:
                seen_ids.add(pid)
            body, media = _v249295_parse_feed_text(str(d.get("text") or ""))
            media_url = str((media or {}).get("url") or (media or {}).get("file_url") or (media or {}).get("media_url") or (media or {}).get("filename") or "")
            if media_url:
                mk = media_url.split("?")[0]
                if mk in seen_media:
                    hidden_dupes += 1
                    continue
                seen_media.add(mk)
            sig = ithaki_v248713_post_signature(d.get("room_id"), d.get("sender_id"), d.get("post_kind"), d.get("anonymous"), d.get("visibility"), d.get("text"))
            if sig in seen_sig:
                hidden_dupes += 1
                continue
            seen_sig.add(sig)
            out.append(d)
            if len(out) >= int(limit):
                break
        except Exception:
            continue
    data["version"] = "V249301-feed-whitelist-throttle-clean"
    data["posts"] = out
    data["returned"] = len(out)
    data["hidden_not_whitelisted_v249301"] = hidden_not_allowed
    data["hidden_duplicate_v249301"] = hidden_dupes
    data["lite"] = False
    data["note"] = "Whitelist feed: only media posts and member text/golden cards reach the UI. MutationObserver removes old bars. Old DB rows are not deleted."
    return data

@app.get("/api/flow/video_quality_status_v249301")
def api_flow_video_quality_status_v249301(room_id: str = Query(default="ithaki-phone-test")):
    return {
        "ok": True,
        "version": "V249301-feed-whitelist-throttle-clean",
        "room_id": room_id,
        "feed": "Whitelist UI plus MutationObserver: only media posts and golden cards are allowed inside feedList.",
        "performance": "Legacy feed intervals are throttled to one sweep path, reducing repeated work on old phones.",
        "media": "lite=false endpoint keeps old server videos visible; client uses metadata first and full quality when shown/played.",
        "note": "No DB deletion; old bars/polls are hidden from live Feed only."
    }



# V249302 — Final clean Feed: ONLY video posts + golden ITHAKI wisdom cards in UI.
# No database deletion. Photos/files/text/polls/system bars remain in DB but do not enter this clean feed endpoint.
def _v249302_has_real_video(d: dict) -> bool:
    try:
        body, media = _v249295_parse_feed_text(str(d.get("text") or ""))
        media = media if isinstance(media, dict) else {}
        kind = str(media.get("kind") or media.get("mime_type") or media.get("type") or d.get("post_kind") or "").lower()
        url = str(media.get("url") or media.get("file_url") or media.get("media_url") or media.get("filename") or media.get("name") or "").lower().split("?")[0]
        if "video" in kind:
            return True
        if url.endswith((".mp4", ".webm", ".mov", ".m4v", ".3gp", ".mkv", ".avi")):
            return True
    except Exception:
        pass
    return False

@app.get("/api/flow/clean_v249302")
def api_flow_clean_v249302(request: Request, room_id: str = Query(...), visibility: str = Query("public"),
                          limit: int = Query(900, ge=1, le=900),
                          scan_limit: int = Query(4000, ge=50, le=4000),
                          viewer_id: str = Query("")):
    try:
        data = api_flow_clean_v249301(request=request, room_id=room_id, visibility=visibility,
                                      limit=min(int(limit), 900), scan_limit=min(int(scan_limit), 3500),
                                      viewer_id=viewer_id)
    except Exception:
        data = api_flow(request=request, room_id=room_id, visibility=visibility,
                        limit=min(int(limit), 900), lite=False, hide_polls=True,
                        no304=True, viewer_id=viewer_id)
    out = []
    seen_ids, seen_media, seen_sig = set(), set(), set()
    hidden_not_video = 0
    hidden_dupes = 0
    for d in data.get("posts", []) or []:
        try:
            if not _v249302_has_real_video(d):
                hidden_not_video += 1
                continue
            pid = str(d.get("post_id") or d.get("message_id") or "")
            if pid and pid in seen_ids:
                hidden_dupes += 1
                continue
            if pid:
                seen_ids.add(pid)
            body, media = _v249295_parse_feed_text(str(d.get("text") or ""))
            media = media if isinstance(media, dict) else {}
            media_url = str(media.get("url") or media.get("file_url") or media.get("media_url") or media.get("filename") or "")
            if media_url:
                mk = media_url.split("?")[0]
                if mk in seen_media:
                    hidden_dupes += 1
                    continue
                seen_media.add(mk)
            sig = ithaki_v248713_post_signature(d.get("room_id"), d.get("sender_id"), d.get("post_kind"), d.get("anonymous"), d.get("visibility"), d.get("text"))
            if sig in seen_sig:
                hidden_dupes += 1
                continue
            seen_sig.add(sig)
            out.append(d)
            if len(out) >= int(limit):
                break
        except Exception:
            continue
    data["version"] = "V249303-feed-classic-search-video-wisdom-no-plain-ithaki"
    data["posts"] = out
    data["returned"] = len(out)
    data["hidden_not_video_v249302"] = hidden_not_video
    data["hidden_duplicate_v249302"] = hidden_dupes
    data["lite"] = False
    data["note"] = "Final clean Feed: UI shows only video posts plus generated golden ITHAKI wisdom cards. No photos/files/plain text/polls/system bars. No DB deletion."
    return data

@app.get("/api/flow/video_quality_status_v249302")
def api_flow_video_quality_status_v249302(room_id: str = Query(default="ithaki-phone-test")):
    return {
        "ok": True,
        "version": "V249303-feed-classic-search-video-wisdom-no-plain-ithaki",
        "room_id": room_id,
        "feed": "Only videos and golden ITHAKI wisdom cards are allowed in the live Feed.",
        "search": "Full search bar remains inside Feed and filters only the allowed video/wisdom cards.",
        "media": "Video uses lite=false, metadata first, full quality when visible/played.",
        "note": "No database deletion; photos/files/text/polls/system bars are hidden from live Feed only."
    }



# V249304 — Emergency Feed restore: fail-open video scan so Feed is never blank because of over-strict UI whitelist.
def _v249304_video_url_from_any(x, depth: int = 0) -> str:
    try:
        if depth > 4 or x is None:
            return ""
        if isinstance(x, str):
            s = x.strip()
            low = s.lower().split("?")[0]
            if low.startswith("data:video/") or any(low.endswith(ext) for ext in (".mp4", ".webm", ".mov", ".m4v", ".3gp", ".mkv", ".avi")) or "/api/media/" in low:
                return s
            return ""
        if isinstance(x, dict):
            for k in ("url", "file_url", "media_url", "video_url", "src", "href", "filename", "name", "path"):
                u = _v249304_video_url_from_any(x.get(k), depth + 1)
                if u:
                    return u
            for v in x.values():
                u = _v249304_video_url_from_any(v, depth + 1)
                if u:
                    return u
        if isinstance(x, (list, tuple)):
            for v in x:
                u = _v249304_video_url_from_any(v, depth + 1)
                if u:
                    return u
    except Exception:
        pass
    return ""

def _v249304_has_video(d: dict) -> bool:
    try:
        body, media = _v249295_parse_feed_text(str(d.get("text") or ""))
        media = media if isinstance(media, dict) else {}
        kind = (str(media.get("kind") or media.get("mime_type") or media.get("type") or d.get("post_kind") or d.get("mime_type") or "")).lower()
        if "video" in kind:
            return True
        if _v249304_video_url_from_any(media) or _v249304_video_url_from_any(d):
            return True
        txt = str(d.get("text") or "").lower()
        return any(ext in txt for ext in (".mp4", ".webm", ".mov", ".m4v", ".3gp", ".mkv", ".avi", "data:video/"))
    except Exception:
        return False

@app.get("/api/flow/clean_v249304")
def api_flow_clean_v249304(request: Request, room_id: str = Query(...), visibility: str = Query("public"),
                          limit: int = Query(500, ge=1, le=500),
                          scan_limit: int = Query(5000, ge=50, le=5000),
                          viewer_id: str = Query("")):
    viewer_id = str(viewer_id or "")[:120]
    where = ["room_id=?"]
    params_list = [room_id]
    if visibility != "all":
        where.append("visibility=?")
        params_list.append(visibility)
    sql = "SELECT * FROM flow_posts WHERE " + " AND ".join(where) + " ORDER BY created_at DESC LIMIT ?"
    params_list.append(int(scan_limit))
    result = []
    seen_ids, seen_media, seen_sig = set(), set(), set()
    scanned = hidden_not_video = hidden_dupes = hidden_missing = 0
    for r in rows(sql, tuple(params_list)):
        scanned += 1
        d = dict(r)
        try:
            if viewer_id and d.get("sender_id") and str(d.get("sender_id") or "") != viewer_id and _is_user_blocked_between(room_id, viewer_id, str(d.get("sender_id") or "")):
                continue
        except Exception:
            pass
        try:
            if ithaki_v248703_feed_post_has_missing_media(d):
                hidden_missing += 1
                continue
        except Exception:
            pass
        if not _v249304_has_video(d):
            hidden_not_video += 1
            continue
        try:
            pid = str(d.get("post_id") or d.get("message_id") or "")
            if pid and pid in seen_ids:
                hidden_dupes += 1
                continue
            if pid:
                seen_ids.add(pid)
            body, media = _v249295_parse_feed_text(str(d.get("text") or ""))
            media_url = _v249304_video_url_from_any(media) or _v249304_video_url_from_any(d)
            if media_url:
                mk = str(media_url).split("?")[0]
                if mk in seen_media:
                    hidden_dupes += 1
                    continue
                seen_media.add(mk)
            sig = ithaki_v248713_post_signature(d.get("room_id"), d.get("sender_id"), d.get("post_kind"), d.get("anonymous"), d.get("visibility"), d.get("text"))
            if sig in seen_sig:
                hidden_dupes += 1
                continue
            seen_sig.add(sig)
        except Exception:
            pass
        d["is_encrypted"] = bool(d.get("is_encrypted"))
        d["anonymous"] = bool(d.get("anonymous"))
        d["message_id"] = d.get("post_id")
        try:
            pr = one("SELECT avatar,nickname FROM people WHERE room_id=? AND device_id=? LIMIT 1", (room_id, str(d.get("sender_id") or "")))
            if pr:
                d["avatar"] = str(pr["avatar"] or "")
                if not str(d.get("nickname") or ""):
                    d["nickname"] = str(pr["nickname"] or "")
            d["photo"] = d.get("avatar") or ""
            d["profile_photo"] = d.get("avatar") or ""
        except Exception:
            d["avatar"] = str(d.get("avatar") or "")
        result.append(d)
        if len(result) >= int(limit):
            break
    try:
        post_ids = [str(d.get("post_id") or d.get("message_id") or "") for d in result]
        post_ids = [x for x in post_ids if x]
        if post_ids:
            channels = ["comment:" + x for x in post_ids]
            comment_counts = {}
            for cr in rows("SELECT channel, COUNT(*) AS total FROM messages WHERE room_id=? AND channel IN (" + ",".join(["?"] * len(channels)) + ") GROUP BY channel", tuple([room_id] + channels)):
                comment_counts[str(cr["channel"])] = int(cr["total"] or 0)
            marks = ",".join(["?"] * len(post_ids))
            reaction_counts = {}
            for rr in rows("SELECT target_id, COUNT(*) AS n FROM reactions WHERE emoji='❤️' AND target_id IN (" + marks + ") GROUP BY target_id", tuple(post_ids)):
                reaction_counts[str(rr["target_id"])] = int(rr["n"] or 0)
            for d in result:
                pid0 = str(d.get("post_id") or d.get("message_id") or "")
                d["comment_count"] = int(comment_counts.get("comment:" + pid0, 0))
                d["like_count"] = int(reaction_counts.get(pid0, 0))
                d["likes"] = int(reaction_counts.get(pid0, 0))
    except Exception:
        pass
    return {"ok": True, "version": "V249304-emergency-feed-restore-no-blank", "posts": result,
            "scanned": scanned, "returned": len(result), "hidden_not_video": hidden_not_video,
            "hidden_duplicate": hidden_dupes, "hidden_missing_media": hidden_missing, "lite": False,
            "note": "Feed restore: server scans raw flow_posts for real video rows; UI adds golden wisdom cards and never leaves an empty screen."}

@app.get("/api/flow/video_quality_status_v249304")
def api_flow_video_quality_status_v249304(room_id: str = Query(default="ithaki-phone-test")):
    return {"ok": True, "version": "V249304-emergency-feed-restore-no-blank", "room_id": room_id,
            "feed": "Only videos plus golden wisdom cards; client has a fail-open fallback so the Feed is not blank.",
            "media": "Uses lite=false/raw scan, metadata first, full quality when visible/played."}

# ──────────────────────────────────────────────
# Rooms
# ──────────────────────────────────────────────

# ──────────────────────────────────────────────
# V249281 — Professional spaces metadata, pins, reports, subscriptions
# ──────────────────────────────────────────────
def ensure_spaces_v249281_tables():
    init_professional_db()
    execute("""
    CREATE TABLE IF NOT EXISTS space_pins_v249281(
        room_id TEXT NOT NULL,
        subroom_id TEXT NOT NULL,
        message_id TEXT NOT NULL,
        pinned_by TEXT NOT NULL DEFAULT '',
        note TEXT NOT NULL DEFAULT '',
        created_at REAL NOT NULL,
        PRIMARY KEY(room_id,subroom_id,message_id)
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS space_message_reports_v249281(
        report_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL,
        subroom_id TEXT NOT NULL,
        message_id TEXT NOT NULL,
        reporter_id TEXT NOT NULL DEFAULT '',
        reason TEXT NOT NULL DEFAULT '',
        created_at REAL NOT NULL
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS space_subscriptions_v249281(
        room_id TEXT NOT NULL,
        subroom_id TEXT NOT NULL,
        device_id TEXT NOT NULL,
        nickname TEXT NOT NULL DEFAULT '',
        created_at REAL NOT NULL,
        updated_at REAL NOT NULL,
        PRIMARY KEY(room_id,subroom_id,device_id)
    )""")
    try:
        execute("CREATE INDEX IF NOT EXISTS idx_space_pins_v249281 ON space_pins_v249281(room_id,subroom_id,created_at)")
        execute("CREATE INDEX IF NOT EXISTS idx_space_reports_v249281 ON space_message_reports_v249281(room_id,subroom_id,created_at)")
        execute("CREATE INDEX IF NOT EXISTS idx_space_subs_v249281 ON space_subscriptions_v249281(room_id,device_id)")
    except Exception:
        pass

def _space_member_preview_v249281(room_id, subroom_id):
    try:
        return [dict(x) for x in rows("SELECT device_id,nickname,joined_at FROM room_members WHERE room_id=? AND subroom_id=? ORDER BY joined_at DESC LIMIT 12", (room_id, subroom_id))]
    except Exception:
        return []

@app.get("/api/rooms/overview")
def api_rooms_overview_v249281(room_id: str = Query(...), device_id: str = Query(default="")):
    ensure_spaces_v249281_tables()
    seed_default_rooms(room_id)
    out=[]
    for r in rows("SELECT * FROM rooms WHERE room_id=? ORDER BY parent_id IS NOT NULL, created_at ASC", (room_id,)):
        d=dict(r)
        sid=d.get("subroom_id") or ""
        ch="space:"+sid
        mc=one("SELECT COUNT(*) c FROM room_members WHERE room_id=? AND subroom_id=?", (room_id, sid))
        msgc=one("SELECT COUNT(*) c, MAX(created_at) last_activity FROM messages WHERE room_id=? AND channel=?", (room_id, ch))
        pc=one("SELECT COUNT(*) c FROM space_pins_v249281 WHERE room_id=? AND subroom_id=?", (room_id, sid))
        sub=one("SELECT 1 FROM space_subscriptions_v249281 WHERE room_id=? AND subroom_id=? AND device_id=?", (room_id, sid, device_id)) if device_id else None
        d["member_count"]=int(mc["c"]) if mc else 0
        d["message_count"]=int(msgc["c"]) if msgc else 0
        d["last_activity"]=float(msgc["last_activity"] or 0) if msgc else 0
        d["pinned_count"]=int(pc["c"]) if pc else 0
        d["members"]=_space_member_preview_v249281(room_id, sid)
        d["subscribed"]=bool(sub)
        out.append(d)
    out.sort(key=lambda x: (-(x.get("member_count") or 0), -(x.get("last_activity") or x.get("created_at") or 0)))
    return {"ok": True, "rooms": out}

@app.get("/api/rooms/members")
def api_rooms_members_v249281(room_id: str = Query(...), subroom_id: str = Query(...)):
    ensure_spaces_v249281_tables()
    return {"ok": True, "members": _space_member_preview_v249281(room_id, subroom_id)}

@app.post("/api/rooms/subscribe")
def api_rooms_subscribe_v249281(payload: dict):
    ensure_spaces_v249281_tables()
    room_id=str(payload.get("room_id") or "ithaki-phone-test")[:120]
    subroom_id=str(payload.get("subroom_id") or "")[:120]
    device_id=str(payload.get("device_id") or "")[:120]
    nickname=str(payload.get("nickname") or "")[:80]
    enabled=bool(payload.get("enabled", True))
    if not subroom_id or not device_id:
        raise HTTPException(400,"subroom_id and device_id required")
    if enabled:
        execute("INSERT INTO space_subscriptions_v249281(room_id,subroom_id,device_id,nickname,created_at,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(room_id,subroom_id,device_id) DO UPDATE SET nickname=excluded.nickname, updated_at=excluded.updated_at", (room_id,subroom_id,device_id,nickname,now(),now()))
    else:
        execute("DELETE FROM space_subscriptions_v249281 WHERE room_id=? AND subroom_id=? AND device_id=?", (room_id,subroom_id,device_id))
    return {"ok": True, "enabled": enabled}

@app.get("/api/rooms/pins")
def api_rooms_pins_v249281(room_id: str = Query(...), subroom_id: str = Query(...)):
    ensure_spaces_v249281_tables()
    out=[]
    for p in rows("SELECT * FROM space_pins_v249281 WHERE room_id=? AND subroom_id=? ORDER BY created_at DESC LIMIT 5", (room_id, subroom_id)):
        d=dict(p); msg=one("SELECT text,nickname,created_at FROM messages WHERE room_id=? AND channel=? AND message_id=?", (room_id, "space:"+subroom_id, d["message_id"]))
        if msg:
            d["text"]=msg["text"]; d["nickname"]=msg["nickname"]; d["message_created_at"]=msg["created_at"]
        out.append(d)
    return {"ok": True, "pins": out}

@app.post("/api/rooms/pin")
def api_rooms_pin_v249281(payload: dict):
    ensure_spaces_v249281_tables()
    room_id=str(payload.get("room_id") or "ithaki-phone-test")[:120]
    subroom_id=str(payload.get("subroom_id") or "")[:120]
    message_id=str(payload.get("message_id") or "")[:160]
    pinned_by=str(payload.get("pinned_by") or "")[:120]
    note=str(payload.get("note") or "")[:300]
    if not subroom_id or not message_id:
        raise HTTPException(400,"subroom_id and message_id required")
    execute("INSERT INTO space_pins_v249281(room_id,subroom_id,message_id,pinned_by,note,created_at) VALUES(?,?,?,?,?,?) ON CONFLICT(room_id,subroom_id,message_id) DO UPDATE SET note=excluded.note, pinned_by=excluded.pinned_by, created_at=excluded.created_at", (room_id,subroom_id,message_id,pinned_by,note,now()))
    try: ws_notify(room_id)
    except Exception: pass
    return {"ok": True, "pinned": True}

@app.post("/api/rooms/report_message")
def api_rooms_report_message_v249281(payload: dict):
    ensure_spaces_v249281_tables()
    room_id=str(payload.get("room_id") or "ithaki-phone-test")[:120]
    subroom_id=str(payload.get("subroom_id") or "")[:120]
    message_id=str(payload.get("message_id") or "")[:160]
    reporter_id=str(payload.get("reporter_id") or "")[:120]
    reason=str(payload.get("reason") or "")[:600]
    if not subroom_id or not message_id:
        raise HTTPException(400,"subroom_id and message_id required")
    rid=nid("space_report")
    execute("INSERT INTO space_message_reports_v249281(report_id,room_id,subroom_id,message_id,reporter_id,reason,created_at) VALUES(?,?,?,?,?,?,?)", (rid,room_id,subroom_id,message_id,reporter_id,reason,now()))
    return {"ok": True, "report_id": rid}

@app.post("/api/rooms/admin/check")
def api_rooms_admin_check_v249281(payload: dict):
    ensure_spaces_v249281_tables()
    room_id=str(payload.get("room_id") or "ithaki-phone-test")[:120]
    subroom_id=str(payload.get("subroom_id") or "")[:120]
    device_id=str(payload.get("device_id") or "")[:120]
    owner_secret=str(payload.get("owner_secret") or "")[:200]
    r=one("SELECT owner_id,owner_secret FROM rooms WHERE room_id=? AND subroom_id=?", (room_id, subroom_id))
    if not r:
        raise HTTPException(404,"space not found")
    if device_id and device_id==str(r["owner_id"] or ""):
        return {"ok": True, "owner": True}
    if owner_secret and owner_secret==str(r["owner_secret"] or ""):
        return {"ok": True, "owner": False}
    raise HTTPException(403,"bad admin code")

@app.post("/api/rooms/invite_many")
def api_rooms_invite_many_v249281(payload: dict):
    ensure_spaces_v249281_tables()
    room_id=str(payload.get("room_id") or "ithaki-phone-test")[:120]
    subroom_id=str(payload.get("subroom_id") or "")[:120]
    from_id=str(payload.get("from_id") or "")[:120]
    from_name=str(payload.get("from_name") or "")[:80]
    targets=payload.get("targets") or []
    ids=[]
    for t in targets[:200]:
        to_id=str((t or {}).get("to_id") or (t or {}).get("id") or "")[:120]
        to_name=str((t or {}).get("to_name") or (t or {}).get("nickname") or to_id)[:80]
        if not to_id: continue
        inv=nid("space_inv")
        execute("INSERT INTO space_invites(invite_id,room_id,subroom_id,from_id,from_name,to_id,to_name,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)", (inv,room_id,subroom_id,from_id,from_name,to_id,to_name,"pending",now(),now()))
        ids.append(inv)
    return {"ok": True, "count": len(ids), "invite_ids": ids}

@app.post("/api/rooms/create")
def create_room(p: RoomIn):
    init_professional_db()
    seed_default_rooms(p.room_id)
    rid = nid("subroom")
    access = (p.access_type or "public").strip().lower()
    if access not in ("public", "invite", "private_code"):
        access = "public"
    is_private = 1 if access in ("invite", "private_code") else 0
    execute("""
    INSERT INTO rooms(subroom_id,room_id,parent_id,owner_id,title,description,category,access_type,join_code,owner_secret,member_policy,is_private,created_at)
    VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
    """, (rid, p.room_id, p.parent_id.strip() or None, p.owner_id, p.title, p.description, p.category,
          access, (p.join_code or "").strip(), (p.owner_secret or "").strip(), "approve" if access != "public" else "open", is_private, now()))
    # Creator is automatically a member.
    if p.owner_id:
        execute("""
        INSERT INTO room_members(room_id,subroom_id,device_id,nickname,joined_at)
        VALUES(?,?,?,?,?) ON CONFLICT(room_id,subroom_id,device_id) DO UPDATE SET joined_at=excluded.joined_at
        """, (p.room_id, rid, p.owner_id, "creator", now()))
    return {"ok": True, "subroom_id": rid, "access_type": access, "private": bool(is_private)}


@app.get("/api/rooms")
def get_rooms(room_id: str = Query(...)):
    init_professional_db()
    seed_default_rooms(room_id)
    return {"ok": True, "rooms": [dict(r) for r in
        rows("SELECT * FROM rooms WHERE room_id=? ORDER BY parent_id IS NOT NULL, created_at ASC", (room_id,))]}


@app.post("/api/rooms/invite")
def api_rooms_invite(payload: dict):
    init_professional_db()
    room_id=str(payload.get("room_id") or "ithaki-phone-test")[:120]
    subroom_id=str(payload.get("subroom_id") or "")[:120]
    from_id=str(payload.get("from_id") or "")[:120]
    to_id=str(payload.get("to_id") or "")[:120]
    if not subroom_id or not from_id or not to_id:
        raise HTTPException(400, "subroom_id, from_id, to_id required")
    inv=nid("space_inv")
    execute("""
    INSERT INTO space_invites(invite_id,room_id,subroom_id,from_id,from_name,to_id,to_name,status,created_at,updated_at)
    VALUES(?,?,?,?,?,?,?,?,?,?)
    """, (inv,room_id,subroom_id,from_id,str(payload.get("from_name") or "")[:80],to_id,str(payload.get("to_name") or "")[:80],"pending",now(),now()))
    return {"ok":True,"invite_id":inv}


@app.post("/api/rooms/invite/respond")
def api_rooms_invite_respond(payload: dict):
    init_professional_db()
    room_id=str(payload.get("room_id") or "ithaki-phone-test")[:120]
    invite_id=str(payload.get("invite_id") or "")[:120]
    device_id=str(payload.get("device_id") or "")[:120]
    accepted=bool(payload.get("accepted", False))
    inv=one("SELECT * FROM space_invites WHERE room_id=? AND invite_id=? AND to_id=?", (room_id, invite_id, device_id))
    if not inv:
        raise HTTPException(404,"invite not found")
    execute("UPDATE space_invites SET status=?, updated_at=? WHERE invite_id=?", ("accepted" if accepted else "declined", now(), invite_id))
    if accepted:
        execute("""
        INSERT INTO room_members(room_id,subroom_id,device_id,nickname,joined_at)
        VALUES(?,?,?,?,?) ON CONFLICT(room_id,subroom_id,device_id) DO UPDATE SET joined_at=excluded.joined_at
        """, (room_id, inv["subroom_id"], device_id, str(payload.get("nickname") or inv["to_name"] or "member")[:80], now()))
    return {"ok": True, "accepted": accepted, "subroom_id": inv["subroom_id"]}


# ──────────────────────────────────────────────
# Reactions
# ──────────────────────────────────────────────
@app.post("/api/reactions/toggle")
def reaction_toggle(p: ReactionIn):
    existing = one("SELECT 1 FROM reactions WHERE target_id=? AND device_id=? AND emoji=?",
                   (p.target_id, p.device_id, p.emoji))
    if existing:
        execute("DELETE FROM reactions WHERE target_id=? AND device_id=? AND emoji=?",
                (p.target_id, p.device_id, p.emoji))
        active = False
    else:
        execute("INSERT INTO reactions(target_id,device_id,emoji,nickname,created_at) VALUES(?,?,?,?,?)",
                (p.target_id, p.device_id, p.emoji, p.nickname, now()))
        active = True
        # V249225: notify the post owner when somebody gives a heart/like.
        try:
            if str(p.emoji or "") == "❤️":
                post = one("SELECT room_id,sender_id,nickname,anonymous FROM flow_posts WHERE post_id=?", (p.target_id,))
                if post:
                    room_id = str(post["room_id"] or "ithaki-phone-test")
                    owner_id = str(post["sender_id"] or "")
                    liker = str(p.nickname or "Μέλος")[:80]
                    if owner_id and owner_id != str(p.device_id or ""):
                        try:
                            _v249247_add_notification_event(room_id, owner_id, str(p.device_id or ""), liker, "like", "❤️ Νέο like", f"{liker} έκανε like στη δημοσίευσή σας", post_id=str(p.target_id), url=f"https://ok-ithaki.gr/?post={p.target_id}")
                        except Exception:
                            LOG.exception("V249247 like notification event insert failed")
                        push_notify_devices(room_id, [owner_id], "OK ITHAKI — νέα καρδιά", f"{liker} έκανε like στη δημοσίευσή σας", {"kind": "like", "post_id": str(p.target_id), "from_id": str(p.device_id or ""), "room_id": room_id, "url": f"https://ok-ithaki.gr/?post={p.target_id}"})
        except Exception:
            LOG.exception("V249225 like owner notification failed")
    try:
        r = one("SELECT COUNT(*) AS n FROM reactions WHERE target_id=? AND emoji=?", (p.target_id, p.emoji))
        count = int(r["n"] if r else 0)
    except Exception:
        count = 0
    return {"ok": True, "active": active, "count": count, "target_id": p.target_id, "emoji": p.emoji}


@app.get("/api/reactions/list")
def reaction_list(target_id: str = Query(...)):
    result = rows("SELECT emoji, COUNT(*) AS count FROM reactions WHERE target_id=? GROUP BY emoji ORDER BY count DESC",
                  (target_id,))
    return {"ok": True, "target_id": target_id, "reactions": [dict(r) for r in result]}



# ──────────────────────────────────────────────
# Polls / Anonymous human statistics
# ──────────────────────────────────────────────
def _safe_poll_options(options) -> list[str]:
    if isinstance(options, str):
        try:
            options = json.loads(options)
        except Exception:
            options = [x.strip() for x in options.replace(";", "\n").splitlines() if x.strip()]
    if not isinstance(options, list):
        options = []
    out = []
    for x in options:
        v = str(x or "").strip()
        if v and v not in out:
            out.append(v[:220])
    return out[:12]


def _poll_stats(poll_id: str) -> dict:
    p = one("SELECT * FROM polls WHERE poll_id=?", (poll_id,))
    if not p:
        return {}
    opts = _safe_poll_options(p["options_json"])
    counts = {i: 0 for i in range(len(opts))}
    total = 0
    for r in rows("SELECT option_index, COUNT(*) AS c FROM poll_votes WHERE poll_id=? GROUP BY option_index", (poll_id,)):
        i = int(r["option_index"])
        c = int(r["c"])
        if i in counts:
            counts[i] = c
            total += c
    return {
        "poll_id": poll_id,
        "question": p["question"],
        "community_id": p["community_id"],
        "options": [{"index": i, "text": opts[i], "count": counts.get(i, 0), "percent": round((counts.get(i,0)*100.0/total), 1) if total else 0.0} for i in range(len(opts))],
        "total_votes": total,
        "allow_stats": bool(p["allow_stats"]),
        "closed": bool(p["closed"]),
        "created_at": p["created_at"],
    }


@app.post("/api/polls/create")
def api_polls_create(payload: dict):
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    creator_id = str(payload.get("creator_id") or payload.get("sender_id") or "")[:120]
    if is_banned(room_id, creator_id):
        raise HTTPException(403, "member blocked")
    question = str(payload.get("question") or "").strip()[:1000]
    options = _safe_poll_options(payload.get("options") or [])
    if not question:
        raise HTTPException(400, "question required")
    if len(options) < 2:
        raise HTTPException(400, "at least 2 options required")
    community_id = str(payload.get("community_id") or payload.get("community") or "")[:160]
    nickname = str(payload.get("nickname") or "")[:80]
    anonymous = 1 if bool(payload.get("anonymous", True)) else 0
    allow_stats = 1 if bool(payload.get("allow_stats", True)) else 0
    poll_id = nid("poll")
    execute("""
    INSERT INTO polls(poll_id,room_id,community_id,creator_id,nickname,question,options_json,anonymous,allow_stats,created_at,closed)
    VALUES(?,?,?,?,?,?,?,?,?,?,0)
    """, (poll_id, room_id, community_id, creator_id, nickname, question, json.dumps(options, ensure_ascii=False), anonymous, allow_stats, now()))
    # Publish to Feed unless the frontend asks to create a stats-only poll.
    # This prevents duplicate/half poll cards: the frontend either lets this endpoint publish OR sends its own feed post, never both.
    publish_feed = bool(payload.get("publish_feed", payload.get("post_to_feed", True)))
    if publish_feed:
        feed_payload = {
            "kind": "poll", "pollId": poll_id, "title": "🗳️ Ψηφοφορία", "question": question,
            "body": question, "community": community_id, "targetCommunity": community_id,
            "options": options, "allowStats": bool(allow_stats)
        }
        execute("""
        INSERT INTO flow_posts(post_id,room_id,sender_id,nickname,language,text,is_encrypted,iv,cipher_text,post_kind,anonymous,visibility,created_at)
        VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
        """, (nid("flow"), room_id, creator_id, nickname if not anonymous else "Ανώνυμη ψηφοφορία", "el", json.dumps(feed_payload, ensure_ascii=False), 0, "", "", "poll", anonymous, "public", now()))
    return {"ok": True, "poll_id": poll_id, "stats": _poll_stats(poll_id)}


@app.get("/api/polls")
def api_polls(room_id: str = Query("ithaki-phone-test"), community_id: str = Query(""), limit: int = Query(100, ge=1, le=300)):
    seed_default_rooms(room_id)
    params = [room_id]
    sql = "SELECT * FROM polls WHERE room_id=?"
    if community_id:
        sql += " AND community_id=?"
        params.append(community_id)
    sql += " ORDER BY created_at DESC LIMIT ?"
    params.append(limit)
    out=[]
    for p in rows(sql, tuple(params)):
        d=dict(p)
        st=_poll_stats(d["poll_id"])
        d["options"] = st.get("options", [])
        d["total_votes"] = st.get("total_votes", 0)
        d["anonymous"] = bool(d.get("anonymous"))
        d["allow_stats"] = bool(d.get("allow_stats"))
        d["closed"] = bool(d.get("closed"))
        d.pop("options_json", None)
        out.append(d)
    return {"ok": True, "polls": out}


@app.post("/api/polls/vote")
def api_polls_vote(payload: dict):
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    poll_id = str(payload.get("poll_id") or "")[:160]
    device_id = str(payload.get("device_id") or "")[:120]
    option_index = int(payload.get("option_index") or 0)
    if not poll_id or not device_id:
        raise HTTPException(400, "poll_id and device_id required")
    p = one("SELECT * FROM polls WHERE room_id=? AND poll_id=?", (room_id, poll_id))
    if not p:
        raise HTTPException(404, "poll not found")
    if int(p["closed"] or 0):
        raise HTTPException(400, "poll closed")
    opts = _safe_poll_options(p["options_json"])
    if option_index < 0 or option_index >= len(opts):
        raise HTTPException(400, "bad option")
    execute("""
    INSERT INTO poll_votes(poll_id,room_id,device_id,option_index,created_at)
    VALUES(?,?,?,?,?)
    ON CONFLICT(poll_id,device_id) DO UPDATE SET option_index=excluded.option_index, created_at=excluded.created_at
    """, (poll_id, room_id, device_id, option_index, now()))
    return {"ok": True, "stats": _poll_stats(poll_id)}


@app.get("/api/polls/stats")
def api_polls_stats(room_id: str = Query("ithaki-phone-test"), poll_id: str = Query("")):
    if not poll_id:
        raise HTTPException(400, "poll_id required")
    p = one("SELECT poll_id FROM polls WHERE room_id=? AND poll_id=?", (room_id, poll_id))
    if not p:
        raise HTTPException(404, "poll not found")
    return {"ok": True, "stats": _poll_stats(poll_id)}


@app.get("/api/polls/summary")
def api_polls_summary(room_id: str = Query("ithaki-phone-test")):
    seed_default_rooms(room_id)
    total_polls = one("SELECT COUNT(*) AS n FROM polls WHERE room_id=?", (room_id,))["n"]
    total_votes = one("SELECT COUNT(*) AS n FROM poll_votes WHERE room_id=?", (room_id,))["n"]
    by_comm = [dict(r) for r in rows("SELECT community_id, COUNT(*) AS polls FROM polls WHERE room_id=? GROUP BY community_id ORDER BY polls DESC LIMIT 30", (room_id,))]
    top = []
    for p in rows("SELECT poll_id,question,community_id,created_at FROM polls WHERE room_id=? ORDER BY created_at DESC LIMIT 30", (room_id,)):
        st = _poll_stats(p["poll_id"])
        top.append({"poll_id": p["poll_id"], "question": p["question"], "community_id": p["community_id"], "total_votes": st.get("total_votes",0), "created_at": p["created_at"]})
    return {"ok": True, "total_polls": total_polls, "total_votes": total_votes, "by_community": by_comm, "recent": top}




# ──────────────────────────────────────────────
# Mood check-in / Human calm support (anonymous aggregate)
# ──────────────────────────────────────────────
def _safe_mood(mood: str) -> str:
    mood = str(mood or "").strip().lower()
    aliases = {"καλά":"good", "καλα":"good", "good":"good", "neutral":"neutral", "ουδέτερα":"neutral", "ουδετερα":"neutral", "hard":"hard", "δύσκολα":"hard", "δυσκολα":"hard", "very_bad":"very_bad", "bad":"very_bad", "πολύ άσχημα":"very_bad", "πολυ ασχημα":"very_bad"}
    return aliases.get(mood, mood if mood in ("good","neutral","hard","very_bad") else "neutral")

@app.post("/api/mood/checkin")
def api_mood_checkin(payload: dict):
    init_db()
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    device_id = str(payload.get("device_id") or "")[:120]
    nickname = str(payload.get("nickname") or "")[:80]
    mood = _safe_mood(str(payload.get("mood") or "neutral"))
    anonymous = 1 if bool(payload.get("anonymous", True)) else 0
    cid = nid("mood")
    execute("""
    INSERT INTO mood_checkins(checkin_id,room_id,device_id,nickname,mood,anonymous,created_at)
    VALUES(?,?,?,?,?,?,?)
    """, (cid, room_id, device_id, nickname if not anonymous else "", mood, anonymous, now()))
    try:
        execute("""
        INSERT INTO people(room_id,device_id,nickname,language,mood,last_seen)
        VALUES(?,?,?,?,?,?)
        ON CONFLICT(room_id,device_id) DO UPDATE SET nickname=excluded.nickname,mood=excluded.mood,last_seen=excluded.last_seen
        """, (room_id, device_id, nickname, str(payload.get("language") or "el")[:10], mood, now()))
    except Exception:
        pass
    return {"ok": True, "checkin_id": cid, "mood": mood}

@app.get("/api/mood/summary")
def api_mood_summary(room_id: str = Query("ithaki-phone-test")):
    init_db()
    cutoff = now() - 86400
    counts = {"good":0,"neutral":0,"hard":0,"very_bad":0}
    total = 0
    for r in rows("SELECT mood, COUNT(*) AS c FROM mood_checkins WHERE room_id=? AND created_at>=? GROUP BY mood", (room_id, cutoff)):
        m = _safe_mood(r["mood"])
        c = int(r["c"] or 0)
        counts[m] = counts.get(m, 0) + c
        total += c
    online = {"good":0,"neutral":0,"hard":0,"very_bad":0}
    for r in rows("SELECT mood, COUNT(*) AS c FROM people WHERE room_id=? AND last_seen>=? GROUP BY mood", (room_id, now()-180)):
        m = _safe_mood(r["mood"])
        online[m] = online.get(m, 0) + int(r["c"] or 0)
    return {"ok": True, "room_id": room_id, "total": total, "counts": counts, "online": online, "note": "anonymous aggregate only"}


# ──────────────────────────────────────────────
# Reports
# ──────────────────────────────────────────────
@app.post("/api/report")
def report(p: ReportIn):
    security_member_guard(p.room_id, p.reporter_id, "report_member", max_per_minute=8, max_per_hour=40)
    rid = nid("rep")
    execute("""
    INSERT INTO reports(report_id,room_id,reporter_id,target_type,target_id,reason,created_at)
    VALUES(?,?,?,?,?,?,?)
    """, (rid, p.room_id, p.reporter_id, p.target_type, p.target_id, p.reason, now()))
    return {"ok": True, "report_id": rid}


# ──────────────────────────────────────────────
# Translation
# ──────────────────────────────────────────────
@app.post("/api/translate/offline")
def translate_offline(payload: dict):
    # V248742: local ITHAKI dictionary/phrasebook first, Argos only as fallback.
    text = str(payload.get("text", ""))
    src = str(payload.get("source_lang", "auto"))
    dst = str(payload.get("target_lang", "en"))
    try:
        from local_dictionary_service import translate_with_local_db
        local = translate_with_local_db(text, src, dst)
        if str(local.get("translated", "")).strip() and str(local.get("translated")) != text:
            local["provider"] = "ithaki-local-dictionary-v248742"
            return local
    except Exception as exc:
        local_note = str(exc)
    else:
        local_note = "local dictionary no change"
    try:
        from argos_offline_service import translate_offline_argos
        out = translate_offline_argos(text, src, dst)
        if out.get("translated"):
            out["note"] = str(out.get("note", "")) + " | " + local_note
        return out
    except Exception as exc:
        return {"ok": True, "provider": "ithaki-local-original", "note": str(exc) + " | " + local_note, "translated": text, "original": text, "source_lang": src, "target_lang": dst}


@app.get("/api/argos/status")
def argos_status_endpoint():
    try:
        from argos_offline_service import argos_status
        return argos_status()
    except Exception as exc:
        return {"ok": False, "note": str(exc)}


@app.get("/api/bridge/dictionary/status")
def api_bridge_dictionary_status():
    try:
        from local_dictionary_service import dictionary_status
        return dictionary_status()
    except Exception as exc:
        return {"ok": False, "note": str(exc)}


@app.get("/api/bridge/dictionary/unknowns")
def api_bridge_dictionary_unknowns():
    try:
        from local_dictionary_service import UNKNOWN_CSV, DICT_DIR
        DICT_DIR.mkdir(parents=True, exist_ok=True)
        if not UNKNOWN_CSV.exists():
            UNKNOWN_CSV.write_text("created_at,src_lang,dst_lang,term,suggested_translation,kind,context,notes\n", encoding="utf-8-sig")
        lines = UNKNOWN_CSV.read_text(encoding="utf-8-sig", errors="replace").splitlines()
        return {"ok": True, "path": str(UNKNOWN_CSV), "rows": max(0, len(lines)-1), "last": lines[-20:]}
    except Exception as exc:
        return {"ok": False, "note": str(exc)}


@app.get("/api/bridge/dictionary/unknowns.csv")
def api_bridge_dictionary_unknowns_csv():
    try:
        from local_dictionary_service import UNKNOWN_CSV, DICT_DIR
        DICT_DIR.mkdir(parents=True, exist_ok=True)
        if not UNKNOWN_CSV.exists():
            UNKNOWN_CSV.write_text("created_at,src_lang,dst_lang,term,suggested_translation,kind,context,notes\n", encoding="utf-8-sig")
        return FileResponse(str(UNKNOWN_CSV), media_type="text/csv; charset=utf-8", filename="unknown_translation_terms.csv")
    except Exception as exc:
        raise HTTPException(status_code=500, detail=str(exc))


@app.post("/api/bridge/dictionary/add")
def api_bridge_dictionary_add(payload: dict):
    try:
        from local_dictionary_service import add_custom_translation
        return add_custom_translation(
            str(payload.get("source", "")),
            str(payload.get("target", "")),
            str(payload.get("src_lang", "el")),
            str(payload.get("dst_lang", "en")),
            str(payload.get("kind", "word")),
        )
    except Exception as exc:
        return {"ok": False, "note": str(exc)}


@app.get("/api/bridge/legal-sources/status")
def api_bridge_legal_sources_status():
    try:
        from legal_sources_service import init_registry, status
        init_registry()
        return status()
    except Exception as exc:
        return {"ok": False, "note": str(exc)}


@app.post("/api/bridge/legal-sources/export-attributions")
def api_bridge_legal_sources_export_attributions():
    try:
        from legal_sources_service import export_attributions
        return export_attributions()
    except Exception as exc:
        return {"ok": False, "note": str(exc)}


@app.post("/api/bridge/translate")
def api_bridge_translate(payload: dict):
    return bridge_translate(
        str(payload.get("text", "")),
        str(payload.get("source_lang", "auto")),
        str(payload.get("target_lang", "en")),
        bool(payload.get("allow_external", False)),
        str(payload.get("translation_mode", payload.get("mode", "offline"))),
        str(payload.get("external_provider", "auto")),
    )





@app.post("/api/bridge/dictionary/log_unknown")
def api_bridge_dictionary_log_unknown_v223(payload: dict):
    try:
        from local_dictionary_service import log_unknown_terms
        return log_unknown_terms(
            str(payload.get("text", "")),
            str(payload.get("src_lang", payload.get("source_lang", "auto"))),
            str(payload.get("dst_lang", payload.get("target_lang", "en"))),
            str(payload.get("context", "ithaki_host_ui")),
        )
    except Exception as exc:
        return {"ok": False, "note": str(exc)}

@app.get("/api/translation/options")
def api_translation_options():
    return {
        "ok": True,
        "version": VERSION,
        "modes": ["offline", "external", "hybrid"],
        "external_providers": ["auto", "deepl", "google", "libretranslate"],
        "external_configured": {
            "deepl": _external_provider_configured("deepl"),
            "google": _external_provider_configured("google"),
            "libretranslate": _external_provider_configured("libretranslate"),
            "auto": _external_provider_configured("auto"),
            "selected": _choose_external_provider("auto"),
            "config_file": str(TRANSLATION_EXTERNAL_CONFIG_FILE),
        },
        "privacy_note": "External translation is used only when the member/device explicitly allows it from the app UI."
    }


@app.post("/api/translation/external/test")
def api_translation_external_test(payload: dict | None = None):
    payload = payload or {}
    provider = str(payload.get("provider", "auto"))
    text = str(payload.get("text", "Καλημέρα, πώς είσαι;"))
    src = str(payload.get("source_lang", "el"))
    dst = str(payload.get("target_lang", "en"))
    out, used, note = _external_translate(text, src, dst, provider)
    return {
        "ok": bool(out),
        "provider": used,
        "configured": _external_provider_configured(used) if used in ("deepl","google","libretranslate") else _external_provider_configured("auto"),
        "note": note,
        "original": text,
        "translated": out or "",
        "config_file": str(TRANSLATION_EXTERNAL_CONFIG_FILE),
    }


@app.get("/api/translation/model/status")
def api_translation_model_status():
    """Detailed EN/EL offline translation model diagnostics for the app/admin."""
    try:
        from argos_offline_service import argos_status
        status = argos_status()
    except Exception as exc:
        status = {"ok": False, "installed": False, "note": str(exc)}
    return {
        "ok": True,
        "version": VERSION,
        "mode": "user_controlled_offline_external_hybrid_translation",
        "argos": status,
        "fallback": "offline argos + sqlite-local-dictionary + emergency phrasebook",
        "external": "optional explicit user consent only: deepl/google/libretranslate via env vars or server/translation_external_config.json",
        "routes": ["/api/bridge/translate", "/api/translate/offline", "/api/argos/status", "/api/bridge/dictionary/status", "/api/bridge/dictionary/add", "/api/bridge/legal-sources/status", "/api/bridge/legal-sources/export-attributions"],
    }


@app.get("/api/bridge/status")
def api_bridge_status():
    try:
        from argos_offline_service import argos_status
        argos = argos_status()
    except Exception:
        argos = {"installed": False}
    return {"ok": True, "version": VERSION, "argos": argos}




# ──────────────────────────────────────────────
# User profile registry v1.5.7
# ──────────────────────────────────────────────
def init_user_profile_db():
    execute("""
    CREATE TABLE IF NOT EXISTS user_profiles(
        room_id TEXT NOT NULL,
        device_id TEXT NOT NULL,
        nickname TEXT NOT NULL,
        nickname_norm TEXT NOT NULL,
        language TEXT NOT NULL DEFAULT 'el',
        interests TEXT NOT NULL DEFAULT '',
        adult_confirmed INTEGER NOT NULL DEFAULT 0,
        policy_accepted INTEGER NOT NULL DEFAULT 0,
        policy_version TEXT NOT NULL DEFAULT 'v1.5.7',
        ai_host_enabled INTEGER NOT NULL DEFAULT 0,
        first_seen REAL NOT NULL,
        last_seen REAL NOT NULL,
        PRIMARY KEY(room_id, device_id)
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_user_profiles_room_nick ON user_profiles(room_id, nickname_norm)")


def _nick_norm(nickname: str) -> str:
    import re, unicodedata
    s = unicodedata.normalize('NFKC', str(nickname or '')).strip().lower()
    s = re.sub(r'\s+', ' ', s)
    return s



def _nickname_suggestions_v249246(room_id: str, nickname: str, device_id: str = "") -> list[str]:
    """Return a few safe available nickname alternatives for immediate profile UI."""
    import re
    init_user_profile_db()
    base = re.sub(r"\s+", " ", str(nickname or "").strip())[:28]
    if not base:
        base = "Μέλος"
    # Keep the suggestion human-readable and avoid weird punctuation bursts.
    clean = re.sub(r"[^0-9A-Za-zΑ-Ωα-ωΆ-Ώά-ώ _.-]+", "", base).strip() or "Μέλος"
    clean = clean[:24].strip() or "Μέλος"
    candidates = []
    for suffix in (" ΙΘΑΚΗ", "_ok", "_1", "_2", "_3", "_2026", "_φίλος", "_νέο"):
        candidates.append((clean + suffix)[:40].strip())
    # Also add numeric options because they are easy to type on mobile.
    for i in range(2, 12):
        candidates.append((clean + str(i))[:40].strip())
    out=[]; seen=set(); dev=str(device_id or "")
    for c in candidates:
        if len(c) < 2: continue
        key=_nick_norm(c)
        if not key or key in seen: continue
        seen.add(key)
        owner=_profile_nickname_owner(room_id, c)
        if not owner or (dev and str(owner["device_id"]) == dev):
            out.append(c)
        if len(out) >= 5:
            break
    return out

def _profile_greeting(nickname: str) -> str:
    hour = time.localtime().tm_hour
    if 5 <= hour < 12:
        hello = 'Καλημέρα'
    elif 12 <= hour < 18:
        hello = 'Καλησπέρα'
    else:
        hello = 'Καλό βράδυ'
    return f"{hello} {nickname}. Χαίρομαι που μπήκες ξανά στην ΙΘΑΚΗ. Είσαι καλά σήμερα;"


def _profile_get(room_id: str, device_id: str):
    init_user_profile_db()
    return one("SELECT * FROM user_profiles WHERE room_id=? AND device_id=?", (room_id, device_id))


def _profile_nickname_owner(room_id: str, nickname: str):
    init_user_profile_db()
    nn = _nick_norm(nickname)
    if not nn:
        return None
    return one("SELECT * FROM user_profiles WHERE room_id=? AND nickname_norm=?", (room_id, nn))

# ──────────────────────────────────────────────
# Bootstrap / Connection
# ──────────────────────────────────────────────
def _is_localhost_url(u: str) -> bool:
    u = str(u or "").lower()
    return "127.0.0.1" in u or "localhost" in u or "0.0.0.0" in u


def _request_public_origin(request: Request) -> str:
    host = (request.headers.get("x-forwarded-host") or request.headers.get("host") or "").strip()
    if not host:
        return ""
    proto = (request.headers.get("x-forwarded-proto") or request.url.scheme or "http").strip()
    return f"{proto}://{host}".rstrip("/")


def _mobile_safe_bootstrap(request: Request) -> dict:
    b = dict(get_bootstrap())
    origin = _request_public_origin(request)
    mobile_fallback = str(_runtime_config_value("mobile_fallback_url", "http://127.0.0.1:8011") or "").rstrip("/")
    active = str(b.get("active_api_url") or mobile_fallback or "").rstrip("/")
    default = str(b.get("default_api_url") or mobile_fallback or "").rstrip("/")

    # Critical mobile rule:
    # If stored bootstrap says localhost but the request reached us through
    # LAN/ngrok, return the request origin. On a phone, 127.0.0.1 is the phone.
    if origin and not _is_localhost_url(origin):
        if not active or _is_localhost_url(active):
            active = origin
        if not default or _is_localhost_url(default):
            default = active

    b["active_api_url"] = active or default or origin or mobile_fallback
    if _is_localhost_url(b["active_api_url"]) and mobile_fallback:
        b["active_api_url"] = mobile_fallback
    b["default_api_url"] = default or b["active_api_url"] or mobile_fallback
    b["request_api_url"] = origin
    b["mobile_api_url"] = b["active_api_url"] if not _is_localhost_url(b["active_api_url"]) else (origin or mobile_fallback)
    b["mobile_fallback_url"] = mobile_fallback
    b["localhost_warning"] = _is_localhost_url(b.get("active_api_url"))
    b["show_connection_settings_to_user"] = False
    b["allow_user_relay_edit"] = False
    b["auto_login_enabled"] = True
    b["require_name"] = False
    b["require_login"] = False
    return b


@app.get("/api/app/bootstrap")
@app.get("/api/app/connection")
@app.get("/api/bootstrap/v2")
def api_bootstrap(request: Request):
    b = _mobile_safe_bootstrap(request)
    return {
        "ok": True, "app": "OK ITHAKI", "version": VERSION,
        "connection_mode": "server_runtime_bootstrap",
        "provider_mode": os.getenv("ITHAKI_PROVIDER_MODE", "cloudflare_tunnel"),
        "active_api_url":     b["active_api_url"],
        "default_api_url":    b["default_api_url"],
        "request_api_url":    b.get("request_api_url", ""),
        "mobile_api_url":     b.get("mobile_api_url", ""),
        "mobile_fallback_url": b.get("mobile_fallback_url", ""),
        "localhost_warning":  bool(b.get("localhost_warning", False)),
        "production_api_url": b["production_api_url"],
        "public_app_token":   b["public_app_token"],
        "public_display_name": b["public_display_name"],
        "show_connection_settings_to_user": False,
        "allow_user_relay_edit": False,
        "auto_login_enabled": True,
        "require_name": False,
        "require_login": False,
        "require_profile": False,
        "default_room_id": b.get("default_room_id", "ithaki-phone-test"),
        "default_nickname": "Επισκέπτης",
        "auto_profile": {
            "enabled": True,
            "require_name": False,
            "require_login": False,
            "default_room_id": b.get("default_room_id", "ithaki-phone-test"),
            "nickname_policy": "visitor_until_profile_saved",
            "device_id_policy": "cookie_or_client_local_storage",
        },
        "features": {"cost_guard": True, "push_foundation": True, "privacy_erasure": True,
                     "copyright_guard": True, "auto_session": True},
        "mobile_rule": "APK/mobile must use LAN IP or HTTPS/ngrok URL, never 127.0.0.1.",
        "note": "Admin/ngrok secrets are never sent to APK.",
    }


@app.get("/api/app/token_check")
def api_token_check(request: Request):
    token    = request.headers.get("X-ITHAKI-TOKEN", "")
    expected = get_bootstrap().get("public_app_token", "")
    return {"ok": True, "token_received": bool(token),
            "token_matches_public": bool(token and expected and token == expected)}


@app.get("/api/app/auto_session")
def api_auto_session(request: Request, response: Response, room_id: str = Query("ithaki-phone-test")):
    boot = _mobile_safe_bootstrap(request)
    device_id = request.cookies.get("ithaki_device_id", "").strip()
    if not device_id:
        device_id = request.headers.get("X-ITHAKI-DEVICE-ID", "").strip()
    if not device_id:
        device_id = nid("dev")
    # v1.6.4 CLEAN: auto_session must never erase a saved profile name.
    # If this endpoint is called by old/mobile code, prefer the registered profile for this device.
    try:
        init_user_profile_db()
        saved_profile = _profile_get(room_id, device_id)
    except Exception:
        saved_profile = None
    # V249154: visitor/auto_session must NOT create online members.
    # A member appears online only after real profile registration or heartbeat from registered client.
    nickname = str(saved_profile["nickname"]) if saved_profile and saved_profile["nickname"] else "Επισκέπτης"
    language = str(saved_profile["language"]) if saved_profile and saved_profile["language"] else "el"
    if saved_profile and str(saved_profile["nickname"] or "").strip():
        execute("""
        INSERT INTO people(room_id,device_id,nickname,language,mood,last_seen) VALUES(?,?,?,?,?,?)
        ON CONFLICT(room_id,device_id) DO UPDATE SET
          nickname=excluded.nickname, language=excluded.language, mood=excluded.mood, last_seen=excluded.last_seen
        """, (room_id, device_id, nickname, language, "registered", now()))
    try:
        response.set_cookie("ithaki_device_id", device_id, max_age=3600*24*365, httponly=False, samesite="lax")
    except Exception:
        pass
    return {
        "ok": True, "auto_login": True, "require_name": False, "require_login": False,
        "room_id": room_id, "device_id": device_id, "nickname": nickname, "language": language,
        "active_api_url": boot.get("mobile_api_url") or boot.get("active_api_url") or "",
        "public_app_token": boot.get("public_app_token", ""),
        "bootstrap": {
            "active_api_url": boot.get("active_api_url", ""),
            "mobile_api_url": boot.get("mobile_api_url", ""),
            "request_api_url": boot.get("request_api_url", ""),
            "public_display_name": boot.get("public_display_name", "OK ITHAKI"),
        }
    }


@app.get("/api/app/autoload")
def api_app_autoload(request: Request, response: Response, room_id: str = Query("ithaki-phone-test")):
    boot = _mobile_safe_bootstrap(request)
    sess = api_auto_session(request, response, room_id)
    return {"ok": True, "bootstrap": boot, "session": sess}


@app.get("/api/profile/check")
def api_profile_check(room_id: str = Query("ithaki-phone-test"), nickname: str = Query(""), device_id: str = Query("")):
    """Check if a nickname is available before first profile save."""
    init_user_profile_db()
    nickname = str(nickname or "").strip()
    device_id = str(device_id or "").strip()
    if len(nickname) < 2:
        return {"ok": True, "valid": False, "available": False, "exists": False, "message": "Το όνομα πρέπει να έχει τουλάχιστον 2 χαρακτήρες."}
    if len(nickname) > 40:
        return {"ok": True, "valid": False, "available": False, "exists": False, "message": "Το όνομα είναι πολύ μεγάλο."}
    owner = _profile_nickname_owner(room_id, nickname)
    if owner and str(owner["device_id"]) != device_id:
        return {"ok": True, "valid": True, "available": False, "exists": True, "same_device": False,
                "suggestions": _nickname_suggestions_v249246(room_id, nickname, device_id),
                "message": "Αυτό το ψευδώνυμο χρησιμοποιείται ήδη. Διάλεξε άλλο."}
    if owner and str(owner["device_id"]) == device_id:
        return {"ok": True, "valid": True, "available": True, "exists": True, "same_device": True,
                "suggestions": [],
                "message": "Το ψευδώνυμο είναι ήδη δικό σου και θα φορτωθεί αυτόματα."}
    return {"ok": True, "valid": True, "available": True, "exists": False, "same_device": False,
            "suggestions": [],
            "message": "Το ψευδώνυμο είναι διαθέσιμο."}


@app.post("/api/profile/register")
def api_profile_register(payload: dict, request: Request):
    """Register or update a local profile. No real name required."""
    init_user_profile_db()
    room_id = str(payload.get("room_id") or "ithaki-phone-test").strip()
    device_id = str(payload.get("device_id") or request.headers.get("X-ITHAKI-DEVICE-ID") or "").strip()
    if device_id:
        security_member_guard(room_id, device_id, "register_member", max_per_minute=3, max_per_hour=5)
    if not device_id:
        device_id = nid("dev")
    nickname = str(payload.get("nickname") or "").strip()
    language = str(payload.get("language") or "el").strip()[:20] or "el"
    interests = str(payload.get("interests") or "").strip()[:1000]
    adult = bool(payload.get("adult_confirmed", False))
    policy = bool(payload.get("policy_accepted", False))
    ai_host = bool(payload.get("ai_host_enabled", False))
    if len(nickname) < 2:
        raise HTTPException(400, "nickname too short")
    if len(nickname) > 40:
        raise HTTPException(400, "nickname too long")
    if not adult:
        raise HTTPException(400, "18+ confirmation required")
    if not policy:
        raise HTTPException(400, "policy acceptance required")
    old = _profile_get(room_id, device_id)
    nickname_locked = False
    if old and str(old["nickname"] or "").strip():
        old_nick = str(old["nickname"] or "").strip()
        if _nick_norm(old_nick) != _nick_norm(nickname):
            # Once a member name is claimed on a device, keep it stable.
            # The member may still update language/interests/privacy options.
            nickname = old_nick
            nickname_locked = True
    owner = _profile_nickname_owner(room_id, nickname)
    if owner and str(owner["device_id"]) != device_id:
        raise HTTPException(409, "nickname already exists")
    t = now()
    nn = _nick_norm(nickname)
    execute("""
    INSERT INTO user_profiles(room_id,device_id,nickname,nickname_norm,language,interests,adult_confirmed,policy_accepted,policy_version,ai_host_enabled,first_seen,last_seen)
    VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
    ON CONFLICT(room_id,device_id) DO UPDATE SET
      nickname=excluded.nickname, nickname_norm=excluded.nickname_norm, language=excluded.language,
      interests=excluded.interests, adult_confirmed=excluded.adult_confirmed,
      policy_accepted=excluded.policy_accepted, policy_version=excluded.policy_version,
      ai_host_enabled=excluded.ai_host_enabled, last_seen=excluded.last_seen
    """, (room_id, device_id, nickname, nn, language, interests, 1 if adult else 0,
          1 if policy else 0, "v24802", 1 if ai_host else 0,
          float(old["first_seen"]) if old else t, t))
    execute("""
    INSERT INTO people(room_id,device_id,nickname,language,mood,last_seen) VALUES(?,?,?,?,?,?)
    ON CONFLICT(room_id,device_id) DO UPDATE SET
      nickname=excluded.nickname, language=excluded.language, mood=excluded.mood, last_seen=excluded.last_seen
    """, (room_id, device_id, nickname, language, "registered", t))
    return {"ok": True, "registered": True, "profile": {"room_id": room_id, "device_id": device_id, "nickname": nickname,
            "language": language, "interests": interests, "policy_version": "v24802", "ai_host_enabled": ai_host, "nickname_locked": True},
            "nickname_locked": True, "ignored_new_nickname": bool(nickname_locked),
            "greeting": _profile_greeting(nickname), "message": "Το προφίλ αποθηκεύτηκε. Το όνομα μέλους κλειδώνει μετά την πρώτη αποθήκευση· αλλάζουν μόνο οι υπόλοιπες ρυθμίσεις."}


@app.post("/api/profile/delete")
def api_profile_delete(payload: dict):
    """Delete the technical nickname reservation and online row for a device."""
    init_user_profile_db()
    room_id = str(payload.get("room_id") or "ithaki-phone-test").strip()
    device_id = str(payload.get("device_id") or "").strip()
    nickname = str(payload.get("nickname") or "").strip()
    if device_id:
        execute("DELETE FROM user_profiles WHERE room_id=? AND device_id=?", (room_id, device_id))
        execute("DELETE FROM people WHERE room_id=? AND device_id=?", (room_id, device_id))
    elif nickname:
        execute("DELETE FROM user_profiles WHERE room_id=? AND nickname_norm=?", (room_id, _nick_norm(nickname)))
        execute("DELETE FROM people WHERE room_id=? AND nickname=?", (room_id, nickname))
    return {"ok": True, "deleted": True, "message": "Η τεχνική κράτηση προφίλ διαγράφηκε από τον relay server."}


@app.get("/api/profile/me")
def api_profile_me(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    init_user_profile_db()
    r = _profile_get(room_id, device_id)
    if not r:
        return {"ok": True, "exists": False, "profile": None}
    d = dict(r)
    d["adult_confirmed"] = bool(d.get("adult_confirmed"))
    d["policy_accepted"] = bool(d.get("policy_accepted"))
    d["ai_host_enabled"] = bool(d.get("ai_host_enabled"))
    return {"ok": True, "exists": True, "profile": d, "greeting": _profile_greeting(d.get("nickname") or "φίλε")}




# ──────────────────────────────────────────────
# v1.6.7 clean social rebind: feed reactions/comments, contacts, calls, private chat, rooms, local AI
# ──────────────────────────────────────────────
def init_social_v166_db():
    execute("""
    CREATE TABLE IF NOT EXISTS social_events(
        id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL,
        type TEXT NOT NULL,
        from_id TEXT NOT NULL,
        from_name TEXT NOT NULL DEFAULT '',
        to_id TEXT NOT NULL,
        to_name TEXT NOT NULL DEFAULT '',
        status TEXT NOT NULL DEFAULT 'pending',
        created_at REAL NOT NULL,
        updated_at REAL NOT NULL
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_social_events_to ON social_events(room_id,to_id,status,created_at)")
    execute("CREATE INDEX IF NOT EXISTS idx_social_events_from ON social_events(room_id,from_id,created_at)")
    execute("""
    CREATE TABLE IF NOT EXISTS room_members(
        room_id TEXT NOT NULL,
        subroom_id TEXT NOT NULL,
        device_id TEXT NOT NULL,
        nickname TEXT NOT NULL DEFAULT '',
        joined_at REAL NOT NULL,
        PRIMARY KEY(room_id, subroom_id, device_id)
    )""")

    # Professional space columns, safe for older SQLite DBs.
    try:
        add_col("rooms", "access_type", "TEXT NOT NULL DEFAULT 'public'")
        add_col("rooms", "join_code", "TEXT NOT NULL DEFAULT ''")
        add_col("rooms", "owner_secret", "TEXT NOT NULL DEFAULT ''")
        add_col("rooms", "member_policy", "TEXT NOT NULL DEFAULT 'open'")
        add_col("rooms", "is_private", "INTEGER NOT NULL DEFAULT 0")
        add_col("rooms", "blocked_members", "TEXT NOT NULL DEFAULT '[]'")
    except Exception:
        LOG.exception("Could not add professional rooms columns")




@app.post("/api/flow/cleanup_polls_v249286")
def api_flow_cleanup_polls_v249286(payload: dict):
    room_id = str(payload.get("room_id") or "").strip()
    return ithaki_v249286_delete_poll_posts(room_id)

@app.get("/api/flow/video_quality_status_v249286")
def api_flow_video_quality_status_v249286(room_id: str = Query(default="ithaki-phone-test")):
    return {
        "ok": True,
        "version": "V249293-feed-golden-search-video-progressive",
        "video_quality_exists": True,
        "feed_video_mode": "metadata_preload_plus_ffmpeg_server_compression",
        "feed_video_target": "720p_2500k_faststart",
        "ffmpeg_background_compression": bool(VIDEO_BACKGROUND_COMPRESSION),
        "ffmpeg_video_bitrate": FFMPEG_TARGET_VIDEO_BITRATE,
        "ffmpeg_maxrate": FFMPEG_MAXRATE,
        "ffmpeg_fallback_uncompressed_mb": FFMPEG_FALLBACK_UNCOMPRESSED_MB,
        "note_el": "Το σύστημα γρήγορης εμφάνισης/συμπίεσης βίντεο υπάρχει. Η Ροή κρύβει προσωρινά τις ψηφοφορίες και φορτώνει βίντεο με metadata preload."
    }



@app.get("/api/flow/video_quality_status_v249293")
def api_flow_video_quality_status_v249293(room_id: str = Query(default="ithaki-phone-test")):
    """V249293: explicit diagnostic for video compression/progressive feed loading."""
    try:
        ffmpeg_path = ithaki_ffmpeg_path()
    except Exception:
        ffmpeg_path = None
    return {
        "ok": True,
        "version": "V249293-feed-golden-search-video-progressive",
        "room_id": room_id,
        "feed_panels": "only_gold_wisdom_cards_no_polls_no_empty_icon_bars",
        "search_filters": ["all", "video", "photo", "file", "text", "wisdom", "mine", "keywords"],
        "video_quality_exists": True,
        "preview_compression": "server_ffmpeg_after_upload_when_needed",
        "feed_video_target": "720p_2500k_faststart",
        "progressive_display": "metadata_first_lazy_preload_visible_video_full_quality_on_open",
        "ffmpeg_available": bool(ffmpeg_path),
        "ffmpeg_path": str(ffmpeg_path or ""),
        "ffmpeg_background_compression": bool(VIDEO_BACKGROUND_COMPRESSION),
        "ffmpeg_video_bitrate": FFMPEG_TARGET_VIDEO_BITRATE,
        "ffmpeg_maxrate": FFMPEG_MAXRATE,
        "ffmpeg_audio_bitrate": FFMPEG_AUDIO_BITRATE,
        "ffmpeg_preset": FFMPEG_PRESET,
        "ffmpeg_fallback_uncompressed_mb": FFMPEG_FALLBACK_UNCOMPRESSED_MB,
        "note_el": "Η συμπίεση βίντεο υπάρχει. Η προεπισκόπηση δείχνει άμεσα το αρχείο, η αποστολή περνά από server/FFmpeg όταν χρειάζεται, και η Ροή φορτώνει πρώτα metadata/preview για ταχύτερη εμφάνιση."
    }



# V249294: Feed hard restore diagnostics. The frontend now requests full feed posts (lite=0)
# so old/large videos are not stripped from the visible Feed. Polls are still hidden/cleanable.
@app.get("/api/flow/video_quality_status_v249294")
def api_flow_video_quality_status_v249294(room_id: str = Query(default="ithaki-phone-test")):
    ffmpeg = False
    try:
        import shutil as _shutil
        ffmpeg = bool(_shutil.which("ffmpeg"))
    except Exception:
        ffmpeg = False
    try:
        total = one("SELECT COUNT(*) AS c FROM flow_posts WHERE room_id=?", (room_id,))
        poll_count = one("SELECT COUNT(*) AS c FROM flow_posts WHERE room_id=? AND (post_kind IN ('poll','vote','poll_card') OR lower(text) LIKE '%\"kind\":%poll%' OR lower(text) LIKE '%\"poll_id\"%' OR lower(text) LIKE '%pollfeedcard%' OR lower(text) LIKE '%professionalpollcard%')", (room_id,))
        video_like = one("SELECT COUNT(*) AS c FROM flow_posts WHERE room_id=? AND (lower(text) LIKE '%video%' OR post_kind='video')", (room_id,))
    except Exception:
        total = poll_count = video_like = {"c": -1}
    return {
        "ok": True,
        "version": "V249294-feed-hard-restore-full-video",
        "policy": "Feed frontend uses lite=0 full posts, hides polls/broken bars, keeps only golden wisdom panels, and applies progressive video preload.",
        "ffmpeg_available": bool(ffmpeg),
        "room_id": room_id,
        "posts_total": int(total["c"] if total else -1),
        "poll_posts_hidden": int(poll_count["c"] if poll_count else -1),
        "video_posts_estimated": int(video_like["c"] if video_like else -1),
        "video_display": "metadata first, auto preload near viewport, full quality when playing",
        "upload_compression": "server FFmpeg/background compression when available; fallback keeps original if compression is not possible"
    }

@app.post("/api/flow/delete")
def api_flow_delete(payload: dict):
    """Delete a feed item created by the same device. Keeps moderation safe: no public anonymous mass delete."""
    room_id = str(payload.get("room_id") or "ithaki-phone-test").strip()
    post_id = str(payload.get("post_id") or "").strip()
    device_id = str(payload.get("device_id") or "").strip()
    if not post_id or not device_id:
        raise HTTPException(400, "post_id and device_id required")
    r = one("SELECT sender_id FROM flow_posts WHERE room_id=? AND post_id=?", (room_id, post_id))
    if not r:
        return {"ok": True, "deleted": False, "reason": "not_found"}
    if str(r["sender_id"]) != device_id:
        raise HTTPException(403, "Only the original device can delete this post")
    execute("DELETE FROM reactions WHERE target_id=?", (post_id,))
    execute("DELETE FROM flow_posts WHERE room_id=? AND post_id=?", (room_id, post_id))
    return {"ok": True, "deleted": True, "post_id": post_id}


@app.post("/api/rooms/join")
def api_rooms_join(payload: dict):
    init_professional_db()
    room_id = str(payload.get("room_id") or "ithaki-phone-test").strip()
    subroom_id = str(payload.get("subroom_id") or "").strip()
    device_id = str(payload.get("device_id") or "").strip()
    nickname = str(payload.get("nickname") or "Επισκέπτης").strip()[:80]
    join_code = str(payload.get("join_code") or "").strip()
    if not subroom_id or not device_id:
        raise HTTPException(400, "subroom_id and device_id required")
    r = one("SELECT * FROM rooms WHERE room_id=? AND subroom_id=?", (room_id, subroom_id))
    if r:
        access = str(r.get("access_type") or "public")
        if access == "private_code" and str(r.get("join_code") or "") and join_code != str(r.get("join_code") or ""):
            raise HTTPException(403, "private code required")
        if access == "invite":
            ok_inv = one("SELECT 1 FROM space_invites WHERE room_id=? AND subroom_id=? AND to_id=? AND status='accepted'", (room_id, subroom_id, device_id))
            if not ok_inv and device_id != str(r.get("owner_id") or ""):
                raise HTTPException(403, "invite approval required")
    execute("""
    INSERT INTO room_members(room_id,subroom_id,device_id,nickname,joined_at)
    VALUES(?,?,?,?,?)
    ON CONFLICT(room_id,subroom_id,device_id) DO UPDATE SET nickname=excluded.nickname, joined_at=excluded.joined_at
    """, (room_id, subroom_id, device_id, nickname, now()))
    return {"ok": True, "joined": True, "subroom_id": subroom_id}


@app.post("/api/rooms/leave")
def api_rooms_leave_v249312(payload: dict):
    ensure_spaces_v249281_tables()
    room_id = str(payload.get("room_id") or "ithaki-phone-test").strip()
    subroom_id = str(payload.get("subroom_id") or "").strip()
    device_id = str(payload.get("device_id") or "").strip()
    if not subroom_id or not device_id:
        raise HTTPException(400, "subroom_id and device_id required")
    execute("DELETE FROM room_members WHERE room_id=? AND subroom_id=? AND device_id=?", (room_id, subroom_id, device_id))
    execute("DELETE FROM space_subscriptions_v249281 WHERE room_id=? AND subroom_id=? AND device_id=?", (room_id, subroom_id, device_id))
    return {"ok": True, "left": True, "subroom_id": subroom_id}

@app.post("/api/rooms/kick")
def api_rooms_kick_v249312(payload: dict):
    ensure_spaces_v249281_tables()
    room_id = str(payload.get("room_id") or "ithaki-phone-test").strip()
    subroom_id = str(payload.get("subroom_id") or "").strip()
    owner_id = str(payload.get("device_id") or payload.get("owner_id") or "").strip()
    target_id = str(payload.get("target_id") or "").strip()
    if not subroom_id or not owner_id or not target_id:
        raise HTTPException(400, "subroom_id, device_id and target_id required")
    r = one("SELECT owner_id FROM rooms WHERE room_id=? AND subroom_id=?", (room_id, subroom_id))
    if not r:
        raise HTTPException(404, "space not found")
    if str(r.get("owner_id") or "") != owner_id:
        raise HTTPException(403, "only owner can kick")
    execute("DELETE FROM room_members WHERE room_id=? AND subroom_id=? AND device_id=?", (room_id, subroom_id, target_id))
    execute("DELETE FROM space_subscriptions_v249281 WHERE room_id=? AND subroom_id=? AND device_id=?", (room_id, subroom_id, target_id))
    return {"ok": True, "kicked": True, "target_id": target_id, "subroom_id": subroom_id}

@app.post("/api/rooms/rename")
def api_rooms_rename_v249312(payload: dict):
    ensure_spaces_v249281_tables()
    room_id = str(payload.get("room_id") or "ithaki-phone-test").strip()
    subroom_id = str(payload.get("subroom_id") or "").strip()
    owner_id = str(payload.get("device_id") or payload.get("owner_id") or "").strip()
    title = str(payload.get("title") or "").strip()[:120]
    description = str(payload.get("description") or "").strip()[:500]
    if not subroom_id or not owner_id or not title:
        raise HTTPException(400, "subroom_id, device_id and title required")
    r = one("SELECT owner_id FROM rooms WHERE room_id=? AND subroom_id=?", (room_id, subroom_id))
    if not r:
        raise HTTPException(404, "space not found")
    if str(r.get("owner_id") or "") != owner_id:
        raise HTTPException(403, "only owner can rename")
    execute("UPDATE rooms SET title=?, description=? WHERE room_id=? AND subroom_id=?", (title, description, room_id, subroom_id))
    return {"ok": True, "renamed": True, "subroom_id": subroom_id, "title": title, "description": description}


def _social_event(kind: str, payload: dict):
    """Create friendship/contact or call notification event.
    Restores the missing social event implementation used by the PC UI and APK.
    """
    init_social_v166_db()
    kind = "call" if kind == "call" else "contact"
    room_id = str(payload.get("room_id") or "ithaki-phone-test").strip()[:120]
    from_id = str(payload.get("from_id") or "").strip()[:120]
    from_name = str(payload.get("from_name") or "Member").strip()[:80]
    to_id = str(payload.get("to_id") or "").strip()[:120]
    to_name = str(payload.get("to_name") or "Member").strip()[:80]
    if not from_id or not to_id:
        raise HTTPException(400, "from_id and to_id required")
    if from_id == to_id:
        raise HTTPException(400, "cannot send to yourself")
    if kind == "contact":
        try:
            accepted = one("""
                SELECT * FROM social_events
                WHERE room_id=? AND type='contact' AND status='accepted'
                  AND ((from_id=? AND to_id=?) OR (from_id=? AND to_id=?))
                ORDER BY updated_at DESC LIMIT 1
            """, (room_id, from_id, to_id, to_id, from_id))
            if accepted:
                return {"ok": True, "id": accepted["id"], "status": "accepted", "already_friends": True, "duplicate": True}
            reverse_pending = one("""
                SELECT * FROM social_events
                WHERE room_id=? AND type='contact' AND status='pending' AND from_id=? AND to_id=?
                ORDER BY updated_at DESC LIMIT 1
            """, (room_id, to_id, from_id))
            if reverse_pending:
                return {"ok": True, "id": reverse_pending["id"], "status": "pending", "reverse_pending": True, "duplicate": True}
        except Exception:
            LOG.exception("V249247 friend precheck failed")
    try:
        if _is_user_blocked_between(room_id, from_id, to_id):
            raise HTTPException(403, "blocked by member")
    except HTTPException:
        raise
    except Exception:
        pass
    # Keep one active request instead of creating duplicates on repeated taps.
    existing = one("""
        SELECT * FROM social_events
        WHERE room_id=? AND type=? AND from_id=? AND to_id=? AND status IN ('pending','accepted')
        ORDER BY created_at DESC LIMIT 1
    """, (room_id, kind, from_id, to_id))
    if existing:
        return {"ok": True, "id": existing["id"], "status": existing["status"], "duplicate": True}
    eid = nid("soc")
    t = now()
    execute("""
    INSERT INTO social_events(id,room_id,type,from_id,from_name,to_id,to_name,status,created_at,updated_at)
    VALUES(?,?,?,?,?,?,?,?,?,?)
    """, (eid, room_id, kind, from_id, from_name, to_id, to_name, "pending", t, t))
    title = "OK ITHAKI — πρόσκληση φιλίας" if kind == "contact" else "OK ITHAKI — κλήση"
    body = f"{from_name or 'Member'} σας έστειλε {'πρόσκληση φιλίας' if kind == 'contact' else 'ειδοποίηση κλήσης'}"
    push = push_notify_devices(room_id, [to_id], title, body, {"kind": kind, "event_id": eid, "room_id": room_id})
    return {"ok": True, "id": eid, "status": "pending", "type": kind, "push": push}


@app.post("/api/contact/request")
def api_contact_request(payload: dict):
    return _social_event("contact", payload)


@app.post("/api/call/invite")
def api_call_invite(payload: dict):
    return _social_event("call", payload)


def _respond_social(kind: str, payload: dict):
    init_social_v166_db()
    room_id = str(payload.get("room_id") or "ithaki-phone-test").strip()
    eid = str(payload.get("id") or "").strip()
    device_id = str(payload.get("device_id") or "").strip()
    raw_accepted = payload.get("accepted", False)
    if kind == "contact":
        if isinstance(raw_accepted, bool):
            accepted = raw_accepted
        elif isinstance(raw_accepted, (int, float)) and raw_accepted in (0, 1):
            accepted = bool(raw_accepted)
        else:
            accepted_text = str(raw_accepted or "").strip().casefold()
            if accepted_text in {"1", "true", "yes", "accept", "accepted", "approve", "ok"}:
                accepted = True
            elif accepted_text in {"0", "false", "no", "reject", "rejected", "deny", "decline"}:
                accepted = False
            else:
                raise HTTPException(400, "accepted must be a real boolean")
    else:
        accepted = bool(raw_accepted)
    if not eid or not device_id:
        raise HTTPException(400, "id and device_id required")
    r = one("SELECT * FROM social_events WHERE room_id=? AND id=? AND type=?", (room_id, eid, kind))
    if not r:
        raise HTTPException(404, "event not found")
    if kind == "contact" and str(r["to_id"]) != device_id:
        raise HTTPException(403, "only the intended recipient can accept or reject a friend request")
    if kind != "contact" and str(r["to_id"]) != device_id and str(r["from_id"]) != device_id:
        raise HTTPException(403, "not your event")
    status = "accepted" if accepted else "rejected"
    execute("UPDATE social_events SET status=?, updated_at=? WHERE id=?", (status, now(), eid))
    try:
        other_id = str(r["from_id"] if device_id == str(r["to_id"]) else r["to_id"])
        who = str(r["to_name"] if device_id == str(r["to_id"]) else r["from_name"])
        body = f"{who or 'Member'} {'αποδέχτηκε' if accepted else 'απέρριψε'} την {'πρόσκληση φιλίας' if kind == 'contact' else 'κλήση'}"
        push = push_notify_devices(room_id, [other_id], "OK ITHAKI", body, {"kind": kind, "event_id": eid, "status": status, "room_id": room_id})
    except Exception:
        push = {"ok": False, "reason": "push_failed"}
    return {"ok": True, "id": eid, "status": status, "push": push}


@app.post("/api/contact/respond")
def api_contact_respond(payload: dict):
    return _respond_social("contact", payload)


@app.post("/api/call/respond")
def api_call_respond(payload: dict):
    return _respond_social("call", payload)


# ──────────────────────────────────────────────
# V249223 professional social layer: visible likers, follows, friend lists.
# Safe additive routes; does not remove the older contact/friend system.
# ──────────────────────────────────────────────
def init_social_v249223_db():
    init_social_v166_db()
    execute("""
    CREATE TABLE IF NOT EXISTS social_follows_v249223(
        room_id TEXT NOT NULL,
        follower_id TEXT NOT NULL,
        follower_name TEXT NOT NULL DEFAULT '',
        target_id TEXT NOT NULL,
        target_name TEXT NOT NULL DEFAULT '',
        status TEXT NOT NULL DEFAULT 'following',
        created_at REAL NOT NULL,
        updated_at REAL NOT NULL,
        PRIMARY KEY(room_id, follower_id, target_id)
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_social_follow_target_v249223 ON social_follows_v249223(room_id,target_id,status,updated_at)")
    execute("CREATE INDEX IF NOT EXISTS idx_social_follow_follower_v249223 ON social_follows_v249223(room_id,follower_id,status,updated_at)")


def _person_v249223(room_id: str, device_id: str, fallback_name: str = '') -> dict:
    device_id = str(device_id or '')[:120]
    r = one("SELECT device_id,nickname,avatar,last_seen FROM people WHERE room_id=? AND device_id=? LIMIT 1", (room_id, device_id))
    if r:
        return {"device_id": str(r["device_id"]), "nickname": str(r["nickname"] or fallback_name or "Μέλος"), "avatar": str(r["avatar"] or ""), "last_seen": float(r["last_seen"] or 0)}
    return {"device_id": device_id, "nickname": fallback_name or "Μέλος", "avatar": "", "last_seen": 0}


def _friend_rows_v249223(room_id: str, device_id: str) -> list[dict]:
    rs = rows("""
        SELECT * FROM social_events
        WHERE room_id=? AND type='contact' AND status='accepted' AND (from_id=? OR to_id=?)
        ORDER BY updated_at DESC LIMIT 500
    """, (room_id, device_id, device_id))
    out = []
    seen = set()
    for r in rs:
        other_id = str(r["to_id"] if str(r["from_id"]) == str(device_id) else r["from_id"])
        other_name = str(r["to_name"] if str(r["from_id"]) == str(device_id) else r["from_name"])
        if not other_id or other_id in seen:
            continue
        seen.add(other_id)
        out.append(_person_v249223(room_id, other_id, other_name))
    return out


def _are_friends_v249227(room_id: str, a: str, b: str) -> bool:
    """Server-side friendship gate for messages and calls. Accepted contact in either direction is required."""
    try:
        init_social_v249223_db()
        a = str(a or "")[:120]; b = str(b or "")[:120]
        if not a or not b or a == b:
            return False
        r = one("""
            SELECT 1 FROM social_events
            WHERE room_id=? AND type='contact' AND status='accepted'
              AND ((from_id=? AND to_id=?) OR (from_id=? AND to_id=?))
            LIMIT 1
        """, (room_id, a, b, b, a))
        return bool(r)
    except Exception:
        return False

def _dm_peer_from_channel_v249227(channel: str, sender_id: str) -> str:
    try:
        if not str(channel or "").startswith("dm:"):
            return ""
        parts = [x for x in str(channel).split(":")[1:] if x]
        for x in parts:
            if str(x) != str(sender_id):
                return str(x)[:120]
    except Exception:
        pass
    return ""


@app.get("/api/social/likers")
def api_social_likers(room_id: str = Query("ithaki-phone-test"), post_id: str = Query("")):
    init_social_v249223_db()
    post_id = str(post_id or "")[:160]
    if not post_id:
        return {"ok": True, "post_id": post_id, "count": 0, "likers": []}
    rs = rows("SELECT device_id,nickname,created_at FROM reactions WHERE target_id=? AND emoji='❤️' ORDER BY created_at DESC LIMIT 300", (post_id,))
    likers = []
    for r in rs:
        p = _person_v249223(room_id, str(r["device_id"]), str(r["nickname"] or ""))
        p["created_at"] = float(r["created_at"] or 0)
        likers.append(p)
    return {"ok": True, "post_id": post_id, "count": len(likers), "likers": likers}


@app.post("/api/social/follow/toggle")
def api_social_follow_toggle(payload: dict):
    init_social_v249223_db()
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    follower_id = str(payload.get("follower_id") or "")[:120]
    follower_name = str(payload.get("follower_name") or "Μέλος")[:80]
    target_id = str(payload.get("target_id") or "")[:120]
    target_name = str(payload.get("target_name") or "Μέλος")[:80]
    if not follower_id or not target_id:
        raise HTTPException(400, "follower_id and target_id required")
    if follower_id == target_id:
        raise HTTPException(400, "cannot follow yourself")
    existing = one("SELECT status FROM social_follows_v249223 WHERE room_id=? AND follower_id=? AND target_id=?", (room_id, follower_id, target_id))
    t = now()
    if existing and str(existing["status"]) == "following":
        execute("UPDATE social_follows_v249223 SET status='unfollowed', updated_at=? WHERE room_id=? AND follower_id=? AND target_id=?", (t, room_id, follower_id, target_id))
        return {"ok": True, "following": False}
    execute("""
    INSERT INTO social_follows_v249223(room_id,follower_id,follower_name,target_id,target_name,status,created_at,updated_at)
    VALUES(?,?,?,?,?,?,?,?)
    ON CONFLICT(room_id,follower_id,target_id) DO UPDATE SET
      follower_name=excluded.follower_name,target_name=excluded.target_name,status='following',updated_at=excluded.updated_at
    """, (room_id, follower_id, follower_name, target_id, target_name, "following", t, t))
    try:
        push_notify_devices(room_id, [target_id], "OK ITHAKI — νέος ακόλουθος", f"{follower_name or 'Μέλος'} σας ακολουθεί", {"kind": "follow", "from_id": follower_id, "room_id": room_id})
    except Exception:
        pass
    return {"ok": True, "following": True}


@app.get("/api/social/member_lists")
def api_social_member_lists(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    init_social_v249223_db()
    device_id = str(device_id or "")[:120]
    if not device_id:
        return {"ok": True, "followers": [], "following": [], "friends": [], "counts": {"followers": 0, "following": 0, "friends": 0}}
    followers = []
    for r in rows("SELECT follower_id,follower_name,updated_at FROM social_follows_v249223 WHERE room_id=? AND target_id=? AND status='following' ORDER BY updated_at DESC LIMIT 500", (room_id, device_id)):
        followers.append(_person_v249223(room_id, str(r["follower_id"]), str(r["follower_name"] or "")))
    following = []
    for r in rows("SELECT target_id,target_name,updated_at FROM social_follows_v249223 WHERE room_id=? AND follower_id=? AND status='following' ORDER BY updated_at DESC LIMIT 500", (room_id, device_id)):
        following.append(_person_v249223(room_id, str(r["target_id"]), str(r["target_name"] or "")))
    friends = _friend_rows_v249223(room_id, device_id)
    return {"ok": True, "device_id": device_id, "followers": followers, "following": following, "friends": friends, "counts": {"followers": len(followers), "following": len(following), "friends": len(friends)}}


@app.post("/api/social/friend/request")
def api_social_friend_request(payload: dict):
    init_social_v249223_db()
    res = _social_event("contact", payload)
    try:
        room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
        from_id = str(payload.get("from_id") or "")[:120]
        from_name = str(payload.get("from_name") or "Μέλος")[:80]
        to_id = str(payload.get("to_id") or "")[:120]
        to_name = str(payload.get("to_name") or "Μέλος")[:80]
        if from_id and to_id:
            ch = "dm:" + ":".join(sorted([from_id, to_id]))
            event_id = str(res.get("id") or "")
            # V249231: even if the same pending request already exists, make sure the receiver
            # gets a visible DM card and an FCM push.  A small 90s guard prevents accidental spam.
            recent = one("""
                SELECT message_id FROM messages
                WHERE room_id=? AND channel=? AND sender_id=? AND text LIKE ? AND created_at>=?
                ORDER BY created_at DESC LIMIT 1
            """, (room_id, ch, from_id, '%"kind": "friend_request"%', now()-90))
            if not recent:
                mid = nid("msg")
                body = json.dumps({"kind": "friend_request", "event_id": event_id, "body": f"{from_name} ζητά φιλία", "from_id": from_id, "to_id": to_id}, ensure_ascii=False)
                execute("""
                INSERT INTO messages(message_id,room_id,channel,sender_id,nickname,language,text,is_encrypted,iv,cipher_text,created_at)
                VALUES(?,?,?,?,?,?,?,?,?,?,?)
                """, (mid, room_id, ch, from_id, from_name, "el", body, 0, "", "", now()))
            push_notify_devices(room_id, [to_id], "OK ITHAKI — αίτημα φιλίας", f"{from_name} ζητά αποδοχή/απόρριψη", {"kind": "friend_request", "event_id": event_id, "from_id": from_id, "room_id": room_id, "channel": ch, "url": f"https://ok-ithaki.gr/?dm={from_id}&kind=friend_request"})
            try:
                ws_notify(room_id)
            except Exception:
                pass
            res["dm_channel"] = ch
            res["visible_message_card"] = True
            res["fcm_to_closed_app"] = True
    except Exception:
        LOG.exception("V249231 friend request message/push failed")
    return res


@app.post("/api/social/friend/respond")
def api_social_friend_respond(payload: dict):
    init_social_v249223_db()
    return _respond_social("contact", payload)


def notify_followers_post_v249223(room_id: str, sender_id: str, nickname: str, post_id: str, anonymous: bool = False, media_kind: str = ""):
    try:
        init_social_v249223_db()
        if anonymous:
            return {"ok": True, "skipped": "anonymous"}
        rs = rows("SELECT follower_id FROM social_follows_v249223 WHERE room_id=? AND target_id=? AND status='following' AND follower_id<>? LIMIT 500", (room_id, sender_id, sender_id))
        device_ids = [str(r["follower_id"]) for r in rs if str(r["follower_id"] or "")]
        try:
            friend_ids = [str(x.get("device_id") or x.get("id") or "") for x in _friend_rows_v249223(room_id, sender_id)]
            device_ids.extend([x for x in friend_ids if x and x != str(sender_id)])
        except Exception:
            pass
        device_ids = sorted({x for x in device_ids if x and x != str(sender_id)})
        is_video = "video" in str(media_kind or "").lower()
        kind = "video_post" if is_video else "follow_post"
        title = "OK ITHAKI — νέο βίντεο" if is_video else "OK ITHAKI — νέα δημοσίευση"
        body = f"{nickname or 'Μέλος'} ανέβασε {'νέο βίντεο' if is_video else 'κάτι νέο'}"
        for did in device_ids:
            try:
                _v249247_add_notification_event(room_id, did, sender_id, nickname or "Μέλος", kind, title, body, post_id=post_id, url=f"https://ok-ithaki.gr/?post={post_id}")
            except Exception:
                LOG.exception("V249247 post notification event insert failed")
        if not device_ids:
            return {"ok": True, "sent": 0, "reason": "no_followers_or_friends"}
        return push_notify_devices(room_id, device_ids, title, body, {"kind": kind, "post_id": post_id, "room_id": room_id, "url": f"https://ok-ithaki.gr/?post={post_id}"})
    except Exception as exc:
        LOG.exception("notify_followers_post_v249223 failed")
        return {"ok": False, "error": str(exc)}


@app.get("/api/diagnostics/v249223")
def api_diagnostics_v249223(room_id: str = Query("ithaki-phone-test")):
    init_social_v249223_db()
    fcm = _fcm_cfg()
    return {
        "ok": True,
        "version": "V249223-pro-social-safe-buttons-call-sound",
        "fcm_enabled": bool(fcm.get("enabled")),
        "fcm_project_id": str(fcm.get("project_id") or ""),
        "followers": int((one("SELECT COUNT(*) n FROM social_follows_v249223 WHERE room_id=? AND status='following'", (room_id,)) or {"n": 0})["n"]),
        "friend_requests": int((one("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending'", (room_id,)) or {"n": 0})["n"]),
        "accepted_friends": int((one("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='accepted'", (room_id,)) or {"n": 0})["n"]),
        "policy": "Safe patch on V249220: visible likers, followers/following/friends lists, friend request message cards, follower post FCM, professional call screens, call-audio diagnostics."
    }



@app.get("/api/diagnostics/v249224")
def diagnostics_v249224():
    base = diagnostics_v249223() if 'diagnostics_v249223' in globals() else {"ok": True}
    try:
        base = dict(base)
    except Exception:
        base = {"ok": True}
    base.update({
        "version": "V249224-social-placement-thumb-likes-cards",
        "ui_fix": "removed Friend/Like-list from feed video side bar; moved like list to member thumbnails; liker rows show only friend request; wisdom/poll action bars stay visible",
        "safe_base": "V249220/V249223 additive patch"
    })
    return base


@app.get("/api/diagnostics/v249225")
def diagnostics_v249225(room_id: str = Query("ithaki-phone-test")):
    try:
        base = dict(api_diagnostics_v249223(room_id))
    except Exception:
        base = {"ok": True}
    try:
        fcm = _fcm_cfg()
        like_count = int((one("SELECT COUNT(*) n FROM reactions WHERE emoji='❤️'", ()) or {"n": 0})["n"])
        active_tokens = int((one("SELECT COUNT(*) n FROM fcm_tokens WHERE room_id=? AND enabled=1", (room_id,)) or {"n": 0})["n"])
    except Exception:
        fcm, like_count, active_tokens = {}, 0, 0
    base.update({
        "version": "V249225-feed-nojump-thumb-likes-dm-notifications",
        "fcm_enabled": bool(fcm.get("enabled")),
        "fcm_project_id": str(fcm.get("project_id") or ""),
        "active_fcm_tokens": active_tokens,
        "likes_total": like_count,
        "ui_fix": "thumbnail heart counter always visible; liker/follower/following/friend lists show only friend request; DM member list has all/online/friends tabs; feed end no longer jumps to the top; unlock-all feed asks first",
        "notification_fix": "DM push existed; follow push existed; V249225 adds like-to-owner FCM push with post_id deep link"
    })
    return base

@app.get("/api/diagnostics/v249227")
def diagnostics_v249227(room_id: str = Query("ithaki-phone-test")):
    try:
        base = dict(diagnostics_v249225(room_id))
    except Exception:
        base = {"ok": True}
    try:
        active_members = int((one("SELECT COUNT(*) n FROM people WHERE room_id=? AND last_seen>=?", (room_id, now()-180)) or {"n": 0})["n"])
        accepted = int((one("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='accepted'", (room_id,)) or {"n": 0})["n"])
    except Exception:
        active_members, accepted = 0, 0
    base.update({
        "version": "V249227-friend-gate-active-home-dm-calls",
        "active_home_members": active_members,
        "accepted_friends": accepted,
        "friend_gate": "DM and voice/video calls require accepted friendship. Non-friends receive a friend request card with accept/reject first.",
        "notification_deeplink": "FCM message/call notifications include kind/channel/post/dm url data so APK can open the relevant screen.",
        "ui_fix": "Home shows active members only. Messages screen is friend-only for communication."
    })
    return base



@app.get("/api/diagnostics/v249228")
def api_diagnostics_v249228(room_id: str = Query("ithaki-phone-test")):
    try:
        fcm = _fcm_cfg()
        accepted = int((one("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='accepted'", (room_id,)) or {"n": 0})["n"])
        pending = int((one("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending'", (room_id,)) or {"n": 0})["n"])
    except Exception:
        fcm, accepted, pending = {}, 0, 0
    return {
        "ok": True,
        "version": "V249228-contacts-first-plus-calls-friend-requests",
        "fcm_enabled": bool(fcm.get("enabled")),
        "fcm_project_id": str(fcm.get("project_id") or ""),
        "accepted_friends": accepted,
        "pending_friend_requests": pending,
        "ui_fix": "Messages open first as separate friends/contacts screen. + menu has video call and voice call. Non-friends receive friend request with accept/reject before communication.",
        "server_gate": "DM and voice/video calls still require accepted friendship server-side.",
    }

@app.get("/api/video/diagnostics/v249223")
def api_video_diagnostics_v249223(room_id: str = Query("ithaki-phone-test")):
    init_video_call_db()
    cfg = _video_cfg(room_id)
    recent = [dict(r) for r in rows("SELECT session_id,caller_id,callee_id,call_type,status,created_at,updated_at,expires_at FROM video_call_sessions WHERE room_id=? ORDER BY updated_at DESC LIMIT 20", (room_id,))]
    return {"ok": True, "version": "V249223-call-audio-diagnostics", "settings": cfg, "recent_sessions": recent, "note": "If remote voice is silent, press 🔊 Άνοιγμα φωνής inside the call screen and verify Android microphone permission."}


@app.get("/api/notifications")
def api_notifications(room_id: str = Query("ithaki-phone-test"), device_id: str = Query(""), limit: str = Query("50")):
    # V249234: server-side clean notification policy.
    # The notification centre must show ONLY pending friend/contact requests.
    # Accepted/rejected/old social events stay in the database for history, but they are not returned here.
    init_social_v166_db()
    try:
        safe_limit = int(str(limit or "50").strip())
    except Exception:
        safe_limit = 50
    if safe_limit < 1:
        safe_limit = 1
    if safe_limit > 500:
        safe_limit = 500
    device_id = str(device_id or "").strip()[:160]
    if not device_id:
        return {"ok": True, "items": [], "limit": safe_limit, "policy": "pending_friend_requests_only"}
    result = []
    for r in rows("""
        SELECT * FROM social_events
        WHERE room_id=?
          AND type='contact'
          AND status='pending'
          AND (to_id=? OR from_id=?)
        ORDER BY updated_at DESC, created_at DESC
        LIMIT ?
    """, (room_id, device_id, device_id, safe_limit)):
        result.append(dict(r))
    return {"ok": True, "items": result, "limit": safe_limit, "policy": "pending_friend_requests_only"}



@app.get("/api/diagnostics/v249231")
def api_diagnostics_v249231(room_id: str = Query("ithaki-phone-test")):
    try:
        fcm = _fcm_cfg()
        pending = int((one("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending'", (room_id,)) or {"n": 0})["n"])
        accepted = int((one("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='accepted'", (room_id,)) or {"n": 0})["n"])
        cards = int((one("SELECT COUNT(*) n FROM messages WHERE room_id=? AND text LIKE ?", (room_id, '%"kind": "friend_request"%')) or {"n": 0})["n"])
        tokens = int((one("SELECT COUNT(*) n FROM fcm_tokens WHERE room_id=? AND enabled=1", (room_id,)) or {"n": 0})["n"])
    except Exception:
        fcm, pending, accepted, cards, tokens = {}, 0, 0, 0, 0
    return {
        "ok": True,
        "version": "V249231-avatar-full-menu-friend-request-dm-scroll",
        "fcm_enabled": bool(fcm.get("enabled")),
        "fcm_project_id": str(fcm.get("project_id") or ""),
        "active_fcm_tokens": tokens,
        "pending_friend_requests": pending,
        "accepted_friends": accepted,
        "friend_request_message_cards": cards,
        "ui_fix": "Avatar opens full menu with escape arrow everywhere; + menu has escape arrow; Messages first screen shows friends only, active or inactive; notifications screen scrolls.",
        "server_fix": "Non-friend communication creates a visible friend-request DM card and FCM push for closed app, with accept/reject before message/voice/video. Duplicate pending requests are refreshed without spam."
    }

@app.post("/api/ai/local")
def api_ai_local(payload: dict):
    """Offline-safe helper. No external AI call; it only cleans and formats text."""
    text = str(payload.get("text") or "").strip()
    if not text:
        return {"ok": False, "result": ""}
    clean = " ".join(text.split())[:3000]
    if clean:
        clean = clean[0].upper() + clean[1:]
    if clean and clean[-1] not in ".!;;;":
        clean += "."
    result = "EN: " + clean + "\n\nEL: " + clean
    return {"ok": True, "result": result, "mode": "local_no_external_ai"}


@app.get("/api/wisdom")
def api_wisdom(slot: int = Query(0)):
    items = [
        {'author': 'Επίκουρος', 'en': 'Epicurean thought: peace grows when we separate what is necessary from what is only noise.', 'el': 'Επίκουρος: η γαλήνη μεγαλώνει όταν ξεχωρίζουμε το αναγκαίο από τον θόρυβο.'},
        {'author': 'Επίκουρος', 'en': 'Epicurean thought: a small, safe joy today is not small for the soul.', 'el': 'Επίκουρος: μια μικρή ασφαλής χαρά σήμερα δεν είναι μικρή για την ψυχή.'},
        {'author': 'Επίκουρος', 'en': 'Epicurean thought: do not let fear decide the whole day; give it one calm breath first.', 'el': 'Επίκουρος: μην αφήνεις τον φόβο να αποφασίζει όλη τη μέρα· δώσε του πρώτα μια ήρεμη αναπνοή.'},
        {'author': 'Σωκράτης', 'en': 'Socratic thought: begin gently with the question: what do I truly need now?', 'el': 'Σωκράτης: ξεκίνα ήρεμα με την ερώτηση: τι χρειάζομαι αληθινά τώρα;'},
        {'author': 'Σωκράτης', 'en': 'Know yourself before you judge the world.', 'el': 'Γνώρισε τον εαυτό σου πριν κρίνεις τον κόσμο.'},
        {'author': 'Σωκράτης', 'en': 'An examined life becomes more human, one honest question at a time.', 'el': 'Η ζωή γίνεται πιο ανθρώπινη με κάθε ειλικρινή ερώτηση.'},
        {'author': 'Ηράκλειτος', 'en': 'Heraclitean thought: when everything changes, hold the inner measure.', 'el': 'Ηράκλειτος: όταν όλα αλλάζουν, κράτα το εσωτερικό μέτρο.'},
        {'author': 'Ηράκλειτος', 'en': 'Out of tension, a deeper harmony can be born.', 'el': 'Από την ένταση μπορεί να γεννηθεί βαθύτερη αρμονία.'},
        {'author': 'Αριστοτέλης', 'en': 'Aristotelian thought: virtue is trained in small repeated choices.', 'el': 'Αριστοτέλης: η αρετή χτίζεται με μικρές επαναλαμβανόμενες επιλογές.'},
        {'author': 'Πλάτων', 'en': 'Platonic thought: the soul needs both truth and care.', 'el': 'Πλάτων: η ψυχή χρειάζεται και αλήθεια και φροντίδα.'},
        {'author': 'Πυθαγόρας', 'en': 'Pythagorean thought: harmony starts when the inner strings are tuned.', 'el': 'Πυθαγόρας: η αρμονία αρχίζει όταν κουρδίζονται οι εσωτερικές χορδές.'},
        {'author': 'Δημόκριτος', 'en': 'Democritean thought: cheerfulness is a quiet strength, not a loud show.', 'el': 'Δημόκριτος: η ευθυμία είναι ήρεμη δύναμη, όχι θόρυβος.'},
        {'author': 'Stoics', 'en': 'Stoic thought: I cannot command everything, but I can choose my next honest action.', 'el': 'Stoics: δεν ελέγχω τα πάντα, αλλά μπορώ να διαλέξω την επόμενη τίμια πράξη.'},
        {'author': 'Επίκτητος', 'en': 'Epictetan thought: make a small space between what happened and how you answer.', 'el': 'Επίκτητος: άφησε λίγο χώρο ανάμεσα σε αυτό που έγινε και στην απάντησή σου.'},
        {'author': 'Μάρκος Αυρήλιος', 'en': 'Marcus Aurelius: return to the present task; that is often enough.', 'el': 'Μάρκος Αυρήλιος: γύρνα στο παρόν έργο· πολλές φορές αυτό αρκεί.'},
        {'author': 'Κυνικοί', 'en': 'Cynic thought: freedom begins when we need less to remain ourselves.', 'el': 'Κυνικοί: η ελευθερία αρχίζει όταν χρειαζόμαστε λιγότερα για να είμαστε ο εαυτός μας.'},
        {'author': 'Λάο Τσε', 'en': 'Lao Tzu: soft water changes hard stone by staying true to its way.', 'el': 'Λάο Τσε: το μαλακό νερό αλλάζει τη σκληρή πέτρα μένοντας στον δρόμο του.'},
        {'author': 'Βούδας', 'en': 'Buddhist thought: breathe, notice, and let the storm pass without becoming the storm.', 'el': 'Βουδιστική σκέψη: ανάπνευσε, παρατήρησε, και άσε την καταιγίδα να περάσει χωρίς να γίνεις καταιγίδα.'},
        {'author': 'Κομφούκιος', 'en': 'Confucian thought: humanity begins in the way we treat the person in front of us.', 'el': 'Κομφούκιος: η ανθρωπιά αρχίζει από τον τρόπο που φερόμαστε στον άνθρωπο μπροστά μας.'},
        {'author': 'Ζεν', 'en': 'Zen thought: one clean breath can open a clean beginning.', 'el': 'Ζεν: μια καθαρή αναπνοή μπορεί να ανοίξει μια καθαρή αρχή.'},
        {'author': 'Καζαντζάκης', 'en': 'Greek thought: give value to yourself, so you can give value to the world.', 'el': 'Δώσε αξία στον εαυτό σου, για να μπορείς να δώσεις αξία και στον κόσμο.'},
        {'author': 'Σεφέρης', 'en': 'Modern Greek thought: where light is wounded, keep a small lamp inside.', 'el': 'Εκεί που πληγώνεται το φως, κράτα μέσα σου ένα μικρό λυχνάρι.'},
        {'author': 'Ελύτης', 'en': 'Modern Greek thought: beauty is a form of resistance against the dark.', 'el': 'Η ομορφιά είναι μια μορφή αντίστασης απέναντι στο σκοτάδι.'},
        {'author': 'Ρήγας', 'en': 'Freedom is not noise; it is dignity in action.', 'el': 'Η ελευθερία δεν είναι θόρυβος· είναι αξιοπρέπεια σε πράξη.'},
        {'author': 'Υπατία', 'en': 'Hypatia-inspired thought: knowledge becomes humane when it is joined with courage.', 'el': 'Υπατία: η γνώση γίνεται ανθρώπινη όταν ενώνεται με θάρρος.'},
        {'author': 'Ανώνυμη ΙΘΑΚΗ', 'en': 'A human question is sometimes worth more than a perfect answer.', 'el': 'Μια ανθρώπινη ερώτηση αξίζει καμιά φορά περισσότερο από μια τέλεια απάντηση.'},
        {'author': 'Ανώνυμη ΙΘΑΚΗ', 'en': 'Nature is not a warehouse; it is our shared home.', 'el': 'Η φύση δεν είναι αποθήκη· είναι κοινό μας σπίτι.'},
        {'author': 'Ανώνυμη ΙΘΑΚΗ', 'en': 'Justice is not revenge; it is balance restored.', 'el': 'Η δικαιοσύνη δεν είναι εκδίκηση· είναι ισορροπία που ξαναβρίσκεται.'},
        {'author': 'Ανώνυμη ΙΘΑΚΗ', 'en': 'Work should not consume the soul that performs it.', 'el': 'Η δουλειά δεν πρέπει να καταπίνει την ψυχή αυτού που τη κάνει.'},
        {'author': 'Ανώνυμη ΙΘΑΚΗ', 'en': 'A community becomes human when it listens before it judges.', 'el': 'Μια κοινότητα γίνεται ανθρώπινη όταν ακούει πριν κρίνει.'}
    ]
    return {"ok": True, "item": items[int(slot) % len(items)], "changes_every_seconds": 600}


# ──────────────────────────────────────────────
# Mobile diagnostics
# ──────────────────────────────────────────────
@app.get("/api/mobile/status")
def api_mobile_status(request: Request, room_id: str = Query("ithaki-phone-test")):
    try:
        cfg   = _cost_guard_cfg(room_id)
        usage = _cost_guard_usage(room_id)
        boot  = _mobile_safe_bootstrap(request)
        return {
            "ok": True, "version": VERSION, "connected": True,
            "https": str(boot.get("active_api_url","")).startswith("https://"),
            "active_api_url": boot.get("active_api_url",""),
            "protection": {
                "guard_enabled":      bool(cfg.get("enabled", True)),
                "max_upload_mb":      cfg.get("max_upload_mb", 500),
                "video_may_be_limited": bool(cfg.get("emergency_disable_video", True)),
                "readonly":           bool(cfg.get("emergency_readonly", False)),
            },
            "public_usage": {"online_users": usage["online_users"], "server_today_mb": usage["total_mb"]},
        }
    except Exception:
        LOG.exception("mobile status failed")
        return {"ok": False, "version": VERSION, "connected": False}


@app.get("/api/mobile/connect_diagnostics")
def api_mobile_diagnostics(request: Request, room_id: str = Query("ithaki-phone-test")):
    boot   = _mobile_safe_bootstrap(request)
    origin = boot.get("request_api_url", "")
    active = str(boot.get("mobile_api_url") or boot.get("active_api_url") or "")
    warnings = []
    if "127.0.0.1" in active or "localhost" in active:
        warnings.append("Το active_api_url δείχνει localhost. Σε κινητό αυτό δεν δείχνει τον server.")
    if active.startswith("http://") and "127.0.0.1" not in active and "localhost" not in active:
        warnings.append("HTTP σε κινητό είναι μόνο για LAN/debug. Για production θέλει HTTPS.")
    return {
        "ok": True, "version": VERSION,
        "server_origin": origin, "active_api_url": active,
        "public_token_present": bool(boot.get("public_app_token")),
        "warnings": warnings,
        "mobile_rule": "Για κινητό βάλε LAN IP π.χ. http://192.168.1.X:8011 ή HTTPS domain. Όχι 127.0.0.1.",
        "recommended_for_this_server": origin,
    }


@app.get("/api/mobile/pair_info")
def api_mobile_pair_info(request: Request, room_id: str = Query("ithaki-phone-test")):
    boot   = _mobile_safe_bootstrap(request)
    origin = boot.get("request_api_url", "")
    active = str(boot.get("mobile_api_url") or boot.get("active_api_url") or "")
    return {
        "ok": True, "version": VERSION,
        "server_origin": origin, "active_api_url": active,
        "mobile_ok": "127.0.0.1" not in active and "localhost" not in active,
        "rule": "Mobile APK must use LAN IP or HTTPS domain.",
        "example": "http://192.168.1.35:8011",
    }


# ──────────────────────────────────────────────
# Seed API
# ──────────────────────────────────────────────
@app.get("/api/seed_auto/status")
def api_seed_status(room_id: str = "ithaki-phone-test"):
    init_seed_db()
    data = []
    for c in _SEED_COMMUNITIES:
        st = _seed_setting(room_id, c)
        data.append({
            "community_id": c["id"], "title": c["title"],
            "enabled": bool(st["enabled"]), "max_daily": int(st["max_daily"]),
            "today_count": _seed_daily_count(room_id, c["id"]),
        })
    return {"ok": True, "room_id": room_id, "communities": data}


@app.post("/api/seed_auto/settings")
def api_seed_settings(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    init_seed_db()
    room_id      = str(payload.get("room_id") or "ithaki-phone-test")
    community_id = str(payload.get("community_id") or "")
    if community_id not in [c["id"] for c in _SEED_COMMUNITIES]:
        raise HTTPException(400, "unknown community")
    execute("""
    INSERT INTO seed_auto_settings(room_id,community_id,enabled,max_daily,include_images,rss_source,updated_at)
    VALUES(?,?,?,?,?,?,?)
    ON CONFLICT(room_id,community_id) DO UPDATE SET
      enabled=excluded.enabled, max_daily=excluded.max_daily,
      include_images=excluded.include_images, rss_source=excluded.rss_source, updated_at=excluded.updated_at
    """, (room_id, community_id,
          1 if payload.get("enabled", True) else 0,
          max(0, min(int(payload.get("max_daily", 3)), 20)),
          1 if payload.get("include_images", True) else 0,
          str(payload.get("rss_source") or "nasa")[:80],
          now()))
    return {"ok": True}


@app.post("/api/seed_auto/run")
def api_seed_run(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    ok, secs_left = app_state_claim_cooldown("seed_manual_last_ms", 60_000)
    if not ok:
        return {"ok": False, "guard": "cooldown", "seconds_left": secs_left}
    return seed_run_once(str(payload.get("room_id") or "ithaki-phone-test"),
                         force=bool(payload.get("force", False)))


@app.post("/api/seed_auto/cron")
def api_seed_cron(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    room_id      = str(payload.get("room_id") or "ithaki-phone-test")
    min_interval = int(_SETTINGS.seed_cron_min_interval_seconds)
    init_seed_db()
    row = one("SELECT last_run FROM seed_scheduler_state WHERE job_name='auto_seed'")
    if row and now() - float(row["last_run"]) < min_interval:
        return {"ok": False, "guard": "min_interval",
                "seconds_left": int(min_interval - (now() - float(row["last_run"])))}
    locked = False
    try:
        _conn.execute("BEGIN IMMEDIATE")
        r = _conn.execute("SELECT locked_until FROM seed_scheduler_state WHERE job_name='auto_seed'").fetchone()
        if r and float(r["locked_until"]) > now():
            _conn.rollback()
            return {"ok": False, "guard": "locked"}
        until = now() + 240
        _conn.execute("""
        INSERT INTO seed_scheduler_state(job_name,locked_until,last_run,run_count,last_result)
        VALUES('auto_seed',?,0,0,'')
        ON CONFLICT(job_name) DO UPDATE SET locked_until=excluded.locked_until
        """, (until,))
        _conn.commit()
        locked = True
    except Exception:
        try: _conn.rollback()
        except Exception: pass
        raise
    try:
        result = seed_run_once(room_id)
        execute("""
        UPDATE seed_scheduler_state
        SET locked_until=0, last_run=?, run_count=run_count+1, last_result=?
        WHERE job_name='auto_seed'
        """, (now(), json.dumps(result, ensure_ascii=False)[:5000]))
        return {"ok": True, "result": result}
    except Exception as exc:
        execute("UPDATE seed_scheduler_state SET locked_until=0 WHERE job_name='auto_seed'")
        return {"ok": False, "error": str(exc)}


@app.get("/api/seeds/rss/sources")
def api_rss_sources():
    return {"ok": True, "sources": _RSS_SOURCES}


# ──────────────────────────────────────────────
# Admin API
# ──────────────────────────────────────────────
@app.get("/api/admin/health")
def api_admin_health(admin_key: str = Query("")):
    _require_admin(admin_key)
    init_admin_db()
    return {
        "ok": True, "app": APP_NAME, "version": VERSION, "time": now(),
        "db_path": str(DB_PATH),
        "online_people": one("SELECT COUNT(*) n FROM people WHERE device_id NOT IN ('ithaki_auto_seed','ithaki_auto_host','ithaki_host') AND lower(device_id) NOT LIKE 'demo%' AND lower(nickname) NOT LIKE 'demo%'")["n"],
        "messages":      one("SELECT COUNT(*) n FROM messages")["n"],
        "flow_posts":    one("SELECT COUNT(*) n FROM flow_posts")["n"],
        "bans_active":   one("SELECT COUNT(*) n FROM admin_bans WHERE active=1")["n"],
        "seed_thread":   bool(_SEED_THREAD_STARTED),
    }


@app.get("/api/admin/config")
def api_admin_config(admin_key: str = Query("")):
    _require_admin(admin_key)
    st = get_settings()
    return {
        "ok": True,
        "static_settings": {
            "admin_key": "***", "public_api_url": st.public_api_url,
            "production_api_url": st.production_api_url, "public_app_token": "***",
            "public_display_name": st.public_display_name,
            "seed_max_daily": st.seed_max_daily,
            "enable_internal_seed_thread": st.enable_internal_seed_thread,
        },
        "runtime_config": get_bootstrap(),
    }


@app.post("/api/admin/config/runtime")
def api_admin_config_runtime(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    save_runtime_config({k: v for k, v in {
        "ngrok_token": payload.get("ngrok_token"),
        "active_api_url": payload.get("active_api_url"),
        "public_display_name": payload.get("public_display_name"),
        "maintenance_message": payload.get("maintenance_message"),
        "seed_scheduler_enabled": payload.get("seed_scheduler_enabled"),
        "updated_at": now(),
    }.items() if v is not None})
    return {"ok": True, "bootstrap": get_bootstrap()}


@app.post("/api/admin/app_connection/save")
def api_admin_connection_save(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    save_bootstrap({
        "active_api_url":     str(payload.get("active_api_url") or "http://127.0.0.1:8011").strip(),
        "default_api_url":    str(payload.get("default_api_url") or "http://127.0.0.1:8011").strip(),
        "production_api_url": str(payload.get("production_api_url") or "https://api.ok-ithaki.gr").strip(),
        "public_app_token":   str(payload.get("public_app_token") or "OKITHAKI_PUBLIC_BETA_TOKEN_SERVER_ONLY_2026").strip(),
        "public_display_name": str(payload.get("public_display_name") or "OK ITHAKI").strip(),
        "auto_login_enabled": True,
        "require_name": False,
        "require_login": False,
    })
    return {"ok": True, "config": get_bootstrap()}


@app.get("/api/admin/app_connection/current")
def api_admin_connection_current(admin_key: str = Query("")):
    _require_admin(admin_key)
    return {"ok": True, "config": get_bootstrap()}


@app.get("/api/admin/bans")
def api_admin_bans(admin_key: str = Query(""), room_id: str = Query("ithaki-phone-test")):
    _require_admin(admin_key)
    init_admin_db()
    return {"ok": True, "bans": [dict(r) for r in
        rows("SELECT * FROM admin_bans WHERE room_id=? AND lower(device_id) NOT LIKE 'demo%' AND lower(nickname) NOT LIKE 'demo%' ORDER BY created_at DESC", (room_id,))]}


@app.post("/api/admin/ban")
def api_admin_ban(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    init_admin_db()
    bid = nid("ban")
    execute("""
    INSERT INTO admin_bans(ban_id,room_id,device_id,nickname,reason,active,created_at,updated_at)
    VALUES(?,?,?,?,?,1,?,?)
    ON CONFLICT DO NOTHING
    """, (bid, str(payload.get("room_id","")), str(payload.get("device_id","")),
          str(payload.get("nickname","")), str(payload.get("reason","")), now(), now()))
    return {"ok": True, "ban_id": bid}


@app.post("/api/admin/unban")
def api_admin_unban(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    execute("UPDATE admin_bans SET active=0, updated_at=? WHERE room_id=? AND device_id=?",
            (now(), str(payload.get("room_id","")), str(payload.get("device_id",""))))
    return {"ok": True}




@app.post("/api/admin/flow/post")
def api_admin_flow_post(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    title = str(payload.get("title") or "Μήνυμα server")[:180]
    text = str(payload.get("text") or "").strip()
    community = str(payload.get("community") or "casual")[:80]
    media = payload.get("media") or None
    kind = str(payload.get("kind") or ("server-media" if media else "server-post"))[:40]
    # Owner/admin action: allowed, but still protected by max_upload_mb when media is embedded.
    if isinstance(media, dict):
        size = int(media.get("size", 0) or 0)
        max_bytes = int(_cost_guard_cfg(room_id).get("max_upload_mb", 50)) * 1024 * 1024
        if size and size > max_bytes:
            raise HTTPException(413, f"server media too large: {size} > {max_bytes}")
    pid = nid("flow")
    post_payload = json.dumps({
        "title": title,
        "text": text,
        "tags": ["server-admin", "live-flow", community],
        "kind": kind,
        "anon": False,
        "audience": "community",
        "targetCommunity": community,
        "media": media,
        "server_admin_post": True,
    }, ensure_ascii=False)
    execute("""
    INSERT INTO flow_posts(post_id,room_id,sender_id,nickname,language,text,is_encrypted,iv,cipher_text,post_kind,anonymous,visibility,created_at)
    VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
    """, (pid, room_id, "server_admin", "Administrator ΙΘΑΚΗ", "el", post_payload, 0, "", "", kind, 0, "public", now()))
    return {"ok": True, "post_id": pid, "nickname": "Administrator ΙΘΑΚΗ", "room_id": room_id, "server_stored_file": bool(media)}

@app.post("/api/admin/host/post")
def api_admin_host_post(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    room_id = str(payload.get("room_id") or "ithaki-phone-test")
    text = str(payload.get("text") or "").strip()
    return auto_host_post_once(room_id, force=True, custom_text=text)

@app.get("/api/admin/host/status")
def api_admin_host_status(admin_key: str = Query(""), room_id: str = Query("ithaki-phone-test")):
    _require_admin(admin_key)
    last = app_state_get_int(f"auto_host_last_ms::{room_id}", 0)
    interval = int(_runtime_config_value("auto_host_interval_seconds", 600))
    interval = max(600, min(interval, 3600))
    return {"ok": True, "enabled": bool(_runtime_config_value("auto_host_enabled", True)), "room_id": room_id, "last_ms": last, "nickname": "ΙΘΑΚΗ Host", "interval_seconds": interval, "ttl_hours": round(ttl_seconds()/3600, 2)}


@app.post("/api/admin/host/settings")
def api_admin_host_settings(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    interval_min = int(payload.get("auto_host_interval_minutes", 10))
    interval_min = max(10, min(interval_min, 60))
    ttl_hours = float(payload.get("message_ttl_hours", 72))
    ttl_hours = max(1.0, min(ttl_hours, 24.0 * 30.0))
    data = _runtime_config_update({
        "auto_host_enabled": bool(payload.get("auto_host_enabled", True)),
        "auto_host_interval_seconds": interval_min * 60,
        "auto_host_room_id": str(payload.get("room_id") or "ithaki-phone-test"),
        "message_ttl_hours": ttl_hours,
        "server_only_media_metadata": True,
    })
    return {"ok": True, "runtime_config": data, "interval_minutes": interval_min, "ttl_hours": ttl_hours}


@app.get("/api/admin/reports")
def api_admin_reports(admin_key: str = Query(""), room_id: str = Query("")):
    _require_admin(admin_key)
    if room_id:
        result = rows("SELECT * FROM reports WHERE room_id=? ORDER BY created_at DESC LIMIT 300", (room_id,))
    else:
        result = rows("SELECT * FROM reports ORDER BY created_at DESC LIMIT 300")
    return {"ok": True, "reports": [dict(r) for r in result]}


@app.get("/api/admin/cost_guard")
def api_cost_guard_get(admin_key: str = Query(""), room_id: str = Query("ithaki-phone-test")):
    _require_admin(admin_key)
    return {"ok": True, "config": _cost_guard_cfg(room_id), "usage": _cost_guard_usage(room_id)}


@app.post("/api/admin/cost_guard/save")
def api_cost_guard_save(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    room_id = str(payload.get("room_id") or "ithaki-phone-test")
    init_cost_guard_db()
    execute("""
    INSERT INTO cost_guard_settings(room_id,enabled,daily_server_mb,daily_device_mb,
      max_online_users,max_concurrent_calls,max_upload_mb,rate_limit_per_minute,
      emergency_disable_video,emergency_disable_calls,emergency_disable_uploads,
      emergency_disable_seeds,emergency_readonly,updated_at)
    VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
    ON CONFLICT(room_id) DO UPDATE SET
      enabled=excluded.enabled,daily_server_mb=excluded.daily_server_mb,
      daily_device_mb=excluded.daily_device_mb,max_online_users=excluded.max_online_users,
      max_concurrent_calls=excluded.max_concurrent_calls,max_upload_mb=excluded.max_upload_mb,
      rate_limit_per_minute=excluded.rate_limit_per_minute,
      emergency_disable_video=excluded.emergency_disable_video,
      emergency_disable_calls=excluded.emergency_disable_calls,
      emergency_disable_uploads=excluded.emergency_disable_uploads,
      emergency_disable_seeds=excluded.emergency_disable_seeds,
      emergency_readonly=excluded.emergency_readonly,updated_at=excluded.updated_at
    """, (room_id,
          1 if payload.get("enabled", True) else 0,
          int(payload.get("daily_server_mb", 200000)),
          int(payload.get("daily_device_mb", 100000)),
          int(payload.get("max_online_users", 500)),
          int(payload.get("max_concurrent_calls", 50)),
          min(int(payload.get("max_upload_mb", 500)), 500),
          int(payload.get("rate_limit_per_minute", 180)),
          1 if payload.get("emergency_disable_video", False) else 0,
          1 if payload.get("emergency_disable_calls", False) else 0,
          1 if payload.get("emergency_disable_uploads", False) else 0,
          1 if payload.get("emergency_disable_seeds", True) else 0,
          1 if payload.get("emergency_readonly", False) else 0,
          now()))
    return {"ok": True, "config": _cost_guard_cfg(room_id)}


# ──────────────────────────────────────────────
# Privacy / Erasure
# ──────────────────────────────────────────────
@app.post("/api/user/re_auth")
def api_user_re_auth(payload: dict, request: Request):
    init_privacy_db()
    if not _token_ok(request):
        raise HTTPException(403, "public app token required")
    room_id       = str(payload.get("room_id") or "")[:120]
    device_id     = str(payload.get("device_id") or "")[:120]
    device_secret = str(payload.get("device_secret") or "")
    if not room_id or not device_id or len(device_secret) < 16:
        raise HTTPException(400, "room_id, device_id and device_secret (min 16 chars) required")
    h = _device_secret_hash(room_id, device_id, device_secret)
    existing = one("SELECT secret_hash FROM user_device_auth WHERE room_id=? AND device_id=?", (room_id, device_id))
    if existing and existing["secret_hash"] != h:
        raise HTTPException(403, "device re-auth failed")
    execute("""
    INSERT INTO user_device_auth(room_id,device_id,secret_hash,created_at,updated_at) VALUES(?,?,?,?,?)
    ON CONFLICT(room_id,device_id) DO UPDATE SET updated_at=excluded.updated_at
    """, (room_id, device_id, h, now(), now()))
    return {"ok": True, "reauthenticated": True}


@app.post("/api/user/delete")
def api_user_delete(payload: dict, request: Request):
    init_privacy_db()
    if not _token_ok(request):
        raise HTTPException(403, "public app token required")
    room_id       = str(payload.get("room_id") or "")[:120]
    device_id     = str(payload.get("device_id") or "")[:120]
    device_secret = str(payload.get("device_secret") or "")
    if str(payload.get("confirm") or "") != "DELETE":
        raise HTTPException(400, "confirm must be 'DELETE'")
    if not room_id or not device_id or len(device_secret) < 16:
        raise HTTPException(400, "room_id, device_id and device_secret required")
    expected = one("SELECT secret_hash FROM user_device_auth WHERE room_id=? AND device_id=?",
                   (room_id, device_id))
    if not expected or expected["secret_hash"] != _device_secret_hash(room_id, device_id, device_secret):
        raise HTTPException(403, "device re-auth failed")
    dm = execute("DELETE FROM messages   WHERE room_id=? AND sender_id=?", (room_id, device_id)).rowcount
    df = execute("DELETE FROM flow_posts WHERE room_id=? AND sender_id=?", (room_id, device_id)).rowcount
    dr = execute("DELETE FROM reactions  WHERE device_id=?", (device_id,)).rowcount
    dp = execute("DELETE FROM people     WHERE room_id=? AND device_id=?", (room_id, device_id)).rowcount
    dre= execute("DELETE FROM reports    WHERE room_id=? AND reporter_id=?", (room_id, device_id)).rowcount
    eid = nid("erasure")
    execute("""
    INSERT INTO user_erasure_log(erasure_id,room_id,device_id_hash,deleted_messages,deleted_flow_posts,
      deleted_reactions,deleted_people,deleted_reports,created_at)
    VALUES(?,?,?,?,?,?,?,?,?)
    """, (eid, room_id, _hash_val(device_id), dm, df, dr, dp, dre, now()))
    execute("DELETE FROM user_device_auth WHERE room_id=? AND device_id=?", (room_id, device_id))
    return {"ok": True, "erasure_id": eid,
            "deleted": {"messages": dm, "flow_posts": df, "reactions": dr, "people": dp, "reports": dre}}


# ──────────────────────────────────────────────
# Push subscriptions (foundation)
# ──────────────────────────────────────────────
@app.post("/api/push/subscribe")
def api_push_subscribe(payload: dict, request: Request):
    init_push_db()
    room_id   = str(payload.get("room_id") or "")[:120]
    device_id = str(payload.get("device_id") or "")[:120]
    endpoint  = str(payload.get("endpoint") or "")
    p256dh    = str(payload.get("p256dh") or "")
    auth_key  = str(payload.get("auth") or "")
    if not room_id or not device_id or not endpoint:
        raise HTTPException(400, "room_id, device_id and endpoint required")
    sid = nid("push")
    execute("""
    INSERT INTO push_subscriptions(sub_id,room_id,device_id,endpoint,p256dh,auth_key,created_at)
    VALUES(?,?,?,?,?,?,?)
    ON CONFLICT DO NOTHING
    """, (sid, room_id, device_id, endpoint, p256dh, auth_key, now()))
    return {"ok": True, "sub_id": sid, "note": "Push foundation registered."}



# ──────────────────────────────────────────────
# v1.9.2 Owner admin/users + member block/report routes
# ──────────────────────────────────────────────
@app.get("/api/admin/users/list")
def api_admin_users_list(admin_key: str = Query(""), room_id: str = Query("ithaki-phone-test")):
    _require_admin(admin_key)
    init_professional_db()
    result=[]
    for r in rows("SELECT room_id,device_id,nickname,language,mood,last_seen FROM people WHERE room_id=? ORDER BY last_seen DESC LIMIT 500", (room_id,)):
        d=dict(r)
        b=one("SELECT 1 FROM admin_bans WHERE room_id=? AND device_id=? AND active=1", (room_id, d["device_id"]))
        d["blocked_by_admin"]=bool(b)
        result.append(d)
    return {"ok": True, "users": result}


@app.post("/api/admin/users/delete")
def api_admin_users_delete(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    room_id=str(payload.get("room_id") or "ithaki-phone-test")[:120]
    device_id=str(payload.get("device_id") or "")[:120]
    if not device_id:
        raise HTTPException(400, "device_id required")
    dm=execute("DELETE FROM messages WHERE room_id=? AND sender_id=?", (room_id,device_id))
    df=execute("DELETE FROM flow_posts WHERE room_id=? AND sender_id=?", (room_id,device_id))
    dp=execute("DELETE FROM people WHERE room_id=? AND device_id=?", (room_id,device_id))
    execute("DELETE FROM room_members WHERE room_id=? AND device_id=?", (room_id,device_id))
    return {"ok": True, "deleted_device_id": device_id, "note": "Member server rows removed. Local device profile remains on that phone only."}


@app.post("/api/user/block")
def api_user_block(payload: dict):
    init_professional_db()
    room_id=str(payload.get("room_id") or "ithaki-phone-test")[:120]
    blocker=str(payload.get("blocker_id") or "")[:120]
    blocked=str(payload.get("blocked_id") or "")[:120]
    if not blocker or not blocked:
        raise HTTPException(400,"blocker_id and blocked_id required")
    execute("""
    INSERT INTO user_blocks(room_id,blocker_id,blocked_id,blocked_name,reason,created_at)
    VALUES(?,?,?,?,?,?) ON CONFLICT(room_id,blocker_id,blocked_id) DO UPDATE SET reason=excluded.reason, created_at=excluded.created_at
    """, (room_id, blocker, blocked, str(payload.get("blocked_name") or "")[:80], str(payload.get("reason") or "")[:300], now()))
    return {"ok": True, "blocked_id": blocked}


@app.post("/api/user/unblock")
def api_user_unblock(payload: dict):
    init_professional_db()
    room_id=str(payload.get("room_id") or "ithaki-phone-test")[:120]
    execute("DELETE FROM user_blocks WHERE room_id=? AND blocker_id=? AND blocked_id=?", (room_id, str(payload.get("blocker_id") or "")[:120], str(payload.get("blocked_id") or "")[:120]))
    return {"ok": True}


@app.get("/api/user/blocks")
def api_user_blocks(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    init_professional_db()
    return {"ok": True, "blocks": [dict(r) for r in rows("SELECT * FROM user_blocks WHERE room_id=? AND blocker_id=? ORDER BY created_at DESC", (room_id, device_id))]}


@app.get("/api/admin/moderation/flags")
def api_admin_moderation_flags(admin_key: str = Query(""), room_id: str = Query("ithaki-phone-test")):
    _require_admin(admin_key)
    init_admin_db()
    return {"ok": True, "flags": [dict(r) for r in rows("SELECT * FROM admin_moderation_flags WHERE room_id=? ORDER BY created_at DESC LIMIT 300", (room_id,))]}

@app.get("/api/admin/moderation/words")
def api_admin_moderation_words(admin_key: str = Query(""), room_id: str = Query("ithaki-phone-test")):
    _require_admin(admin_key)
    seed_default_moderation_words(room_id)
    return {"ok": True, "words": [dict(r) for r in rows("SELECT word,active,created_at,updated_at FROM moderation_words WHERE room_id=? ORDER BY active DESC, word ASC", (room_id,))]}

@app.post("/api/admin/moderation/words/add")
def api_admin_moderation_words_add(payload: dict):
    _require_admin(str(payload.get("admin_key") or ""))
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    words_raw = str(payload.get("words") or payload.get("word") or "").replace(",", "\n")
    items = []
    for w in words_raw.splitlines():
        w = " ".join(str(w or "").strip().split())[:80]
        if w and w.lower() not in [x.lower() for x in items]:
            items.append(w)
    if not items:
        raise HTTPException(400, "word required")
    init_moderation_words_db()
    t = now()
    for w in items:
        execute("""
        INSERT INTO moderation_words(room_id,word,active,created_at,updated_at) VALUES(?,?,?,?,?)
        ON CONFLICT(room_id,word) DO UPDATE SET active=1, updated_at=excluded.updated_at
        """, (room_id, w, 1, t, t))
    admin_audit(room_id, 'moderation_words_add', 'server', {'count': len(items), 'words': items[:20]})
    return {"ok": True, "added": items, "words": _soft_words(room_id)}

@app.post("/api/admin/moderation/words/delete")
def api_admin_moderation_words_delete(payload: dict):
    _require_admin(str(payload.get("admin_key") or ""))
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    word = " ".join(str(payload.get("word") or "").strip().split())[:80]
    if not word:
        raise HTTPException(400, "word required")
    init_moderation_words_db()
    execute("UPDATE moderation_words SET active=0, updated_at=? WHERE room_id=? AND lower(word)=lower(?)", (now(), room_id, word))
    admin_audit(room_id, 'moderation_words_disable', 'server', {'word': word})
    return {"ok": True, "disabled": word}

@app.post("/api/admin/moderation/words/reset")
def api_admin_moderation_words_reset(payload: dict):
    _require_admin(str(payload.get("admin_key") or ""))
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    init_moderation_words_db()
    execute("DELETE FROM moderation_words WHERE room_id=?", (room_id,))
    seed_default_moderation_words(room_id)
    admin_audit(room_id, 'moderation_words_reset', 'server', {})
    return {"ok": True, "words": _soft_words(room_id)}

@app.post("/api/admin/moderation/test")
def api_admin_moderation_test(payload: dict):
    _require_admin(str(payload.get("admin_key") or ""))
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    text = str(payload.get("text") or "")[:3000]
    return {"ok": True, "result": _moderate_text(room_id, "admin_test", "Admin", text, "test", "preview")}


@app.get("/api/admin/limits/summary")
def api_admin_limits_summary(admin_key: str = Query(""), room_id: str = Query("ithaki-phone-test")):
    _require_admin(admin_key)
    return {"ok": True, "quota": quota_status(room_id, "admin-preview"), "stats": api_admin_server_stats_detailed(admin_key, room_id)}
# ──────────────────────────────────────────────
# Debug helpers
# ──────────────────────────────────────────────
@app.get("/api/debug/room")
def debug_room(room_id: str = Query(...)):
    people = [dict(r) for r in rows("SELECT * FROM people WHERE room_id=? ORDER BY last_seen DESC", (room_id,))]
    channels = [dict(r) for r in rows("""
        SELECT channel, COUNT(*) AS messages FROM messages WHERE room_id=?
        GROUP BY channel ORDER BY messages DESC LIMIT 50
    """, (room_id,))]
    return {
        "ok": True, "room_id": room_id, "server_time": now(),
        "people_online": people, "channels": channels,
        "flow_posts": one("SELECT COUNT(*) AS n FROM flow_posts WHERE room_id=?", (room_id,))["n"],
        "rooms": one("SELECT COUNT(*) AS n FROM rooms WHERE room_id=?", (room_id,))["n"],
    }


@app.post("/api/debug/ping")
def debug_ping(payload: dict):
    room_id   = str(payload.get("room_id", "ithaki-phone-test"))[:120]
    device_id = str(payload.get("device_id", "unknown"))[:120]
    nickname  = str(payload.get("nickname", "member"))[:80]
    language  = str(payload.get("language", "el"))[:20]
    execute("""
    INSERT INTO people(room_id,device_id,nickname,language,mood,last_seen) VALUES(?,?,?,?,?,?)
    ON CONFLICT(room_id,device_id) DO UPDATE SET
      nickname=excluded.nickname, language=excluded.language, mood=excluded.mood, last_seen=excluded.last_seen
    """, (room_id, device_id, nickname, language, "ping-test", now()))
    return {"ok": True, "room_id": room_id, "device_id": device_id, "server_time": now()}



# ──────────────────────────────────────────────
# v2.0.2 All-in-one Owner Server Control Center APIs
# ──────────────────────────────────────────────
@app.get("/api/admin/control/overview")
def api_admin_control_overview(admin_key: str = Query(""), room_id: str = Query("ithaki-phone-test")):
    _require_admin(admin_key)
    init_member_control_db(); init_professional_db(); init_media_db()
    stats = api_admin_server_stats_detailed(admin_key, room_id)
    out_users = []
    for r in rows("SELECT room_id,device_id,nickname,language,mood,last_seen FROM people WHERE room_id=? ORDER BY last_seen DESC LIMIT 1000", (room_id,)):
        d = dict(r); device_id = str(d.get('device_id') or '')
        if device_id in ('ithaki_auto_seed','ithaki_auto_host','ithaki_host') or device_id.lower().startswith('demo'):
            continue
        if str(d.get('nickname') or '').lower().startswith('demo'):
            continue
        d['online_seconds_ago'] = max(0, int(now() - float(d.get('last_seen') or 0)))
        d['blocked_by_admin'] = bool(one("SELECT 1 FROM admin_bans WHERE room_id=? AND device_id=? AND active=1 LIMIT 1", (room_id, device_id)))
        d['usage'] = _member_usage_summary(room_id, device_id)
        d['quota'] = quota_status(room_id, device_id)
        d['override'] = _member_quota_override(room_id, device_id)
        d['reports_count'] = int((one("SELECT COUNT(*) n FROM reports WHERE room_id=? AND reporter_id=?", (room_id, device_id)) or {'n':0})['n'])
        d['behavior_count'] = int((one("SELECT COUNT(*) n FROM admin_behavior_reports WHERE room_id=? AND device_id=?", (room_id, device_id)) or {'n':0})['n'])
        out_users.append(d)
    return {"ok": True, "room_id": room_id, "stats": stats, "users": out_users,
            "reports": [dict(x) for x in rows("SELECT * FROM reports WHERE room_id=? ORDER BY created_at DESC LIMIT 100", (room_id,))],
            "behavior": [dict(x) for x in rows("SELECT * FROM admin_behavior_reports WHERE room_id=? ORDER BY created_at DESC LIMIT 100", (room_id,))],
            "bans": [dict(x) for x in rows("SELECT * FROM admin_bans WHERE room_id=? ORDER BY updated_at DESC LIMIT 100", (room_id,))],
            "audit": [dict(x) for x in rows("SELECT * FROM admin_audit_log WHERE room_id=? ORDER BY created_at DESC LIMIT 80", (room_id,))],
            "uploads_by_kind": [dict(x) for x in rows("SELECT media_kind, COUNT(*) events, COALESCE(SUM(size_bytes),0) bytes FROM user_upload_usage WHERE room_id=? GROUP BY media_kind ORDER BY bytes DESC", (room_id,))]}

@app.post("/api/admin/control/member/quota")
def api_admin_control_member_quota(payload: dict):
    _require_admin(str(payload.get('admin_key',''))); init_member_control_db()
    room_id = str(payload.get('room_id') or 'ithaki-phone-test')[:120]
    device_id = str(payload.get('device_id') or '')[:120]
    if not device_id: raise HTTPException(400, 'device_id required')
    def intval(name, default=-1, lo=-1, hi=10000000):
        try: v=int(payload.get(name, default))
        except Exception: v=default
        return max(lo, min(hi, v))
    def boolint(name):
        if name not in payload or payload.get(name) is None: return -1
        return 1 if bool(payload.get(name)) else 0
    vals = {k:intval(k) for k in ['daily_device_mb','monthly_device_mb','max_upload_mb','max_photo_mb','max_video_mb','max_audio_mb']}
    vals['uploads_disabled'] = boolint('uploads_disabled'); vals['video_disabled'] = boolint('video_disabled')
    nickname = str(payload.get('nickname') or '')[:80]; notes = str(payload.get('notes') or '')[:1200]
    execute("""
    INSERT INTO member_quota_overrides(room_id,device_id,nickname,daily_device_mb,monthly_device_mb,max_upload_mb,max_photo_mb,max_video_mb,max_audio_mb,uploads_disabled,video_disabled,notes,updated_at)
    VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
    ON CONFLICT(room_id,device_id) DO UPDATE SET nickname=excluded.nickname,daily_device_mb=excluded.daily_device_mb,monthly_device_mb=excluded.monthly_device_mb,max_upload_mb=excluded.max_upload_mb,max_photo_mb=excluded.max_photo_mb,max_video_mb=excluded.max_video_mb,max_audio_mb=excluded.max_audio_mb,uploads_disabled=excluded.uploads_disabled,video_disabled=excluded.video_disabled,notes=excluded.notes,updated_at=excluded.updated_at
    """, (room_id,device_id,nickname,vals['daily_device_mb'],vals['monthly_device_mb'],vals['max_upload_mb'],vals['max_photo_mb'],vals['max_video_mb'],vals['max_audio_mb'],vals['uploads_disabled'],vals['video_disabled'],notes,now()))
    admin_audit(room_id, 'member_quota_save', device_id, {'nickname': nickname, **vals, 'notes': notes})
    return {"ok": True, "device_id": device_id, "quota": quota_status(room_id, device_id), "override": _member_quota_override(room_id, device_id)}

@app.post("/api/admin/control/member/quota/clear")
def api_admin_control_member_quota_clear(payload: dict):
    _require_admin(str(payload.get('admin_key',''))); init_member_control_db()
    room_id=str(payload.get('room_id') or 'ithaki-phone-test')[:120]; device_id=str(payload.get('device_id') or '')[:120]
    if not device_id: raise HTTPException(400, 'device_id required')
    execute("DELETE FROM member_quota_overrides WHERE room_id=? AND device_id=?", (room_id, device_id))
    admin_audit(room_id, 'member_quota_clear', device_id, {})
    return {"ok": True, "device_id": device_id, "quota": quota_status(room_id, device_id)}

@app.post("/api/admin/control/member/block")
def api_admin_control_member_block(payload: dict):
    _require_admin(str(payload.get('admin_key',''))); init_member_control_db()
    room_id=str(payload.get('room_id') or 'ithaki-phone-test')[:120]; device_id=str(payload.get('device_id') or '')[:120]
    if not device_id: raise HTTPException(400, 'device_id required')
    nickname=str(payload.get('nickname') or '')[:80]; reason=str(payload.get('reason') or 'admin block')[:600]; active=1 if bool(payload.get('active', True)) else 0
    if active:
        bid=nid('ban')
        execute("INSERT INTO admin_bans(ban_id,room_id,device_id,nickname,reason,active,created_at,updated_at) VALUES(?,?,?,?,?,1,?,?)", (bid,room_id,device_id,nickname,reason,now(),now()))
        admin_audit(room_id, 'member_block', device_id, {'reason': reason})
        return {"ok": True, "blocked": True, "ban_id": bid}
    execute("UPDATE admin_bans SET active=0, updated_at=? WHERE room_id=? AND device_id=?", (now(), room_id, device_id))
    admin_audit(room_id, 'member_unblock', device_id, {})
    return {"ok": True, "blocked": False}

@app.post("/api/admin/control/member/delete")
def api_admin_control_member_delete(payload: dict):
    _require_admin(str(payload.get('admin_key',''))); init_member_control_db(); init_media_db()
    room_id=str(payload.get('room_id') or 'ithaki-phone-test')[:120]; device_id=str(payload.get('device_id') or '')[:120]
    if not device_id: raise HTTPException(400, 'device_id required')
    deleted={
        'messages': execute("DELETE FROM messages WHERE room_id=? AND sender_id=?", (room_id,device_id)).rowcount,
        'flow_posts': execute("DELETE FROM flow_posts WHERE room_id=? AND sender_id=?", (room_id,device_id)).rowcount,
        'people': execute("DELETE FROM people WHERE room_id=? AND device_id=?", (room_id,device_id)).rowcount,
        'room_members': execute("DELETE FROM room_members WHERE room_id=? AND device_id=?", (room_id,device_id)).rowcount if table_cols('room_members') else 0,
        'media_metadata': execute("DELETE FROM media_metadata WHERE room_id=? AND device_id=?", (room_id,device_id)).rowcount,
        'usage_events': execute("DELETE FROM user_upload_usage WHERE room_id=? AND device_id=?", (room_id,device_id)).rowcount,
        'quota_override': execute("DELETE FROM member_quota_overrides WHERE room_id=? AND device_id=?", (room_id,device_id)).rowcount,
    }
    admin_audit(room_id, 'member_delete', device_id, deleted)
    return {"ok": True, "device_id": device_id, "deleted": deleted}

@app.post("/api/admin/control/behavior/add")
def api_admin_control_behavior_add(payload: dict):
    _require_admin(str(payload.get('admin_key',''))); init_member_control_db()
    room_id=str(payload.get('room_id') or 'ithaki-phone-test')[:120]; device_id=str(payload.get('device_id') or '')[:120]
    nickname=str(payload.get('nickname') or '')[:80]; category=str(payload.get('category') or 'note')[:80]
    note=str(payload.get('note') or '')[:2000]; severity=max(1,min(5,int(payload.get('severity') or 1)))
    if not note: raise HTTPException(400, 'note required')
    bid=nid('beh')
    execute("INSERT INTO admin_behavior_reports(behavior_id,room_id,device_id,nickname,category,severity,note,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)", (bid,room_id,device_id,nickname,category,severity,note,'open',now(),now()))
    admin_audit(room_id, 'behavior_add', device_id, {'category': category, 'severity': severity, 'note': note[:300]})
    return {"ok": True, "behavior_id": bid}

@app.get("/api/admin/control/member/detail")
def api_admin_control_member_detail(admin_key: str = Query(""), room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    _require_admin(admin_key); init_member_control_db(); init_media_db()
    if not device_id: raise HTTPException(400, 'device_id required')
    person = one("SELECT * FROM people WHERE room_id=? AND device_id=?", (room_id, device_id))
    return {"ok": True, "person": dict(person) if person else None, "quota": quota_status(room_id, device_id), "override": _member_quota_override(room_id, device_id), "usage": _member_usage_summary(room_id, device_id), "media": [dict(x) for x in rows("SELECT * FROM media_metadata WHERE room_id=? AND device_id=? ORDER BY created_at DESC LIMIT 100", (room_id, device_id))], "messages": [dict(x) for x in rows("SELECT message_id,channel,nickname,language,text,created_at FROM messages WHERE room_id=? AND sender_id=? ORDER BY created_at DESC LIMIT 100", (room_id, device_id))], "flow_posts": [dict(x) for x in rows("SELECT post_id,nickname,language,text,post_kind,visibility,created_at FROM flow_posts WHERE room_id=? AND sender_id=? ORDER BY created_at DESC LIMIT 100", (room_id, device_id))], "reports": [dict(x) for x in rows("SELECT * FROM reports WHERE room_id=? AND reporter_id=? ORDER BY created_at DESC LIMIT 100", (room_id, device_id))], "behavior": [dict(x) for x in rows("SELECT * FROM admin_behavior_reports WHERE room_id=? AND device_id=? ORDER BY created_at DESC LIMIT 100", (room_id, device_id))]}

@app.get("/admin/control", response_class=HTMLResponse)
def admin_control_center():
    p = ROOT / "admin" / "control_center.html"
    if p.exists():
        return HTMLResponse(p.read_text(encoding='utf-8', errors='replace'), headers={"Cache-Control":"no-store"})
    return HTMLResponse("<h1>control_center.html missing</h1>")



# ──────────────────────────────────────────────
# ITHAKI V249211 Server-only Professional Control Center
# Runs inside the same FastAPI server/admin. No separate desktop program.
# ──────────────────────────────────────────────
def _v249211_num(x, default=0):
    try:
        return float(x or 0)
    except Exception:
        return float(default)


def _v249211_bucket_series(table: str, room_id: str, field: str = "created_at", hours: int = 24) -> list[dict]:
    try:
        start = now() - int(hours) * 3600
        if table not in {"messages", "flow_posts", "user_upload_usage", "poll_votes", "humanguard_events"}:
            return []
        rs = rows(f"""
            SELECT CAST(({field} - ?) / 3600 AS INTEGER) AS bucket, COUNT(*) AS n
            FROM {table}
            WHERE room_id=? AND {field}>=?
            GROUP BY bucket ORDER BY bucket
        """, (start, room_id, start))
        out=[]
        for r in rs:
            b=int(r["bucket"] or 0)
            ts=start + b*3600
            out.append({"hour": time.strftime("%H:%M", time.localtime(ts)), "count": int(r["n"] or 0)})
        return out
    except Exception:
        LOG.exception("v249211 series failed %s", table)
        return []


def _v249211_reaction_series(room_id: str, hours: int = 24) -> list[dict]:
    try:
        start = now() - int(hours) * 3600
        rs = rows("""
            SELECT CAST((r.created_at - ?) / 3600 AS INTEGER) AS bucket, COUNT(*) AS n
            FROM reactions r JOIN flow_posts f ON f.post_id=r.target_id
            WHERE f.room_id=? AND r.created_at>=?
            GROUP BY bucket ORDER BY bucket
        """, (start, room_id, start))
        out=[]
        for r in rs:
            b=int(r["bucket"] or 0); ts=start+b*3600
            out.append({"hour": time.strftime("%H:%M", time.localtime(ts)), "count": int(r["n"] or 0)})
        return out
    except Exception:
        LOG.exception("v249211 reactions series failed")
        return []


@app.get("/api/admin/control/pro/analytics")
def api_admin_control_pro_analytics(admin_key: str = Query(""), room_id: str = Query("ithaki-phone-test")):
    _require_admin(admin_key)
    init_member_control_db(); init_media_db(); _hg_init_db()
    t=now(); day=t-86400; month=t-30*86400
    def cnt(q, args=()):
        try:
            r=one(q,args); return int(r["n"] if r else 0)
        except Exception:
            return 0
    def sum_bytes(q,args=()):
        try:
            r=one(q,args); return int(r["n"] if r else 0)
        except Exception:
            return 0
    active_members=[]
    for r in rows("""
        SELECT p.device_id,p.nickname,p.last_seen,
          (SELECT COUNT(*) FROM messages m WHERE m.room_id=p.room_id AND m.sender_id=p.device_id AND m.created_at>=?) AS messages_day,
          (SELECT COUNT(*) FROM flow_posts f WHERE f.room_id=p.room_id AND f.sender_id=p.device_id AND f.created_at>=?) AS posts_day,
          (SELECT COUNT(*) FROM reactions rr JOIN flow_posts ff ON ff.post_id=rr.target_id WHERE ff.room_id=p.room_id AND rr.device_id=p.device_id AND rr.created_at>=?) AS likes_day,
          (SELECT COUNT(*) FROM poll_votes pv WHERE pv.room_id=p.room_id AND pv.device_id=p.device_id AND pv.created_at>=?) AS votes_day,
          (SELECT COALESCE(SUM(size_bytes),0) FROM user_upload_usage uu WHERE uu.room_id=p.room_id AND uu.device_id=p.device_id AND uu.created_at>=?) AS upload_bytes_day
        FROM people p WHERE p.room_id=? ORDER BY p.last_seen DESC LIMIT 300
    """, (day,day,day,day,day,room_id)):
        d=dict(r); d["online_seconds_ago"]=max(0,int(t-float(d.get("last_seen") or 0))); d["upload_mb_day"]=round(int(d.get("upload_bytes_day") or 0)/1024/1024,2); active_members.append(d)
    bad=[]
    for r in rows("SELECT event_id,device_id,nickname,action,level,reason,score_delta,text_sample,created_at FROM humanguard_events WHERE room_id=? AND level IN ('warning','blocked') ORDER BY created_at DESC LIMIT 200", (room_id,)):
        bad.append(dict(r))
    return {
        "ok": True,
        "room_id": room_id,
        "period": {"day_start": day, "month_start": month, "server_time": t},
        "totals": {
            "members_total": cnt("SELECT COUNT(*) n FROM people WHERE room_id=?", (room_id,)),
            "members_online": cnt("SELECT COUNT(*) n FROM people WHERE room_id=? AND last_seen>=?", (room_id,t-60)),
            "messages_day": cnt("SELECT COUNT(*) n FROM messages WHERE room_id=? AND created_at>=?", (room_id,day)),
            "messages_month": cnt("SELECT COUNT(*) n FROM messages WHERE room_id=? AND created_at>=?", (room_id,month)),
            "posts_day": cnt("SELECT COUNT(*) n FROM flow_posts WHERE room_id=? AND created_at>=?", (room_id,day)),
            "posts_month": cnt("SELECT COUNT(*) n FROM flow_posts WHERE room_id=? AND created_at>=?", (room_id,month)),
            "likes_day": cnt("SELECT COUNT(*) n FROM reactions r JOIN flow_posts f ON f.post_id=r.target_id WHERE f.room_id=? AND r.created_at>=?", (room_id,day)),
            "votes_day": cnt("SELECT COUNT(*) n FROM poll_votes WHERE room_id=? AND created_at>=?", (room_id,day)),
            "upload_mb_day": round(sum_bytes("SELECT COALESCE(SUM(size_bytes),0) n FROM user_upload_usage WHERE room_id=? AND created_at>=?", (room_id,day))/1024/1024,2),
            "upload_mb_month": round(sum_bytes("SELECT COALESCE(SUM(size_bytes),0) n FROM user_upload_usage WHERE room_id=? AND created_at>=?", (room_id,month))/1024/1024,2),
        },
        "series": {
            "messages": _v249211_bucket_series("messages", room_id),
            "posts": _v249211_bucket_series("flow_posts", room_id),
            "uploads": _v249211_bucket_series("user_upload_usage", room_id),
            "likes": _v249211_reaction_series(room_id),
            "votes": _v249211_bucket_series("poll_votes", room_id),
            "warnings": _v249211_bucket_series("humanguard_events", room_id),
        },
        "members": active_members,
        "bad_language_or_spam": bad,
        "uploads_by_member_day": [dict(x) for x in rows("""
            SELECT device_id, COUNT(*) uploads, COALESCE(SUM(size_bytes),0) bytes
            FROM user_upload_usage WHERE room_id=? AND created_at>=?
            GROUP BY device_id ORDER BY bytes DESC LIMIT 100
        """, (room_id,day))],
    }


@app.post("/api/admin/control/member/message")
def api_admin_control_member_message(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    room_id=str(payload.get("room_id") or "ithaki-phone-test")[:120]
    target=str(payload.get("device_id") or "")[:120]
    text=str(payload.get("message") or "").strip()[:2500]
    if not target:
        raise HTTPException(400, "device_id required")
    if not text:
        raise HTTPException(400, "message required")
    admin_id="server_admin"
    channel="dm:"+":".join(sorted([admin_id,target]))
    mid=nid("msg")
    body="Μήνυμα διαχειριστή ΙΘΑΚΗΣ:\n"+text
    execute("""
    INSERT INTO messages(message_id,room_id,channel,sender_id,nickname,language,text,is_encrypted,iv,cipher_text,created_at)
    VALUES(?,?,?,?,?,?,?,?,?,?,?)
    """, (mid,room_id,channel,admin_id,"Administrator ΙΘΑΚΗΣ","el",body,0,"","",now()))
    admin_audit(room_id, "member_personal_message", target, {"message_id": mid, "preview": text[:180]})
    try:
        push_notify(room_id, channel, admin_id, "Administrator ΙΘΑΚΗΣ", body, mid)
    except Exception:
        pass
    return {"ok": True, "message_id": mid, "channel": channel}


@app.post("/api/admin/control/defaults/apply")
def api_admin_control_defaults_apply(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    room_id=str(payload.get("room_id") or "ithaki-phone-test")[:120]
    init_cost_guard_db(); ensure_uploads_open_default(room_id)
    photo=max(1,min(int(payload.get("max_photo_mb") or 100),100))
    video=max(1,min(int(payload.get("max_video_mb") or 1000),1000))
    filemb=max(1,min(int(payload.get("max_upload_mb") or 500),500))
    audio=max(1,min(int(payload.get("max_audio_mb") or 100),100))
    daily=max(1,min(int(payload.get("daily_device_mb") or 100000),10000000))
    monthly=max(1,min(int(payload.get("monthly_device_mb") or 1000000),100000000))
    execute("""
        UPDATE cost_guard_settings SET max_photo_mb=?, max_video_mb=?, max_upload_mb=?, max_audio_mb=?, daily_device_mb=?, monthly_device_mb=?, emergency_disable_video=0, emergency_disable_uploads=0, updated_at=?
        WHERE room_id=?
    """, (photo,video,filemb,audio,daily,monthly,now(),room_id))
    try:
        cfgfile = ROOT / "media_quota_config.json"
        existing = json.loads(cfgfile.read_text(encoding="utf-8")) if cfgfile.exists() else {}
        existing.update({"max_photo_mb": photo, "max_video_mb": video, "max_upload_mb": filemb, "max_audio_mb": audio, "daily_device_mb": daily, "monthly_device_mb": monthly, "video_disabled": False, "uploads_disabled": False, "show_upgrade_cards": False, "version": "249211_server_control_only", "note": "V249394 TEST limits: photos 100MB, videos 1000MB, files 500MB, audio 100MB; editable from /admin/control."})
        cfgfile.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding="utf-8")
    except Exception:
        LOG.exception("v249211 defaults cfg write failed")
    admin_audit(room_id, "server_control_defaults_apply", "", {"photo": photo, "video": video, "file": filemb, "audio": audio, "daily": daily, "monthly": monthly})
    return {"ok": True, "quota": quota_status(room_id, "admin-preview")}

# ──────────────────────────────────────────────
# Infra status
# ──────────────────────────────────────────────
@app.get("/api/infra/status")
def api_infra_status(request: Request):
    b    = get_bootstrap()
    url  = str(b.get("active_api_url") or os.getenv("ITHAKI_PUBLIC_API_URL", "https://api.ok-ithaki.gr"))
    mode = os.getenv("ITHAKI_PROVIDER_MODE", "cloudflare_tunnel")
    host = request.headers.get("host", "")
    warnings = []
    if "127.0.0.1" in url or "localhost" in url:
        warnings.append("Public URL points to localhost; not suitable for phones.")
    if url.startswith("http://") and mode not in ("local_lan", "ngrok_dev"):
        warnings.append("Production URL should be HTTPS.")
    return {
        "ok": True, "version": VERSION, "app": APP_NAME,
        "provider_mode": mode, "public_api_url": url,
        "https_ready": url.startswith("https://"),
        "warnings": warnings,
        "request_origin": f"{request.url.scheme}://{host}",
    }




@app.get("/admin/humanguard", response_class=HTMLResponse)
def admin_humanguard_control_center_v24715():
    p = ROOT / "admin" / "humanguard_control.html"
    if p.exists():
        return HTMLResponse(p.read_text(encoding='utf-8', errors='replace'), headers={"Cache-Control":"no-store"})
    return HTMLResponse("<h1>humanguard_control.html missing</h1>")

@app.get("/admin/console", response_class=HTMLResponse)
def admin_console():
    return admin_control_center()

# ──────────────────────────────────────────────
# V24691X Professional Server Management Center aliases
# ──────────────────────────────────────────────
@app.get("/admin", response_class=HTMLResponse)
def admin_professional_alias():
    return admin_control_center()

@app.get("/server-manager", response_class=HTMLResponse)
def server_manager_alias():
    return admin_control_center()

@app.get("/api/admin/pro/summary")
def api_admin_pro_summary(admin_key: str = Query(""), room_id: str = Query("ithaki-phone-test")):
    _require_admin(admin_key)
    data = api_admin_control_overview(admin_key, room_id)
    try:
        data["infra"] = {
            "version": VERSION,
            "db_path": str(DB_PATH),
            "frontend": str(FRONTEND),
            "root": str(ROOT),
            "runtime_config": _runtime_config_read() if '_runtime_config_read' in globals() else {},
        }
    except Exception:
        data["infra"] = {"version": VERSION}
    return data


# ──────────────────────────────────────────────
# ITHAKI V245.7.9 - Secure WebRTC video call signaling + anti-flood guard
# Server stores no camera/microphone media. It only keeps short-lived signaling.
# ──────────────────────────────────────────────
VIDEO_CALL_CONFIG_PATH = ROOT / "video_call_config.json"


def _video_defaults() -> dict:
    return {
        "enabled": True,
        "default_quality": "low",
        "max_minutes": 12,
        "warning_seconds": 60,
        "signal_ttl_seconds": 180,
        "session_ttl_seconds": 900,
        "max_concurrent_calls": 20,
        "max_signals_per_10s": 80,
        "ice_servers": [{"urls": "stun:stun.l.google.com:19302"}],
        "quality_profiles": {
            "eco":    {"width": 240, "height": 180, "fps": 10, "max_bitrate_kbps": 120},
            "low":    {"width": 320, "height": 240, "fps": 12, "max_bitrate_kbps": 220},
            "medium": {"width": 480, "height": 360, "fps": 15, "max_bitrate_kbps": 450},
            "high":   {"width": 640, "height": 480, "fps": 20, "max_bitrate_kbps": 850}
        },
        "privacy_note_el": "Η βιντεοκλήση γίνεται απευθείας μεταξύ συσκευών με WebRTC όπου γίνεται εφικτό. Ο server δεν αποθηκεύει εικόνα, ήχο ή εγγραφή. Κρατά μόνο προσωρινά σήματα σύνδεσης για λίγα λεπτά και καθαρίζονται αυτόματα.",
        "privacy_note_en": "Video calls use peer-to-peer WebRTC where possible. The server does not store video, audio or recordings. It keeps only short-lived signaling data for a few minutes and purges it automatically."
    }


def _video_cfg(room_id: str = "ithaki-phone-test") -> dict:
    cfg = _video_defaults()
    try:
        if VIDEO_CALL_CONFIG_PATH.exists():
            saved = json.loads(VIDEO_CALL_CONFIG_PATH.read_text(encoding="utf-8"))
            if isinstance(saved, dict):
                for k, v in saved.items():
                    if k == "quality_profiles" and isinstance(v, dict):
                        cfg["quality_profiles"].update(v)
                    else:
                        cfg[k] = v
    except Exception:
        LOG.exception("video_call_config read failed")
    try:
        guard = _cost_guard_cfg(room_id)
        if guard.get("emergency_disable_calls", False):
            cfg["enabled"] = False
        if guard.get("max_concurrent_calls"):
            cfg["max_concurrent_calls"] = int(guard.get("max_concurrent_calls"))
    except Exception:
        pass
    q = str(cfg.get("default_quality") or "low")
    if q not in cfg.get("quality_profiles", {}):
        cfg["default_quality"] = "low"
    cfg["max_minutes"] = max(1, min(int(cfg.get("max_minutes", 12)), 120))
    cfg["warning_seconds"] = max(10, min(int(cfg.get("warning_seconds", 60)), 600))
    cfg["signal_ttl_seconds"] = max(30, min(int(cfg.get("signal_ttl_seconds", 180)), 600))
    cfg["session_ttl_seconds"] = max(120, min(int(cfg.get("session_ttl_seconds", 900)), 7200))
    cfg["max_concurrent_calls"] = max(1, min(int(cfg.get("max_concurrent_calls", 20)), 500))
    cfg["max_signals_per_10s"] = max(20, min(int(cfg.get("max_signals_per_10s", 80)), 500))
    try:
        turn_host = str(os.getenv("ITHAKI_TURN_HOST", "ok-ithaki.gr") or "ok-ithaki.gr").strip()
        turn_user = str(os.getenv("ITHAKI_TURN_USERNAME", "ithaki") or "ithaki").strip()
        turn_pass = str(os.getenv("ITHAKI_TURN_PASSWORD", "") or "").strip()
        turn_udp = f"turn:{turn_host}:3478?transport=udp"
        turn_tcp = f"turn:{turn_host}:3478?transport=tcp"
        turn_tls = f"turns:{turn_host}:5349?transport=tcp"
        ice = cfg.get("ice_servers") or []
        if not isinstance(ice, list):
            ice = [ice]
        normalized = []
        has_stun = False
        has_turn = False
        for item in ice:
            if not isinstance(item, dict):
                continue
            copied = dict(item)
            urls = copied.get("urls")
            url_list = urls if isinstance(urls, list) else [urls]
            flat = [str(u or "").strip() for u in url_list if str(u or "").strip()]
            if any(u.startswith("stun:") for u in flat):
                has_stun = True
            if any(u.startswith("turn:") or u.startswith("turns:") for u in flat):
                has_turn = True
                if turn_pass and (str(copied.get("credential") or "").startswith("CHANGE_BY_DEPLOY") or not str(copied.get("credential") or "").strip()):
                    copied["credential"] = turn_pass
                if turn_user and not str(copied.get("username") or "").strip():
                    copied["username"] = turn_user
                rewritten = []
                for u in flat:
                    if u.startswith("turn:") or u.startswith("turns:"):
                        if ":3478" in u or ":5349" in u or "ok-ithaki.gr" in u:
                            if u.startswith("turns:"):
                                rewritten.append(turn_tls)
                            elif "transport=tcp" in u:
                                rewritten.append(turn_tcp)
                            else:
                                rewritten.append(turn_udp)
                        else:
                            rewritten.append(u)
                    else:
                        rewritten.append(u)
                copied["urls"] = rewritten if len(rewritten) > 1 else (rewritten[0] if rewritten else urls)
            normalized.append(copied)
        if not has_stun:
            normalized.insert(0, {"urls": "stun:stun.l.google.com:19302"})
        if turn_pass and not has_turn:
            normalized.append({"urls": [turn_udp, turn_tcp, turn_tls], "username": turn_user, "credential": turn_pass})
        cfg["ice_servers"] = normalized
        cfg["ws_call_signaling"] = True
        cfg["eco_retry_on_fail"] = True
    except Exception:
        pass
    return cfg


def init_video_call_db():
    execute("""
    CREATE TABLE IF NOT EXISTS video_call_sessions(
        session_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL,
        caller_id TEXT NOT NULL,
        caller_name TEXT NOT NULL DEFAULT '',
        callee_id TEXT NOT NULL,
        callee_name TEXT NOT NULL DEFAULT '',
        call_type TEXT NOT NULL DEFAULT 'video',
        status TEXT NOT NULL DEFAULT 'ringing',
        quality TEXT NOT NULL DEFAULT 'low',
        max_minutes INTEGER NOT NULL DEFAULT 12,
        max_bitrate_kbps INTEGER NOT NULL DEFAULT 220,
        video_width INTEGER NOT NULL DEFAULT 320,
        video_height INTEGER NOT NULL DEFAULT 240,
        fps INTEGER NOT NULL DEFAULT 12,
        created_at REAL NOT NULL,
        updated_at REAL NOT NULL,
        expires_at REAL NOT NULL
    )""")
    try:
        cols = {str(r["name"]) for r in rows("PRAGMA table_info(video_call_sessions)")}
        if "call_type" not in cols:
            execute("ALTER TABLE video_call_sessions ADD COLUMN call_type TEXT NOT NULL DEFAULT 'video'")
    except Exception:
        LOG.exception("video call schema migration failed")
    execute("CREATE INDEX IF NOT EXISTS idx_vcall_room_target ON video_call_sessions(room_id, callee_id, status, updated_at)")
    execute("CREATE INDEX IF NOT EXISTS idx_vcall_room_caller ON video_call_sessions(room_id, caller_id, status, updated_at)")
    execute("""
    CREATE TABLE IF NOT EXISTS video_call_signals(
        signal_id TEXT PRIMARY KEY,
        session_id TEXT NOT NULL,
        room_id TEXT NOT NULL,
        sender_id TEXT NOT NULL,
        target_id TEXT NOT NULL,
        signal_type TEXT NOT NULL,
        payload TEXT NOT NULL DEFAULT '{}',
        created_at REAL NOT NULL
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_vcall_signals_target ON video_call_signals(room_id, target_id, session_id, created_at)")
    execute("""
    CREATE TABLE IF NOT EXISTS missed_call_once_v249260(
        session_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL DEFAULT '',
        caller_id TEXT NOT NULL DEFAULT '',
        callee_id TEXT NOT NULL DEFAULT '',
        call_type TEXT NOT NULL DEFAULT '',
        created_at REAL NOT NULL
    )""")
    try:
        _close_duplicate_live_calls_v249260()
        execute("""
        CREATE UNIQUE INDEX IF NOT EXISTS idx_vcall_one_live_pair_v249260
        ON video_call_sessions(room_id, (CASE WHEN caller_id < callee_id THEN caller_id || ':' || callee_id ELSE callee_id || ':' || caller_id END))
        WHERE status IN ('ringing','accepted','active')
        """)
    except Exception:
        LOG.exception("V249260 live-call unique guard init failed")


def _video_cleanup(room_id: str = ""):
    try:
        init_video_call_db()
        try:
            _close_duplicate_live_calls_v249260(room_id or "")
        except Exception:
            pass
        t = now()
        if room_id:
            execute("DELETE FROM video_call_signals WHERE room_id=? AND created_at<?", (room_id, t - _video_cfg(room_id).get("signal_ttl_seconds", 180)))
            try:
                expired = rows("SELECT * FROM video_call_sessions WHERE room_id=? AND status='ringing' AND expires_at<?", (room_id, t))
                for rr in expired:
                    _insert_missed_call_message_v249230(str(rr['room_id']), str(rr['caller_id']), str(rr['caller_name'] or 'Μέλος'), str(rr['callee_id']), str(rr['call_type'] or 'video'), str(rr['session_id']))
            except Exception:
                LOG.exception("missed notify expired calls failed")
            execute("UPDATE video_call_sessions SET status='expired', updated_at=? WHERE room_id=? AND status IN ('ringing','accepted','active') AND expires_at<?", (t, room_id, t))
        else:
            execute("DELETE FROM video_call_signals WHERE created_at<?", (t - 600,))
            try:
                expired = rows("SELECT * FROM video_call_sessions WHERE status='ringing' AND expires_at<?", (t,))
                for rr in expired:
                    _insert_missed_call_message_v249230(str(rr['room_id']), str(rr['caller_id']), str(rr['caller_name'] or 'Μέλος'), str(rr['callee_id']), str(rr['call_type'] or 'video'), str(rr['session_id']))
            except Exception:
                LOG.exception("missed notify expired calls failed")
            execute("UPDATE video_call_sessions SET status='expired', updated_at=? WHERE status IN ('ringing','accepted','active') AND expires_at<?", (t, t))
    except Exception:
        LOG.exception("video cleanup failed")


def _v249260_pair_expr_sql() -> str:
    return "(CASE WHEN caller_id < callee_id THEN caller_id || ':' || callee_id ELSE callee_id || ':' || caller_id END)"


def _close_duplicate_live_calls_v249260(room_id: str = "") -> dict:
    """Keep one live call row per caller/callee pair and close the rest silently.
    This is a safe cleanup: it touches only video_call_sessions live duplicates,
    never members, posts, files or normal chat messages.
    """
    out = {"closed": 0, "groups": 0}
    try:
        with _ITHAKI_CALL_CLEAN_LOCK_V249260:
            where = "status IN ('ringing','accepted','active')"
            params = []
            if room_id:
                where += " AND room_id=?"
                params.append(str(room_id))
            groups = rows(f"""
                SELECT room_id,
                       { _v249260_pair_expr_sql() } AS pair_key,
                       COUNT(*) AS c
                FROM video_call_sessions
                WHERE {where}
                GROUP BY room_id, pair_key
                HAVING c>1
            """, tuple(params))
            for g in groups:
                out["groups"] += 1
                rs = rows(f"""
                    SELECT * FROM video_call_sessions
                    WHERE room_id=? AND { _v249260_pair_expr_sql() }=?
                      AND status IN ('ringing','accepted','active')
                    ORDER BY CASE status WHEN 'active' THEN 3 WHEN 'accepted' THEN 2 WHEN 'ringing' THEN 1 ELSE 0 END DESC,
                             updated_at DESC
                """, (str(g["room_id"]), str(g["pair_key"])))
                keep = str(rs[0]["session_id"]) if rs else ""
                for rr in rs[1:]:
                    sid = str(rr["session_id"])
                    if sid and sid != keep:
                        execute("UPDATE video_call_sessions SET status='ended', updated_at=? WHERE session_id=?", (now(), sid))
                        out["closed"] += 1
        return out
    except Exception:
        LOG.exception("V249260 duplicate live call cleanup failed")
        return out


def _cleanup_call_timeline_messages_v249260(room_id: str = "ithaki-phone-test") -> dict:
    """Remove duplicate/unwanted call-event cards created by old frontend versions.
    Keeps one server-side missed card per session, deletes outgoing/answered/rejected/ended call cards.
    Does not touch normal text/media messages.
    """
    result = {"deleted": 0, "kept_missed": 0}
    try:
        rs = rows("""
            SELECT message_id, room_id, channel, text, created_at
            FROM messages
            WHERE room_id=? AND text LIKE '%\"kind\":%call_event%'
            ORDER BY created_at ASC
        """, (room_id,))
        seen_missed = set()
        for r in rs:
            txt = str(r["text"] or "")
            mid = str(r["message_id"] or "")
            keep = False
            try:
                obj = json.loads(txt)
                media = obj.get("media") if isinstance(obj, dict) else None
                status = str((media or {}).get("status") or "") if isinstance(media, dict) else ""
                sid = str((media or {}).get("session_id") or "") if isinstance(media, dict) else ""
                if status == "missed" and sid:
                    key = (str(r["room_id"]), str(r["channel"]), sid)
                    if key not in seen_missed:
                        seen_missed.add(key); keep = True; result["kept_missed"] += 1
            except Exception:
                keep = False
            if not keep and mid:
                execute("DELETE FROM messages WHERE message_id=?", (mid,))
                result["deleted"] += 1
        return result
    except Exception:
        LOG.exception("V249260 call timeline message cleanup failed")
        return result


def _video_session_dict(r) -> dict:
    d = dict(r)
    d["time_left_seconds"] = max(0, int(float(d.get("expires_at") or 0) - now()))
    return d


def _video_active_count(room_id: str) -> int:
    _video_cleanup(room_id)
    r = one("SELECT COUNT(*) n FROM video_call_sessions WHERE room_id=? AND status IN ('ringing','accepted','active') AND expires_at>?", (room_id, now()))
    return int(r["n"] if r else 0)


def _video_rate_check(room_id: str, device_id: str, limit: int):
    if not device_id:
        raise HTTPException(400, "device_id required")
    r = one("SELECT COUNT(*) n FROM video_call_signals WHERE room_id=? AND sender_id=? AND created_at>?", (room_id, device_id, now() - 10.0))
    if int(r["n"] if r else 0) > int(limit):
        raise HTTPException(429, "video signaling rate limit")


def _dm_channel_for_v249230(a: str, b: str) -> str:
    aa = str(a or '').strip(); bb = str(b or '').strip()
    return 'dm:' + ':'.join(sorted([aa, bb]))


def _insert_missed_call_message_v249230(room_id: str, caller_id: str, caller_name: str, callee_id: str, call_type: str, session_id: str):
    """Server-side missed-call timeline + FCM. V249259: exactly one missed message/push per session."""
    try:
        channel = _dm_channel_for_v249230(caller_id, callee_id)
        sid = str(session_id or "")[:160]
        media = {"kind": "call_event", "call_kind": "voice" if str(call_type) == "voice" else "video", "status": "missed", "session_id": sid, "created_at": int(now()*1000)}
        text = json.dumps({"body": "", "media": media}, ensure_ascii=False)
        # V249260: database-level once gate. One missed-call message/push per session, even if
        # Android foreground polling, closed-app FCM and cleanup timeout all report together.
        if sid:
            cur = execute("INSERT OR IGNORE INTO missed_call_once_v249260(session_id,room_id,caller_id,callee_id,call_type,created_at) VALUES(?,?,?,?,?,?)", (sid, room_id, caller_id, callee_id, str(call_type or "video"), now()))
            if getattr(cur, "rowcount", 0) == 0:
                dup = one("SELECT message_id FROM messages WHERE room_id=? AND channel=? AND text LIKE ? LIMIT 1", (room_id, channel, '%' + sid + '%'))
                return {"ok": True, "duplicate_prevented": True, "message_id": str(dup["message_id"]) if dup else "", "session_id": sid}
        # Older safety net if a previous build already wrote the same missed card.
        if sid:
            dup = one("SELECT message_id FROM messages WHERE room_id=? AND channel=? AND text LIKE ? LIMIT 1", (room_id, channel, '%' + sid + '%'))
            if dup:
                return {"ok": True, "duplicate_prevented": True, "message_id": str(dup["message_id"]), "session_id": sid}
        mid = nid("msg")
        execute("""
        INSERT INTO messages(message_id,room_id,channel,sender_id,nickname,language,text,is_encrypted,iv,cipher_text,created_at)
        VALUES(?,?,?,?,?,?,?,?,?,?,?)
        """, (mid, room_id, channel, caller_id, caller_name or "Μέλος", "el", text, 0, "", "", now()))
        try:
            ws_notify(room_id)
        except Exception:
            pass
        title = "OK ITHAKI — αναπάντητη κλήση"
        body = f"{caller_name or 'Μέλος'} έκανε αναπάντητη {'φωνητική κλήση' if str(call_type)=='voice' else 'βιντεοκλήση'}"
        data = {"kind": "missed_call", "call_type": str(call_type or "video"), "session_id": sid, "room_id": room_id, "peer_id": caller_id, "channel": channel, "notification_id": "missed_" + sid, "collapse_key": "missed_" + sid, "url": f"https://ok-ithaki.gr/?dm={caller_id}&missed=1"}
        try:
            push_notify_devices(room_id, [callee_id], title, body, data)
        except Exception:
            pass
        return {"ok": True, "message_id": mid, "session_id": sid}
    except Exception:
        LOG.exception("missed call message v249230 failed")
        return {"ok": False, "error": "missed_call_insert_failed"}


@app.get("/api/video/settings")
def api_video_settings(room_id: str = Query("ithaki-phone-test")):
    init_video_call_db(); _video_cleanup(room_id)
    cfg = _video_cfg(room_id)
    return {
        "ok": True,
        "room_id": room_id,
        "enabled": bool(cfg.get("enabled", True)),
        "default_quality": cfg.get("default_quality", "low"),
        "quality_profiles": cfg.get("quality_profiles", {}),
        "ice_servers": cfg.get("ice_servers", []),
        "max_minutes": int(cfg.get("max_minutes", 12)),
        "warning_seconds": int(cfg.get("warning_seconds", 60)),
        "max_concurrent_calls": int(cfg.get("max_concurrent_calls", 20)),
        "active_calls": _video_active_count(room_id),
        "privacy": {"server_records_media": False, "server_stores_audio_video": False, "temporary_signaling_only": True, "note_el": cfg.get("privacy_note_el"), "note_en": cfg.get("privacy_note_en")}
    }


@app.post("/api/video/session/create")
def api_video_session_create(payload: dict):
    init_video_call_db()
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    caller_id = str(payload.get("caller_id") or "")[:120]
    caller_name = str(payload.get("caller_name") or "Member")[:80]
    callee_id = str(payload.get("callee_id") or "")[:120]
    callee_name = str(payload.get("callee_name") or "Member")[:80]
    call_type = "voice" if str(payload.get("call_type") or "").lower() == "voice" else "video"
    quality = str(payload.get("quality") or "")[:40]
    if not caller_id or not callee_id:
        raise HTTPException(400, "caller_id and callee_id required")
    if caller_id == callee_id:
        raise HTTPException(400, "cannot call yourself")
    cfg = _video_cfg(room_id); _video_cleanup(room_id)
    if not bool(cfg.get("enabled", True)):
        raise HTTPException(503, "video calls disabled by server")
    if _video_active_count(room_id) >= int(cfg.get("max_concurrent_calls", 20)):
        raise HTTPException(429, "server call capacity reached")
    if _is_user_blocked_between(room_id, caller_id, callee_id):
        raise HTTPException(403, "blocked by member")
    if not _are_friends_v249227(room_id, caller_id, callee_id):
        raise HTTPException(status_code=403, detail={"ok": False, "reason": "friend_required", "message": "Πρώτα χρειάζεται αποδοχή φιλίας για κλήση."})
    # V249259: one live call creation at a time per server process. This closes the race
    # where Android/open-app/FCM retries could create 2-4 ringing sessions before the
    # first INSERT became visible to the next request.
    with _ITHAKI_CALL_CREATE_LOCK_V249259:
        existing = one("""
            SELECT * FROM video_call_sessions
            WHERE room_id=?
              AND ((caller_id=? AND callee_id=?) OR (caller_id=? AND callee_id=?))
              AND status IN ('ringing','accepted','active') AND expires_at>?
            ORDER BY updated_at DESC LIMIT 1
        """, (room_id, caller_id, callee_id, callee_id, caller_id, now()))
        if existing:
            return {"ok": True, "duplicate_prevented": True, "session": _video_session_dict(existing), "settings": api_video_settings(room_id), "one_ring_policy": True}
        _close_other_pair_ringing_v249251(room_id, caller_id, callee_id, "")
        profiles = cfg.get("quality_profiles", {}) or {}
        if quality not in profiles:
            quality = str(cfg.get("default_quality") or "low")
        prof = profiles.get(quality) or profiles.get("low") or {"width": 320, "height": 240, "fps": 12, "max_bitrate_kbps": 220}
        t = now(); sid = nid("vcall")
        max_minutes = int(payload.get("max_minutes") or cfg.get("max_minutes", 12))
        max_minutes = max(1, min(max_minutes, int(cfg.get("max_minutes", 12))))
        ring_timeout_seconds = int(payload.get("ring_timeout_seconds") or 60)
        ring_timeout_seconds = max(30, min(ring_timeout_seconds, 90))
        # V249209/V249259: ringing expires in 1 minute if the callee does not answer.
        # When accepted, /respond extends expires_at to the normal call duration.
        try:
            execute("""
            INSERT INTO video_call_sessions(session_id,room_id,caller_id,caller_name,callee_id,callee_name,call_type,status,quality,max_minutes,max_bitrate_kbps,video_width,video_height,fps,created_at,updated_at,expires_at)
            VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
            """, (sid, room_id, caller_id, caller_name, callee_id, callee_name, call_type, "ringing", quality, max_minutes, int(prof.get("max_bitrate_kbps", 220)), int(prof.get("width", 320)), int(prof.get("height", 240)), int(prof.get("fps", 12)), t, t, t + ring_timeout_seconds))
        except sqlite3.IntegrityError:
            existing = one("""
                SELECT * FROM video_call_sessions
                WHERE room_id=?
                  AND ((caller_id=? AND callee_id=?) OR (caller_id=? AND callee_id=?))
                  AND status IN ('ringing','accepted','active')
                ORDER BY updated_at DESC LIMIT 1
            """, (room_id, caller_id, callee_id, callee_id, caller_id))
            if existing:
                return {"ok": True, "duplicate_prevented": True, "session": _video_session_dict(existing), "settings": api_video_settings(room_id), "one_ring_policy": True, "db_unique_guard": True}
            raise
    try:
        label = "φωνητική κλήση" if call_type == "voice" else "βιντεοκλήση"
        call_title = f"OK ITHAKI — {label}"
        call_body = f"{caller_name or 'Μέλος'} σε καλεί"
        try:
            secure_call_push_v249389(room_id, callee_id, caller_id, sid, call_type)
        except Exception:
            LOG.exception("V249389 secure call wake failed")
    except Exception:
        pass
    r = one("SELECT * FROM video_call_sessions WHERE session_id=?", (sid,))
    return {"ok": True, "session": _video_session_dict(r), "settings": api_video_settings(room_id)}


@app.get("/api/video/session/incoming")
def api_video_session_incoming(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    init_video_call_db(); _video_cleanup(room_id)
    if not device_id:
        return {"ok": True, "sessions": []}
    rs = rows("""
        SELECT * FROM video_call_sessions
        WHERE room_id=? AND callee_id=? AND status IN ('ringing','accepted','active') AND expires_at>?
        ORDER BY updated_at DESC LIMIT 5
    """, (room_id, device_id, now()))
    # V249259: if an older version already left several live rows, keep only the newest
    # visible incoming call and silently close/cancel the rest so the phone rings once.
    try:
        if len(rs) > 1:
            keep_sid = str(rs[0]["session_id"])
            for old in rs[1:]:
                execute("UPDATE video_call_sessions SET status='ended', updated_at=? WHERE session_id=?", (now(), str(old["session_id"])))
                _push_call_control_v249251(room_id, [str(old["caller_id"]), str(old["callee_id"])], str(old["session_id"]), "call_cancel", str(old["call_type"] or "video"), str(old["caller_id"] or ""))
            rs = rs[:1]
    except Exception:
        LOG.exception("V249259 incoming duplicate call cleanup failed")
    return {"ok": True, "sessions": [_video_session_dict(r) for r in rs[:1]], "settings": api_video_settings(room_id), "one_ring_policy": True}


@app.get("/api/video/session/status")
def api_video_session_status(room_id: str = Query("ithaki-phone-test"), session_id: str = Query(""), device_id: str = Query("")):
    init_video_call_db(); _video_cleanup(room_id)
    r = one("SELECT * FROM video_call_sessions WHERE room_id=? AND session_id=?", (room_id, session_id))
    if not r:
        raise HTTPException(404, "session not found")
    if device_id and device_id not in (str(r["caller_id"]), str(r["callee_id"])):
        raise HTTPException(403, "not your session")
    return {"ok": True, "session": _video_session_dict(r), "settings": api_video_settings(room_id)}


@app.post("/api/video/session/respond")
def api_video_session_respond(payload: dict):
    init_video_call_db()
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    sid = str(payload.get("session_id") or "")[:160]
    device_id = str(payload.get("device_id") or "")[:120]
    accepted = bool(payload.get("accepted", False))
    r = one("SELECT * FROM video_call_sessions WHERE room_id=? AND session_id=?", (room_id, sid))
    if not r:
        raise HTTPException(404, "session not found")
    if device_id not in (str(r["caller_id"]), str(r["callee_id"])):
        raise HTTPException(403, "not your session")
    status = "accepted" if accepted else "rejected"
    t = now()
    if accepted:
        # V249209: after answer, restore normal call duration from max_minutes.
        execute("UPDATE video_call_sessions SET status=?, updated_at=?, expires_at=? WHERE session_id=?", (status, t, t + int(r["max_minutes"] or 12) * 60, sid))
        _close_other_pair_ringing_v249251(room_id, str(r["caller_id"]), str(r["callee_id"]), sid)
        _push_call_control_v249251(room_id, [str(r["caller_id"]), str(r["callee_id"])], sid, "call_answered", str(r["call_type"] or "video"), str(r["caller_id"] or ""))
    else:
        execute("UPDATE video_call_sessions SET status=?, updated_at=? WHERE session_id=?", (status, t, sid))
        _push_call_control_v249251(room_id, [str(r["caller_id"]), str(r["callee_id"])], sid, "call_cancel", str(r["call_type"] or "video"), str(r["caller_id"] or ""))
    return {"ok": True, "session_id": sid, "status": status}


@app.post("/api/video/session/ping")
def api_video_session_ping(payload: dict):
    init_video_call_db()
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    sid = str(payload.get("session_id") or "")[:160]
    device_id = str(payload.get("device_id") or "")[:120]
    r = one("SELECT * FROM video_call_sessions WHERE room_id=? AND session_id=?", (room_id, sid))
    if not r:
        raise HTTPException(404, "session not found")
    if device_id not in (str(r["caller_id"]), str(r["callee_id"])):
        raise HTTPException(403, "not your session")
    if str(r["status"]) in ("ringing", "accepted"):
        execute("UPDATE video_call_sessions SET status='active', updated_at=? WHERE session_id=?", (now(), sid))
    else:
        execute("UPDATE video_call_sessions SET updated_at=? WHERE session_id=?", (now(), sid))
    return {"ok": True, "time_left_seconds": max(0, int(float(r["expires_at"])-now()))}


@app.post("/api/video/session/end")
def api_video_session_end(payload: dict):
    init_video_call_db()
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    sid = str(payload.get("session_id") or "")[:160]
    device_id = str(payload.get("device_id") or "")[:120]
    r = one("SELECT * FROM video_call_sessions WHERE room_id=? AND session_id=?", (room_id, sid))
    if not r:
        return {"ok": True, "ended": True}
    if device_id and device_id not in (str(r["caller_id"]), str(r["callee_id"])):
        raise HTTPException(403, "not your session")
    reason = str(payload.get("reason") or "").lower()
    old_status = str(r["status"] or "")
    if old_status == "ringing" and ("missed" in reason or "timeout" in reason):
        try:
            _insert_missed_call_message_v249230(room_id, str(r["caller_id"]), str(r["caller_name"] or "Μέλος"), str(r["callee_id"]), str(r["call_type"] or "video"), sid)
        except Exception:
            pass
        execute("UPDATE video_call_sessions SET status='expired', updated_at=? WHERE session_id=?", (now(), sid))
    else:
        execute("UPDATE video_call_sessions SET status='ended', updated_at=? WHERE session_id=?", (now(), sid))
    try:
        _push_call_control_v249251(room_id, [str(r["caller_id"]), str(r["callee_id"])], sid, "call_cancel", str(r["call_type"] or "video"), str(r["caller_id"] or ""))
        _close_other_pair_ringing_v249251(room_id, str(r["caller_id"]), str(r["callee_id"]), sid)
    except Exception:
        pass
    execute("DELETE FROM video_call_signals WHERE session_id=?", (sid,))
    return {"ok": True, "ended": True, "previous_status": old_status, "missed_message_only": old_status == "ringing" and ("missed" in reason or "timeout" in reason)}


def _video_store_signal(room_id: str, sid: str, sender_id: str, target_id: str, signal_type: str, raw_payload, notify_ws: bool = True):
    init_video_call_db()
    room_id = str(room_id or "ithaki-phone-test")[:120]
    sid = str(sid or "")[:160]
    sender_id = str(sender_id or "")[:120]
    target_id = str(target_id or "")[:120]
    signal_type = str(signal_type or "")[:40]
    raw_payload = raw_payload or {}
    cfg = _video_cfg(room_id); _video_cleanup(room_id)
    _video_rate_check(room_id, sender_id, int(cfg.get("max_signals_per_10s", 80)))
    if signal_type not in {"offer", "answer", "ice", "bye"}:
        raise HTTPException(400, "invalid signal_type")
    r = one("SELECT * FROM video_call_sessions WHERE room_id=? AND session_id=?", (room_id, sid))
    if not r:
        raise HTTPException(404, "session not found")
    members = {str(r["caller_id"]), str(r["callee_id"])}
    if sender_id not in members or target_id not in members or sender_id == target_id:
        raise HTTPException(403, "not your video session")
    payload_text = json.dumps(raw_payload, ensure_ascii=False)
    if len(payload_text) > 160000:
        raise HTTPException(413, "signal too large")
    created_at = now()
    signal_id = nid("sig")
    execute("""
    INSERT INTO video_call_signals(signal_id,session_id,room_id,sender_id,target_id,signal_type,payload,created_at)
    VALUES(?,?,?,?,?,?,?,?)
    """, (signal_id, sid, room_id, sender_id, target_id, signal_type, payload_text, created_at))
    frame = {"signal_id": signal_id, "session_id": sid, "room_id": room_id, "sender_id": sender_id, "target_id": target_id, "signal_type": signal_type, "payload": raw_payload, "created_at": created_at}
    if notify_ws:
        try:
            ws_call_signal_notify(target_id, frame)
        except Exception:
            pass
    return frame


@app.post("/api/video/signal/send")
def api_video_signal_send(payload: dict):
    _video_store_signal(str(payload.get("room_id") or "ithaki-phone-test"), str(payload.get("session_id") or ""), str(payload.get("sender_id") or ""), str(payload.get("target_id") or ""), str(payload.get("signal_type") or ""), payload.get("payload") or {}, notify_ws=True)
    return {"ok": True, "ws_call_signaling": True}


@app.get("/api/video/signal/poll")
def api_video_signal_poll(room_id: str = Query("ithaki-phone-test"), session_id: str = Query(""), device_id: str = Query(""), after: float = Query(0.0)):
    init_video_call_db(); _video_cleanup(room_id)
    r = one("SELECT * FROM video_call_sessions WHERE room_id=? AND session_id=?", (room_id, session_id))
    if not r:
        return {"ok": False, "signals": [], "status": "missing"}
    if device_id not in (str(r["caller_id"]), str(r["callee_id"])):
        raise HTTPException(403, "not your session")
    rs = rows("""
        SELECT * FROM video_call_signals
        WHERE room_id=? AND session_id=? AND target_id=? AND created_at>?
        ORDER BY created_at ASC LIMIT 80
    """, (room_id, session_id, device_id, float(after or 0)))
    out = []
    for x in rs:
        try:
            pl = json.loads(x["payload"] or "{}")
        except Exception:
            pl = {}
        out.append({"signal_id": x["signal_id"], "sender_id": x["sender_id"], "signal_type": x["signal_type"], "payload": pl, "created_at": x["created_at"]})
    return {"ok": True, "signals": out, "status": r["status"], "session": _video_session_dict(r)}





@app.post("/api/video/cleanup/v249260")
def api_video_cleanup_v249260(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    init_video_call_db()
    calls = _close_duplicate_live_calls_v249260(room_id)
    msgs = _cleanup_call_timeline_messages_v249260(room_id)
    return {"ok": True, "version": "V249260-call-clean-one-ring-no-dupes", "closed_duplicate_calls": calls, "cleaned_call_messages": msgs}


@app.get("/api/video/diagnostics/v249230")
def api_video_diagnostics_v249230(room_id: str = Query("ithaki-phone-test")):
    init_video_call_db(); _video_cleanup(room_id)
    cfg = _video_cfg(room_id); fcm = _fcm_cfg()
    active = rows("SELECT status,call_type,COUNT(*) c FROM video_call_sessions WHERE room_id=? GROUP BY status,call_type", (room_id,))
    return {
        "ok": True,
        "version": "V249230-real-webrtc-turn-fullscreen-calls",
        "webrtc_audio_fix": "getUserMedia audio, addTrack, addTransceiver sendrecv, remote ontrack fallback MediaStream, audio unlock button, Android call audio mode",
        "missed_call_policy": "Only missed/unanswered calls create chat messages and missed_call FCM. Outgoing/answered/rejected/ended are not written as chat messages.",
        "fcm_enabled": bool(fcm.get("enabled")),
        "fcm_project_id": str(fcm.get("project_id") or ""),
        "ice_servers": cfg.get("ice_servers", []),
        "call_counts": [dict(x) for x in active],
    }

@app.get("/api/diagnostics/v249230")
def api_diagnostics_v249230(room_id: str = Query("ithaki-phone-test")):
    fcm = _fcm_cfg()
    return {"ok": True, "version": "V249230-real-webrtc-turn-fullscreen-calls", "fcm_enabled": bool(fcm.get("enabled")), "fcm_project_id": str(fcm.get("project_id") or ""), "policy": "Professional deep correction for video/voice call audio and missed-call-only messages."}

@app.post("/api/admin/video/settings/save")
def api_admin_video_settings_save(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    cfg = _video_cfg(str(payload.get("room_id") or "ithaki-phone-test"))
    allowed = {"enabled", "default_quality", "max_minutes", "warning_seconds", "signal_ttl_seconds", "session_ttl_seconds", "max_concurrent_calls", "max_signals_per_10s", "ice_servers", "quality_profiles"}
    for k, v in payload.items():
        if k in allowed:
            cfg[k] = v
    VIDEO_CALL_CONFIG_PATH.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
    return {"ok": True, "config": cfg}


# ITHAKI V24673 NO CACHE MIDDLEWARE
# Ελληνικό σχόλιο:
# Για να μη φορτώνει παλιό index.js/index.html από browser cache,
# στα βασικά frontend αρχεία στέλνουμε no-store.
@app.middleware("http")
async def ithaki_v24673_no_cache_middleware(request, call_next):
    response = await call_next(request)
    try:
        p = request.url.path.lower()
        if p in ("", "/", "/index.html", "/favicon.ico") or p.endswith((".js", ".css", ".html")):
            response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
            response.headers["Pragma"] = "no-cache"
            response.headers["Expires"] = "0"
        response.headers["X-ITHAKI-BUILD"] = "249342-inline-js-public-final"
    except Exception:
        pass
    return response

@app.get("/api/version_v24673")
def api_version_v24673():
    return {"ok": True, "build": "24673", "port": 8011, "cache": "no-store"}



@app.get("/api/version_v24674")
def api_version_v24674():
    return {"ok": True, "build": "24674", "port": 8011, "fix": "flow scroll mobile mouse + big round member photos"}



# ITHAKI V24675 MOBILE LAN/TUNNEL CHECK
@app.get("/api/version_v24675")
def api_version_v24675():
    return {"ok": True, "build": "24675", "port": 8011, "mobile": "LAN-IP:8011", "tunnel": "optional-ngrok"}


# ITHAKI V24678 CLEAN FULL PRO ROUTES
@app.get("/api/version_v24678")
def api_version_v24678():
    return {"ok": True, "build": "24678", "port": 8011, "clean_full": True, "fixes": ["safe_ngrok_token", "mobile_flow", "mobile_contacts", "server_control_pro"]}

@app.get("/mobile_test", response_class=HTMLResponse)
def mobile_test_alias_v24678(request: Request):
    origin = _request_public_origin(request) or ""
    html = ("<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>"
            "<title>OK ITHAKI Mobile Test</title></head><body style='font-family:Arial;background:#06101f;color:white;padding:20px;line-height:1.45'>"
            "<h2>OK ITHAKI Mobile Test</h2><p>Ο server απαντάει από αυτή τη διεύθυνση:</p>"
            "<p style='font-size:18px;color:#facc15'><b>" + origin + "</b></p>"
            "<p>Αν αυτή η σελίδα άνοιξε από κινητό, τότε το ίδιο Wi-Fi δουλεύει.</p>"
            "<p><a style='color:#8df;font-size:18px' href='/?v=24794&server=" + origin + "&apk=0&t=1'>Άνοιγμα εφαρμογής ΙΘΑΚΗ</a></p>"
            "<p><a style='color:#8df' href='/api/app/bootstrap'>/api/app/bootstrap</a> · <a style='color:#8df' href='/health'>/health</a></p>"
            "<hr style='border-color:#334155'><p>Αν το APK δεν φορτώνει, κάνε rebuild αφού τρέξεις: <b>72_PREPARE_APK_WIFI_FIRST_TUNNEL_V24794.cmd</b></p>"
            "</body></html>")
    return HTMLResponse(html)

@app.get("/api/mobile/connection_hint")
def api_mobile_connection_hint(request: Request):
    origin = _request_public_origin(request) or ""
    return {"ok": True, "version": "24794", "request_api_url": origin, "open_app_url": (origin + "/?v=24794") if origin else "", "mobile_test": (origin + "/mobile_test") if origin else "", "rule": "same Wi-Fi/LAN first; domain/VPS/ngrok only after LAN fails"}

@app.get("/server_connection_control", response_class=HTMLResponse)
def server_connection_control_alias_v24678():
    return HTMLResponse('''<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>OK ITHAKI Server Control</title></head><body style="font-family:Arial;background:#06101f;color:white;padding:20px"><h2>OK ITHAKI Server Connection Control</h2><p>Η παλιά διεύθυνση δουλεύει σαν alias.</p><p><a style="color:#8df" href="/?v=24678&screen=server&t=1">Άνοιγμα control μέσα στην εφαρμογή</a></p><p><a style="color:#8df" href="/server-admin">Server Admin</a></p><p><a style="color:#8df" href="/api/app/connection">Connection API</a></p></body></html>''')


# ITHAKI V24691Y feedback server routes
class FeedbackIn(BaseModel):
    room_id: str = Field(default="ithaki-phone-test", max_length=120)
    device_id: str = Field(default="", max_length=120)
    nickname: str = Field(default="", max_length=80)
    screen: str = Field(default="home", max_length=80)
    message: str = Field(default="", max_length=10000)
    user_agent: str = Field(default="", max_length=500)

class FeedbackReplyIn(BaseModel):
    admin_key: str = Field(default="", max_length=300)
    room_id: str = Field(default="ithaki-phone-test", max_length=120)
    feedback_id: str = Field(default="", max_length=120)
    target_device_id: str = Field(default="", max_length=120)
    target_nickname: str = Field(default="", max_length=80)
    reply: str = Field(default="", max_length=10000)

def _ithaki_feedback_db():
    execute("""
    CREATE TABLE IF NOT EXISTS member_feedback(
        feedback_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL DEFAULT '',
        device_id TEXT NOT NULL DEFAULT '',
        nickname TEXT NOT NULL DEFAULT '',
        screen TEXT NOT NULL DEFAULT '',
        message TEXT NOT NULL DEFAULT '',
        user_agent TEXT NOT NULL DEFAULT '',
        status TEXT NOT NULL DEFAULT 'open',
        admin_reply TEXT NOT NULL DEFAULT '',
        reply_message_id TEXT NOT NULL DEFAULT '',
        created_at REAL NOT NULL,
        updated_at REAL NOT NULL
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_member_feedback_room_created ON member_feedback(room_id, created_at)")

@app.post("/api/feedback/send")
def api_feedback_send(p: FeedbackIn):
    _ithaki_feedback_db()
    security_member_guard(p.room_id, p.device_id, "feedback_member", max_per_minute=3, max_per_hour=20)
    msg=(p.message or '').strip()
    if not msg:
        raise HTTPException(400, "Empty feedback")
    humanguard_check_or_raise(p.room_id, p.device_id, p.nickname, "feedback", msg)
    fid=nid("fb")
    t=now()
    execute("""
    INSERT INTO member_feedback(feedback_id,room_id,device_id,nickname,screen,message,user_agent,status,created_at,updated_at)
    VALUES(?,?,?,?,?,?,?,?,?,?)
    """, (fid,p.room_id,p.device_id,p.nickname,p.screen,msg,(p.user_agent or '')[:500],'open',t,t))
    return {"ok": True, "feedback_id": fid}

@app.get("/api/admin/feedback")
def api_admin_feedback(admin_key: str = Query(""), room_id: str = Query("ithaki-phone-test"), limit: int = Query(200, ge=1, le=500)):
    _require_admin(admin_key)
    _ithaki_feedback_db()
    result=[]
    for r in rows("SELECT * FROM member_feedback WHERE room_id=? ORDER BY created_at DESC LIMIT ?", (room_id, limit)):
        result.append(dict(r))
    return {"ok": True, "feedback": result}

@app.post("/api/admin/feedback/reply")
def api_admin_feedback_reply(p: FeedbackReplyIn):
    _require_admin(p.admin_key)
    _ithaki_feedback_db()
    text=(p.reply or '').strip()
    if not text:
        raise HTTPException(400, "Empty reply")
    target=(p.target_device_id or '').strip()
    if not target and p.feedback_id:
        fb=one("SELECT device_id,nickname FROM member_feedback WHERE feedback_id=? AND room_id=?", (p.feedback_id,p.room_id))
        if fb:
            target=str(fb["device_id"] or "")
            if not p.target_nickname:
                p.target_nickname=str(fb["nickname"] or "")
    if not target:
        raise HTTPException(400, "No target member")
    admin_id="server_admin"
    mid=nid("msg")
    channel="dm:"+":".join(sorted([admin_id,target]))
    body="Απάντηση διαχειριστή ΙΘΑΚΗΣ:\n"+text
    execute("""
    INSERT INTO messages(message_id,room_id,channel,sender_id,nickname,language,text,is_encrypted,iv,cipher_text,created_at)
    VALUES(?,?,?,?,?,?,?,?,?,?,?)
    """, (mid,p.room_id,channel,admin_id,"Administrator ΙΘΑΚΗΣ","el",body,0,"","",now()))
    execute("UPDATE member_feedback SET status='answered', admin_reply=?, reply_message_id=?, updated_at=? WHERE feedback_id=? AND room_id=?", (text,mid,now(),p.feedback_id,p.room_id))
    try:
        push_notify(p.room_id, channel, admin_id, "Administrator ΙΘΑΚΗΣ", body, mid)
    except Exception:
        pass
    return {"ok": True, "message_id": mid, "channel": channel}


# ITHAKI V24718: Admin Flow Editor + public safety/legal pages
from fastapi.responses import HTMLResponse as _ITH24718_HTMLResponse

@app.get("/api/admin/flow/list")
def api_admin_flow_list_v24718(admin_key: str = Query(""), room_id: str = Query("ithaki-phone-test"), limit: int = Query(150, ge=1, le=500), host_only: int = Query(0, ge=0, le=1)):
    _require_admin(admin_key)
    if int(host_only or 0):
        rs = rows("""
            SELECT post_id,room_id,sender_id,nickname,language,text,post_kind,anonymous,visibility,created_at
            FROM flow_posts
            WHERE room_id=? AND (sender_id IN ('server_admin','ithaki_auto_host','ithaki_host') OR lower(nickname) LIKE '%ithaki%' OR nickname LIKE '%ΙΘΑΚΗ%')
            ORDER BY created_at DESC LIMIT ?
        """, (room_id, limit))
    else:
        rs = rows("""
            SELECT post_id,room_id,sender_id,nickname,language,text,post_kind,anonymous,visibility,created_at
            FROM flow_posts WHERE room_id=? ORDER BY created_at DESC LIMIT ?
        """, (room_id, limit))
    return {"ok": True, "posts": [dict(r) for r in rs]}

@app.post("/api/admin/flow/update")
def api_admin_flow_update_v24718(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    post_id = str(payload.get("post_id") or "").strip()
    if not post_id:
        raise HTTPException(400, "post_id required")
    r = one("SELECT * FROM flow_posts WHERE room_id=? AND post_id=?", (room_id, post_id))
    if not r:
        raise HTTPException(404, "post not found")
    raw = str(r["text"] or "")
    new_title = str(payload.get("title") or "").strip()[:180]
    new_text = str(payload.get("text") or "").strip()
    new_visibility = str(payload.get("visibility") or r["visibility"] or "public")[:40]
    try:
        obj = json.loads(raw)
        if isinstance(obj, dict):
            if new_title:
                obj["title"] = new_title
            obj["text"] = new_text
            obj["body"] = new_text
            obj["edited_by_admin"] = True
            obj["edited_at"] = now()
            safe = json.dumps(obj, ensure_ascii=False)
        else:
            safe = new_text
    except Exception:
        safe = new_text
    execute("UPDATE flow_posts SET text=?, visibility=? WHERE room_id=? AND post_id=?", (safe, new_visibility, room_id, post_id))
    try:
        admin_audit(room_id, 'flow_update', 'server_admin', {'post_id': post_id, 'visibility': new_visibility})
    except Exception:
        pass
    return {"ok": True, "post_id": post_id}

@app.post("/api/admin/flow/delete_any")
def api_admin_flow_delete_any_v24718(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    post_id = str(payload.get("post_id") or "").strip()
    if not post_id:
        raise HTTPException(400, "post_id required")
    execute("DELETE FROM reactions WHERE target_id=?", (post_id,))
    execute("DELETE FROM messages WHERE room_id=? AND channel=?", (room_id, "comment:" + post_id))
    execute("DELETE FROM flow_posts WHERE room_id=? AND post_id=?", (room_id, post_id))
    try:
        admin_audit(room_id, 'flow_delete_any', 'server_admin', {'post_id': post_id})
    except Exception:
        pass
    return {"ok": True, "deleted": True, "post_id": post_id}

@app.get("/admin/flow-editor", response_class=_ITH24718_HTMLResponse)
def admin_flow_editor_v24718():
    p = ROOT / "admin" / "flow_editor.html"
    if p.exists():
        return _ITH24718_HTMLResponse(p.read_text(encoding='utf-8', errors='replace'), headers={"Cache-Control":"no-store"})
    return _ITH24718_HTMLResponse("<h1>flow_editor.html missing</h1>")

@app.get("/ithaki-safety", response_class=_ITH24718_HTMLResponse)
def ithaki_safety_page_v24718():
    p = ROOT / "public" / "ithaki_safety.html"
    if p.exists():
        return _ITH24718_HTMLResponse(p.read_text(encoding='utf-8', errors='replace'), headers={"Cache-Control":"no-store"})
    return _ITH24718_HTMLResponse("<h1>ithaki_safety.html missing</h1>")

@app.get("/privacy", response_class=_ITH24718_HTMLResponse)
def ithaki_privacy_page_v24718():
    return ithaki_safety_page_v24718()

@app.get("/terms", response_class=_ITH24718_HTMLResponse)
def ithaki_terms_page_v24718():
    return ithaki_safety_page_v24718()
# ITHAKI V24718 END




# ITHAKI V24767 download page route
@app.get("/downloads", response_class=HTMLResponse)
def ithaki_downloads_page_v24767():
    p = ROOT / "public" / "downloads.html"
    if p.exists():
        return HTMLResponse(p.read_text(encoding="utf-8", errors="replace"), headers={"Cache-Control":"no-store"})
    return HTMLResponse("<h1>OK ITHAKI downloads</h1><p>APK build is prepared from Android Studio.</p>")

@app.get("/downloads/", response_class=HTMLResponse)
def ithaki_downloads_page_slash_v24767():
    return ithaki_downloads_page_v24767()
# END ITHAKI V24767 download page route

# ITHAKI V24720 public marketing/safety page
@app.get("/ok-ithaki", response_class=_ITH24718_HTMLResponse)
def ok_ithaki_public_landing_v24720():
    p = ROOT / "public" / "ithaki_safety.html"
    if p.exists():
        return _ITH24718_HTMLResponse(p.read_text(encoding="utf-8", errors="replace"), headers={"Cache-Control":"no-store"})
    return _ITH24718_HTMLResponse("<h1>OK ITHAKI public page missing</h1>")

@app.get("/site", response_class=_ITH24718_HTMLResponse)
def ok_ithaki_public_site_alias_v24720():
    return ok_ithaki_public_landing_v24720()
# ITHAKI V24720 end


# V24791 mobile LAN/tunnel helper headers
V24791_MOBILE_NO_CACHE_MIDDLEWARE = True
@app.middleware("http")
async def ithaki_v24791_mobile_headers(request: Request, call_next):
    response = await call_next(request)
    try:
        response.headers["Access-Control-Allow-Origin"] = "*"
        response.headers["Access-Control-Allow-Headers"] = "*"
        response.headers["Access-Control-Allow-Methods"] = "GET,POST,OPTIONS"
        if str(request.url.path).startswith(("/api/app", "/health", "/mobile_test")):
            response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
    except Exception:
        pass
    return response


# ITHAKI V24863 Feed settings / persistent engagement / comments / polls
# Safe additive routes: no schema changes to existing tables.
def _ith24863_tables():
    _app_state_ensure()
    execute("CREATE TABLE IF NOT EXISTS feed_engagement_v24863(room_id TEXT NOT NULL, post_id TEXT NOT NULL, likes INTEGER NOT NULL DEFAULT 0, saves INTEGER NOT NULL DEFAULT 0, shares INTEGER NOT NULL DEFAULT 0, updated_at_ms INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(room_id,post_id))")
    execute("CREATE TABLE IF NOT EXISTS feed_comments_v24863(comment_id TEXT PRIMARY KEY, room_id TEXT NOT NULL, post_id TEXT NOT NULL, parent_id TEXT NOT NULL DEFAULT '', device_id TEXT NOT NULL DEFAULT '', nickname TEXT NOT NULL DEFAULT '', text TEXT NOT NULL DEFAULT '', created_at_ms INTEGER NOT NULL DEFAULT 0)")
    execute("CREATE INDEX IF NOT EXISTS idx_feed_comments_v24863_post ON feed_comments_v24863(room_id,post_id,created_at_ms)")
    execute("CREATE TABLE IF NOT EXISTS feed_poll_votes_v24863(room_id TEXT NOT NULL, poster_id TEXT NOT NULL, device_id TEXT NOT NULL, choice TEXT NOT NULL, updated_at_ms INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(room_id,poster_id,device_id))")

@app.get('/api/feed/v24863/settings')
def ith24863_feed_settings(room_id: str = 'ithaki-phone-test'):
    _ith24863_tables()
    ms = app_state_get_int('ithaki_feed_poster_interval_ms', 600000)
    if ms < 60000:
        ms = 600000
    return {'ok': True, 'room_id': room_id, 'poster_interval_ms': ms, 'poster_interval_minutes': round(ms/60000, 2)}

@app.post('/api/feed/v24863/settings')
async def ith24863_feed_settings_set(request: Request):
    _ith24863_tables()
    data = await request.json()
    minutes = float(data.get('poster_interval_minutes') or data.get('minutes') or 10)
    if minutes < 1:
        minutes = 1
    if minutes > 24*60:
        minutes = 24*60
    ms = int(minutes * 60000)
    app_state_set_int('ithaki_feed_poster_interval_ms', ms, str(minutes))
    return {'ok': True, 'poster_interval_ms': ms, 'poster_interval_minutes': minutes}

@app.post('/api/feed/v24863/stats')
async def ith24863_feed_stats(request: Request):
    _ith24863_tables()
    data = await request.json()
    room_id = str(data.get('room_id') or 'ithaki-phone-test')[:120]
    post_ids = data.get('post_ids') or []
    if not isinstance(post_ids, list):
        post_ids = []
    post_ids = [str(x)[:200] for x in post_ids[:200] if str(x)]
    engagement, comments, polls = {}, {}, {}
    for pid in post_ids:
        r = one('SELECT likes,saves,shares FROM feed_engagement_v24863 WHERE room_id=? AND post_id=?', (room_id, pid))
        engagement[pid] = {'likes': int(r['likes']) if r else 0, 'saves': int(r['saves']) if r else 0, 'shares': int(r['shares']) if r else 0}
        c = one('SELECT COUNT(*) n FROM feed_comments_v24863 WHERE room_id=? AND post_id=?', (room_id, pid))
        comments[pid] = int(c['n'] if c else 0)
        vr = rows('SELECT choice, COUNT(*) n FROM feed_poll_votes_v24863 WHERE room_id=? AND poster_id=? GROUP BY choice', (room_id, pid))
        if vr:
            polls[pid] = {'counts': {str(x['choice']): int(x['n']) for x in vr}}
    return {'ok': True, 'engagement': engagement, 'comments': comments, 'polls': polls}

@app.post('/api/feed/v24863/engagement')
async def ith24863_feed_engagement(request: Request):
    _ith24863_tables()
    data = await request.json()
    room_id = str(data.get('room_id') or 'ithaki-phone-test')[:120]
    post_id = str(data.get('post_id') or '')[:200]
    action = str(data.get('action') or '').lower()
    if not post_id or action not in ('like','save','share'):
        return {'ok': False, 'error': 'bad_request'}
    col = {'like':'likes','save':'saves','share':'shares'}[action]
    now = int(time.time()*1000)
    execute('INSERT OR IGNORE INTO feed_engagement_v24863(room_id,post_id,updated_at_ms) VALUES(?,?,?)', (room_id, post_id, now))
    execute(f'UPDATE feed_engagement_v24863 SET {col}={col}+1, updated_at_ms=? WHERE room_id=? AND post_id=?', (now, room_id, post_id))
    r = one('SELECT likes,saves,shares FROM feed_engagement_v24863 WHERE room_id=? AND post_id=?', (room_id, post_id))
    return {'ok': True, 'engagement': {'likes': int(r['likes']), 'saves': int(r['saves']), 'shares': int(r['shares'])}}

@app.get('/api/feed/v24863/comments')
def ith24863_feed_comments(room_id: str='ithaki-phone-test', post_id: str=''):
    _ith24863_tables()
    room_id = str(room_id)[:120]; post_id = str(post_id)[:200]
    rs = rows('SELECT comment_id,parent_id,nickname,text,created_at_ms FROM feed_comments_v24863 WHERE room_id=? AND post_id=? ORDER BY created_at_ms ASC LIMIT 300', (room_id, post_id))
    return {'ok': True, 'comments': [dict(x) for x in rs]}

@app.post('/api/feed/v24863/comments')
async def ith24863_feed_comments_add(request: Request):
    _ith24863_tables()
    data = await request.json()
    room_id = str(data.get('room_id') or 'ithaki-phone-test')[:120]
    post_id = str(data.get('post_id') or '')[:200]
    parent_id = str(data.get('parent_id') or '')[:200]
    device_id = str(data.get('device_id') or '')[:200]
    nickname = str(data.get('nickname') or 'Member')[:80]
    text = str(data.get('text') or '').strip()[:2000]
    if not post_id or not text:
        return {'ok': False, 'error': 'bad_request'}
    cid = 'c_' + secrets.token_hex(8)
    now = int(time.time()*1000)
    execute('INSERT INTO feed_comments_v24863(comment_id,room_id,post_id,parent_id,device_id,nickname,text,created_at_ms) VALUES(?,?,?,?,?,?,?,?)', (cid, room_id, post_id, parent_id, device_id, nickname, text, now))
    return {'ok': True, 'comment': {'comment_id': cid, 'parent_id': parent_id, 'nickname': nickname, 'text': text, 'created_at_ms': now}}

@app.post('/api/feed/v24863/poll/vote')
async def ith24863_feed_poll_vote(request: Request):
    _ith24863_tables()
    data = await request.json()
    room_id = str(data.get('room_id') or 'ithaki-phone-test')[:120]
    poster_id = str(data.get('poster_id') or '')[:200]
    device_id = str(data.get('device_id') or 'web')[:200]
    choice = str(data.get('choice') or '')[:120]
    if not poster_id or not choice:
        return {'ok': False, 'error': 'bad_request'}
    now = int(time.time()*1000)
    execute('INSERT OR REPLACE INTO feed_poll_votes_v24863(room_id,poster_id,device_id,choice,updated_at_ms) VALUES(?,?,?,?,?)', (room_id, poster_id, device_id, choice, now))
    vr = rows('SELECT choice, COUNT(*) n FROM feed_poll_votes_v24863 WHERE room_id=? AND poster_id=? GROUP BY choice', (room_id, poster_id))
    return {'ok': True, 'poll': {'counts': {str(x['choice']): int(x['n']) for x in vr}}}

@app.get('/server-admin/feed-timing', response_class=HTMLResponse)
def ith24863_feed_timing_admin():
    ms = app_state_get_int('ithaki_feed_poster_interval_ms', 600000)
    minutes = round(ms/60000,2)
    html = f"""<!doctype html><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>
    <title>ΙΘΑΚΗ Feed Timing</title><body style='font-family:system-ui;background:#06101d;color:white;padding:24px'>
    <h1>ΙΘΑΚΗ - Ρύθμιση Ροής</h1><p>Τρέχουσα εναλλαγή poster: <b>{minutes}</b> λεπτά.</p>
    <input id='m' type='number' min='1' max='1440' value='{minutes}' style='font-size:20px;padding:10px;border-radius:12px'>
    <button onclick='save()' style='font-size:20px;padding:10px 14px;border-radius:12px'>Αποθήκευση</button>
    <pre id='out'></pre><script>
    async function save(){{let minutes=Number(document.getElementById('m').value||10);let r=await fetch('/api/feed/v24863/settings',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{poster_interval_minutes:minutes}})}});document.getElementById('out').textContent=JSON.stringify(await r.json(),null,2)}}
    </script></body>"""
    return HTMLResponse(html, headers={'Cache-Control':'no-store'})
# END ITHAKI V24863


# ITHAKI V248745 - known/offline members for DM contacts
@app.get("/api/people/known")
def people_known(room_id: str = Query(...), limit: int = Query(500, ge=1, le=1000)):
    ensure_people_avatar_column_v24614()
    rs = rows("""
        SELECT device_id,nickname,language,mood,avatar,last_seen
        FROM people
        WHERE room_id=?
        ORDER BY last_seen DESC
        LIMIT ?
    """, (room_id, limit))
    people = []
    now_ts = now()
    for r in rs:
        d = dict(r)
        last_seen = float(d.get("last_seen") or 0)
        people.append({
            "id": d.get("device_id") or "",
            "device_id": d.get("device_id") or "",
            "name": d.get("nickname") or d.get("device_id") or "Member",
            "nickname": d.get("nickname") or "",
            "language": d.get("language") or "el",
            "mood": d.get("mood") or "",
            "avatar": d.get("avatar") or "",
            "last_seen": last_seen,
            "online": (now_ts - last_seen) <= 180,
        })
    return {"ok": True, "room_id": room_id, "people": people, "count": len(people)}

# QUALITY V249141: client waits for /api/media/status final URL before publishing video posts; no persistent video_processing post payloads.

# OK ITHAKI V249164 — Server Center route and safe masked state
def _ithaki_v249164_mask(obj):
    try:
        if isinstance(obj, dict):
            out = {}
            for k, v in obj.items():
                lk = str(k).lower()
                if any(s in lk for s in ("token", "secret", "password", "credential", "service_account", "key")) and isinstance(v, str):
                    out[k] = (v[:6] + "…MASKED…") if v else ""
                else:
                    out[k] = _ithaki_v249164_mask(v)
            return out
        if isinstance(obj, list):
            return [_ithaki_v249164_mask(x) for x in obj]
        return obj
    except Exception:
        return obj

def _ithaki_v249164_read_json(name, default=None):
    try:
        p = ROOT / name
        if p.exists():
            return json.loads(p.read_text(encoding="utf-8"))
    except Exception:
        pass
    return default if default is not None else {}

@app.get("/server-center", response_class=HTMLResponse)
def ithaki_server_center_v249166():
    p = ROOT / "admin" / "server_center_v249166.html"
    if p.exists():
        return HTMLResponse(p.read_text(encoding="utf-8"))
    return HTMLResponse("<h1>OK ITHAKI Server Center</h1><p>server_center_v249166.html missing.</p>")

@app.get("/api/admin/server-center/state")
def ithaki_server_center_state_v249166():
    try:
        data = {
            "ok": True,
            "version": "249166",
            "health": {"time": now()},
            "runtime": _ithaki_v249164_read_json("relay_runtime_config.json", {}),
            "app_connection": _ithaki_v249164_read_json("app_connection_runtime.json", {}),
            "video_call": _ithaki_v249164_read_json("video_call_config.json", {}),
            "fcm": _ithaki_v249164_read_json("fcm_config.json", {}),
            "media_quota": _ithaki_v249164_read_json("media_quota_config.json", {}),
        }
        return _ithaki_v249164_mask(data)
    except Exception as exc:
        return {"ok": False, "error": str(exc)}


# ──────────────────────────────────────────────
# ITHAKI V249217 — Feed Control PRO
# Cards after video threshold, one-card cadence, admin first-view ordering, soft delete/restore.
# ──────────────────────────────────────────────
def _v249217_bool(v, default=False):
    if v is None:
        return bool(default)
    if isinstance(v, bool):
        return v
    if isinstance(v, (int, float)):
        return bool(int(v))
    return str(v).strip().lower() in {"1", "true", "yes", "y", "on", "ναι"}


def _v249217_init():
    try:
        init_db()
    except Exception:
        pass
    execute("""
    CREATE TABLE IF NOT EXISTS feed_control_settings_v249217(
        room_id TEXT PRIMARY KEY,
        min_videos_before_cards INTEGER NOT NULL DEFAULT 5,
        card_every_videos INTEGER NOT NULL DEFAULT 5,
        card_interval_minutes INTEGER NOT NULL DEFAULT 10,
        card_max_per_load INTEGER NOT NULL DEFAULT 1,
        loop_feed_enabled INTEGER NOT NULL DEFAULT 1,
        admin_priority_enabled INTEGER NOT NULL DEFAULT 1,
        updated_at REAL NOT NULL DEFAULT 0
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS feed_priority_v249217(
        room_id TEXT NOT NULL,
        post_id TEXT NOT NULL,
        priority_order INTEGER NOT NULL DEFAULT 0,
        enabled INTEGER NOT NULL DEFAULT 1,
        home_first INTEGER NOT NULL DEFAULT 1,
        note TEXT NOT NULL DEFAULT '',
        updated_at REAL NOT NULL DEFAULT 0,
        PRIMARY KEY(room_id, post_id)
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS flow_deleted_archive_v249217(
        archive_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL,
        post_id TEXT NOT NULL,
        sender_id TEXT NOT NULL DEFAULT '',
        nickname TEXT NOT NULL DEFAULT '',
        post_json TEXT NOT NULL DEFAULT '{}',
        comments_json TEXT NOT NULL DEFAULT '[]',
        reactions_json TEXT NOT NULL DEFAULT '[]',
        deleted_by TEXT NOT NULL DEFAULT 'server_admin',
        reason TEXT NOT NULL DEFAULT '',
        deleted_at REAL NOT NULL,
        restored_at REAL NOT NULL DEFAULT 0
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS member_deleted_archive_v249217(
        archive_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL,
        device_id TEXT NOT NULL,
        nickname TEXT NOT NULL DEFAULT '',
        person_json TEXT NOT NULL DEFAULT '{}',
        posts_json TEXT NOT NULL DEFAULT '[]',
        comments_json TEXT NOT NULL DEFAULT '[]',
        deleted_by TEXT NOT NULL DEFAULT 'server_admin',
        reason TEXT NOT NULL DEFAULT '',
        deleted_at REAL NOT NULL,
        restored_at REAL NOT NULL DEFAULT 0
    )""")


def _v249217_feed_control_settings(room_id: str) -> dict:
    _v249217_init()
    room_id = str(room_id or "ithaki-phone-test")[:120]
    r = one("SELECT * FROM feed_control_settings_v249217 WHERE room_id=?", (room_id,))
    if not r:
        execute("""
        INSERT OR IGNORE INTO feed_control_settings_v249217
        (room_id,min_videos_before_cards,card_every_videos,card_interval_minutes,card_max_per_load,loop_feed_enabled,admin_priority_enabled,updated_at)
        VALUES(?,?,?,?,?,?,?,?)
        """, (room_id, 5, 5, 10, 1, 1, 1, now()))
        r = one("SELECT * FROM feed_control_settings_v249217 WHERE room_id=?", (room_id,))
    d = dict(r) if r else {}
    return {
        "room_id": room_id,
        "min_videos_before_cards": max(0, int(d.get("min_videos_before_cards", 5) or 5)),
        "card_every_videos": max(1, int(d.get("card_every_videos", 5) or 5)),
        "card_interval_minutes": max(1, int(d.get("card_interval_minutes", 10) or 10)),
        "card_max_per_load": max(0, min(10, int(d.get("card_max_per_load", 1) or 1))),
        "loop_feed_enabled": bool(int(d.get("loop_feed_enabled", 1) or 0)),
        "admin_priority_enabled": bool(int(d.get("admin_priority_enabled", 1) or 0)),
        "updated_at": float(d.get("updated_at", 0) or 0),
    }


def _v249217_is_video_row(row_or_dict) -> bool:
    try:
        d = dict(row_or_dict)
    except Exception:
        d = row_or_dict or {}
    kind = str(d.get("post_kind") or "").lower()
    if "video" in kind:
        return True
    text = str(d.get("text") or "")
    low = text.lower()
    if '"kind":"video"' in low or '"kind": "video"' in low or "video/" in low:
        return True
    try:
        obj = json.loads(text)
        if isinstance(obj, dict):
            m = obj.get("media") or obj.get("file") or {}
            mk = str((m or {}).get("kind") or (m or {}).get("type") or (m or {}).get("mime_type") or "").lower()
            if "video" in mk:
                return True
    except Exception:
        pass
    return False


def _v249217_count_public_videos(room_id: str) -> int:
    _v249217_init()
    total = 0
    for r in rows("SELECT post_kind,text FROM flow_posts WHERE room_id=? AND visibility='public' ORDER BY created_at DESC LIMIT 2000", (room_id,)):
        if _v249217_is_video_row(r):
            total += 1
    return total


def _v249217_apply_priority_order(room_id: str, result: list[dict]) -> list[dict]:
    if not result:
        return result
    cfg = _v249217_feed_control_settings(room_id)
    if not cfg.get("admin_priority_enabled", True):
        return result
    pr = {}
    for r in rows("SELECT post_id,priority_order FROM feed_priority_v249217 WHERE room_id=? AND enabled=1 AND home_first=1 ORDER BY priority_order ASC", (room_id,)):
        pr[str(r["post_id"])] = int(r["priority_order"] or 0)
    if not pr:
        return result
    indexed = list(enumerate(result))
    indexed.sort(key=lambda it: (0 if str(it[1].get("post_id")) in pr else 1, pr.get(str(it[1].get("post_id")), 999999), it[0]))
    return [x[1] for x in indexed]


def _v249217_json_rows(sql: str, params: tuple = ()) -> list[dict]:
    return [dict(x) for x in rows(sql, params)]


@app.get("/api/admin/feed/control/state")
def api_admin_feed_control_state_v249217(admin_key: str = Query(""), room_id: str = Query("ithaki-phone-test"), limit: int = Query(300, ge=1, le=1000)):
    _require_admin(admin_key)
    _v249217_init()
    cfg = _v249217_feed_control_settings(room_id)
    posts = _v249217_json_rows("""
        SELECT f.post_id,f.room_id,f.sender_id,f.nickname,f.post_kind,f.visibility,f.created_at,f.text,
               COALESCE(p.priority_order,0) AS priority_order, COALESCE(p.enabled,0) AS priority_enabled, COALESCE(p.home_first,0) AS home_first
        FROM flow_posts f
        LEFT JOIN feed_priority_v249217 p ON p.room_id=f.room_id AND p.post_id=f.post_id
        WHERE f.room_id=?
        ORDER BY f.created_at DESC LIMIT ?
    """, (room_id, limit))
    for d in posts:
        d["is_video"] = _v249217_is_video_row(d)
        txt = str(d.get("text") or "")
        try:
            obj = json.loads(txt)
            if isinstance(obj, dict):
                d["title"] = str(obj.get("title") or obj.get("question") or obj.get("body") or obj.get("text") or "")[:220]
            else:
                d["title"] = txt[:220]
        except Exception:
            d["title"] = txt[:220]
        d["text"] = txt[:700]
    deleted = _v249217_json_rows("SELECT archive_id,room_id,post_id,sender_id,nickname,reason,deleted_at,restored_at FROM flow_deleted_archive_v249217 WHERE room_id=? ORDER BY deleted_at DESC LIMIT 200", (room_id,))
    member_deleted = _v249217_json_rows("SELECT archive_id,room_id,device_id,nickname,reason,deleted_at,restored_at FROM member_deleted_archive_v249217 WHERE room_id=? ORDER BY deleted_at DESC LIMIT 200", (room_id,))
    return {"ok": True, "settings": cfg, "published_video_count": _v249217_count_public_videos(room_id), "posts": posts, "deleted_posts": deleted, "deleted_members": member_deleted, "version": "249217_feed_control"}


@app.post("/api/admin/feed/control/settings")
def api_admin_feed_control_settings_v249217(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    minv = max(0, min(1000, int(payload.get("min_videos_before_cards", 5) or 5)))
    every = max(1, min(1000, int(payload.get("card_every_videos", 5) or 5)))
    minutes = max(1, min(1440, int(payload.get("card_interval_minutes", 10) or 10)))
    maxload = max(0, min(10, int(payload.get("card_max_per_load", 1) or 1)))
    loop = 1 if _v249217_bool(payload.get("loop_feed_enabled"), True) else 0
    prio = 1 if _v249217_bool(payload.get("admin_priority_enabled"), True) else 0
    _v249217_init()
    execute("""
        INSERT INTO feed_control_settings_v249217(room_id,min_videos_before_cards,card_every_videos,card_interval_minutes,card_max_per_load,loop_feed_enabled,admin_priority_enabled,updated_at)
        VALUES(?,?,?,?,?,?,?,?)
        ON CONFLICT(room_id) DO UPDATE SET
          min_videos_before_cards=excluded.min_videos_before_cards,
          card_every_videos=excluded.card_every_videos,
          card_interval_minutes=excluded.card_interval_minutes,
          card_max_per_load=excluded.card_max_per_load,
          loop_feed_enabled=excluded.loop_feed_enabled,
          admin_priority_enabled=excluded.admin_priority_enabled,
          updated_at=excluded.updated_at
    """, (room_id, minv, every, minutes, maxload, loop, prio, now()))
    return {"ok": True, "settings": _v249217_feed_control_settings(room_id)}


@app.post("/api/admin/feed/control/priority")
def api_admin_feed_control_priority_v249217(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    post_id = str(payload.get("post_id") or "").strip()
    if not post_id:
        raise HTTPException(400, "post_id required")
    order = max(0, min(999999, int(payload.get("priority_order", 0) or 0)))
    enabled = 1 if _v249217_bool(payload.get("enabled"), True) else 0
    home_first = 1 if _v249217_bool(payload.get("home_first"), True) else 0
    note = str(payload.get("note") or "")[:500]
    _v249217_init()
    if not one("SELECT post_id FROM flow_posts WHERE room_id=? AND post_id=?", (room_id, post_id)):
        raise HTTPException(404, "post not found")
    execute("""
        INSERT INTO feed_priority_v249217(room_id,post_id,priority_order,enabled,home_first,note,updated_at)
        VALUES(?,?,?,?,?,?,?)
        ON CONFLICT(room_id,post_id) DO UPDATE SET priority_order=excluded.priority_order, enabled=excluded.enabled, home_first=excluded.home_first, note=excluded.note, updated_at=excluded.updated_at
    """, (room_id, post_id, order, enabled, home_first, note, now()))
    return {"ok": True, "post_id": post_id, "priority_order": order, "enabled": bool(enabled)}


def _v249217_archive_post(room_id: str, post_id: str, deleted_by: str, reason: str) -> dict:
    p = one("SELECT * FROM flow_posts WHERE room_id=? AND post_id=?", (room_id, post_id))
    if not p:
        raise HTTPException(404, "post not found")
    comments = _v249217_json_rows("SELECT * FROM messages WHERE room_id=? AND channel=?", (room_id, "comment:" + post_id))
    reactions = _v249217_json_rows("SELECT * FROM reactions WHERE target_id=?", (post_id,))
    archive_id = nid("flowarch")
    execute("""
        INSERT INTO flow_deleted_archive_v249217(archive_id,room_id,post_id,sender_id,nickname,post_json,comments_json,reactions_json,deleted_by,reason,deleted_at,restored_at)
        VALUES(?,?,?,?,?,?,?,?,?,?,?,0)
    """, (archive_id, room_id, post_id, str(p["sender_id"] or ""), str(p["nickname"] or ""), json.dumps(dict(p), ensure_ascii=False), json.dumps(comments, ensure_ascii=False), json.dumps(reactions, ensure_ascii=False), deleted_by, reason[:800], now()))
    execute("DELETE FROM feed_priority_v249217 WHERE room_id=? AND post_id=?", (room_id, post_id))
    execute("DELETE FROM reactions WHERE target_id=?", (post_id,))
    execute("DELETE FROM messages WHERE room_id=? AND channel=?", (room_id, "comment:" + post_id))
    execute("DELETE FROM flow_posts WHERE room_id=? AND post_id=?", (room_id, post_id))
    return {"archive_id": archive_id, "post_id": post_id}


@app.post("/api/admin/feed/control/delete")
def api_admin_feed_control_delete_v249217(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    post_id = str(payload.get("post_id") or "").strip()
    if not post_id:
        raise HTTPException(400, "post_id required")
    r = _v249217_archive_post(room_id, post_id, "server_admin", str(payload.get("reason") or "admin soft delete"))
    try:
        admin_audit(room_id, "flow_soft_delete_v249217", "server_admin", r)
    except Exception:
        pass
    return {"ok": True, "deleted": True, **r}


@app.post("/api/admin/feed/control/restore")
def api_admin_feed_control_restore_v249217(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    archive_id = str(payload.get("archive_id") or "").strip()
    post_id = str(payload.get("post_id") or "").strip()
    if archive_id:
        a = one("SELECT * FROM flow_deleted_archive_v249217 WHERE room_id=? AND archive_id=?", (room_id, archive_id))
    else:
        a = one("SELECT * FROM flow_deleted_archive_v249217 WHERE room_id=? AND post_id=? ORDER BY deleted_at DESC LIMIT 1", (room_id, post_id))
    if not a:
        raise HTTPException(404, "archive not found")
    post = json.loads(a["post_json"] or "{}")
    if not one("SELECT post_id FROM flow_posts WHERE room_id=? AND post_id=?", (room_id, str(post.get("post_id") or a["post_id"]))):
        execute("""
        INSERT INTO flow_posts(post_id,room_id,sender_id,nickname,language,text,is_encrypted,iv,cipher_text,post_kind,anonymous,visibility,created_at)
        VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
        """, (post.get("post_id"), post.get("room_id"), post.get("sender_id"), post.get("nickname"), post.get("language"), post.get("text"), int(post.get("is_encrypted") or 0), post.get("iv") or "", post.get("cipher_text") or "", post.get("post_kind") or "text", int(post.get("anonymous") or 0), post.get("visibility") or "public", float(post.get("created_at") or now())))
    try:
        for m in json.loads(a["comments_json"] or "[]"):
            execute("INSERT OR IGNORE INTO messages(message_id,room_id,channel,sender_id,nickname,language,text,is_encrypted,iv,cipher_text,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", (m.get("message_id"), m.get("room_id"), m.get("channel"), m.get("sender_id"), m.get("nickname"), m.get("language"), m.get("text"), int(m.get("is_encrypted") or 0), m.get("iv") or "", m.get("cipher_text") or "", float(m.get("created_at") or now())))
    except Exception:
        pass
    try:
        for r in json.loads(a["reactions_json"] or "[]"):
            execute("INSERT OR IGNORE INTO reactions(target_id,device_id,emoji,nickname,created_at) VALUES(?,?,?,?,?)", (r.get("target_id"), r.get("device_id"), r.get("emoji"), r.get("nickname") or "", float(r.get("created_at") or now())))
    except Exception:
        pass
    execute("UPDATE flow_deleted_archive_v249217 SET restored_at=? WHERE archive_id=?", (now(), a["archive_id"]))
    return {"ok": True, "restored": True, "post_id": post.get("post_id") or a["post_id"]}


@app.post("/api/admin/feed/control/member_delete")
def api_admin_feed_control_member_delete_v249217(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    device_id = str(payload.get("device_id") or "").strip()[:160]
    if not device_id:
        raise HTTPException(400, "device_id required")
    person = one("SELECT * FROM people WHERE room_id=? AND device_id=?", (room_id, device_id))
    posts = _v249217_json_rows("SELECT * FROM flow_posts WHERE room_id=? AND sender_id=?", (room_id, device_id))
    comments = _v249217_json_rows("SELECT * FROM messages WHERE room_id=? AND sender_id=?", (room_id, device_id))
    archive_id = nid("memarch")
    execute("""
        INSERT INTO member_deleted_archive_v249217(archive_id,room_id,device_id,nickname,person_json,posts_json,comments_json,deleted_by,reason,deleted_at,restored_at)
        VALUES(?,?,?,?,?,?,?,?,?,?,0)
    """, (archive_id, room_id, device_id, str((person["nickname"] if person else payload.get("nickname")) or ""), json.dumps(dict(person) if person else {}, ensure_ascii=False), json.dumps(posts, ensure_ascii=False), json.dumps(comments, ensure_ascii=False), "server_admin", str(payload.get("reason") or "admin member soft delete")[:800], now()))
    for pp in posts:
        pid = str(pp.get("post_id") or "")
        if pid:
            execute("DELETE FROM feed_priority_v249217 WHERE room_id=? AND post_id=?", (room_id, pid))
            execute("DELETE FROM reactions WHERE target_id=?", (pid,))
            execute("DELETE FROM messages WHERE room_id=? AND channel=?", (room_id, "comment:" + pid))
    execute("DELETE FROM flow_posts WHERE room_id=? AND sender_id=?", (room_id, device_id))
    execute("DELETE FROM messages WHERE room_id=? AND sender_id=?", (room_id, device_id))
    execute("DELETE FROM people WHERE room_id=? AND device_id=?", (room_id, device_id))
    return {"ok": True, "deleted_member": device_id, "archive_id": archive_id, "posts_deleted": len(posts)}


@app.post("/api/admin/feed/control/member_restore")
def api_admin_feed_control_member_restore_v249217(payload: dict):
    _require_admin(str(payload.get("admin_key", "")))
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    archive_id = str(payload.get("archive_id") or "").strip()
    device_id = str(payload.get("device_id") or "").strip()[:160]
    if archive_id:
        a = one("SELECT * FROM member_deleted_archive_v249217 WHERE room_id=? AND archive_id=?", (room_id, archive_id))
    else:
        a = one("SELECT * FROM member_deleted_archive_v249217 WHERE room_id=? AND device_id=? ORDER BY deleted_at DESC LIMIT 1", (room_id, device_id))
    if not a:
        raise HTTPException(404, "member archive not found")
    try:
        person = json.loads(a["person_json"] or "{}")
        if person:
            execute("INSERT OR REPLACE INTO people(room_id,device_id,nickname,language,mood,avatar,last_seen) VALUES(?,?,?,?,?,?,?)", (person.get("room_id") or room_id, person.get("device_id") or a["device_id"], person.get("nickname") or a["nickname"], person.get("language") or "el", person.get("mood") or "", person.get("avatar") or "", now()))
    except Exception:
        pass
    restored_posts = 0
    try:
        for post in json.loads(a["posts_json"] or "[]"):
            if not one("SELECT post_id FROM flow_posts WHERE room_id=? AND post_id=?", (room_id, post.get("post_id"))):
                execute("INSERT INTO flow_posts(post_id,room_id,sender_id,nickname,language,text,is_encrypted,iv,cipher_text,post_kind,anonymous,visibility,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)", (post.get("post_id"), post.get("room_id") or room_id, post.get("sender_id") or a["device_id"], post.get("nickname") or a["nickname"], post.get("language") or "el", post.get("text") or "", int(post.get("is_encrypted") or 0), post.get("iv") or "", post.get("cipher_text") or "", post.get("post_kind") or "text", int(post.get("anonymous") or 0), post.get("visibility") or "public", float(post.get("created_at") or now())))
                restored_posts += 1
    except Exception:
        pass
    try:
        for m in json.loads(a["comments_json"] or "[]"):
            execute("INSERT OR IGNORE INTO messages(message_id,room_id,channel,sender_id,nickname,language,text,is_encrypted,iv,cipher_text,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", (m.get("message_id"), m.get("room_id") or room_id, m.get("channel"), m.get("sender_id") or a["device_id"], m.get("nickname") or a["nickname"], m.get("language") or "el", m.get("text") or "", int(m.get("is_encrypted") or 0), m.get("iv") or "", m.get("cipher_text") or "", float(m.get("created_at") or now())))
    except Exception:
        pass
    execute("UPDATE member_deleted_archive_v249217 SET restored_at=? WHERE archive_id=?", (now(), a["archive_id"]))
    return {"ok": True, "restored_member": a["device_id"], "posts_restored": restored_posts}


@app.get("/admin/feed-control", response_class=HTMLResponse)
def admin_feed_control_page_v249217():
    p = ROOT / "admin" / "feed_control_v249217.html"
    if p.exists():
        return HTMLResponse(p.read_text(encoding="utf-8", errors="replace"), headers={"Cache-Control":"no-store"})
    return HTMLResponse("<h1>feed_control_v249217.html missing</h1>")


@app.get("/api/diagnostics/v249232")
def api_diagnostics_v249232(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    """V249232: Feed discovery/search algorithm diagnostics."""
    try:
        init_social_v249223_db()
    except Exception:
        pass
    def cnt(sql, params=()):
        try:
            r = one(sql, params)
            return int((r and (r.get("n") if hasattr(r, "get") else r["n"])) or 0)
        except Exception:
            return 0
    today = int(now() // 86400)
    return {
        "ok": True,
        "version": "V249232-feed-discovery-algorithm-cards",
        "feed_search_button": True,
        "feed_filters": ["following", "friends", "likes", "ithaki", "followers", "same_interests", "popular_likes", "keywords", "free_flow"],
        "ready_keywords": ["επιστήμη","χόμπι","τέχνη","διακοπές","μόδα","αποκαλύψεις","ειδήσεις","μαθήματα","έρευνα","διάστημα","εργασίες","αστεία","ανέκδοτα"],
        "wisdom_policy": "Ancient and modern philosophy cards appear after at least 12 posts or when the 10-minute local interval is due.",
        "poll_policy": "At most one professional ITHAKI poll card per 24 hours per device/session.",
        "duplicate_publication_guard_seconds": 180,
        "counts": {
            "flow_posts": cnt("SELECT COUNT(*) n FROM flow_posts WHERE room_id=?", (room_id,)),
            "public_posts": cnt("SELECT COUNT(*) n FROM flow_posts WHERE room_id=? AND visibility='public'", (room_id,)),
            "likes": cnt("SELECT COUNT(*) n FROM reactions WHERE emoji='❤️'"),
            "friends": cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='accepted'", (room_id,)),
            "follows": cnt("SELECT COUNT(*) n FROM social_follows_v249223 WHERE room_id=? AND status='following'", (room_id,)),
        },
        "note": "Safe frontend ordering/filtering; server still returns newest posts and prevents duplicate posts within 180 seconds."
    }


# V249233 diagnostics + safe cleanup endpoint
@app.get("/api/diagnostics/v249233")
def api_diagnostics_v249233(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    def cnt(sql, params=()):
        try:
            r = one(sql, params)
            return int((r["n"] if r and "n" in r.keys() else 0) or 0)
        except Exception:
            return 0
    posters = cnt("SELECT COUNT(DISTINCT sender_id) n FROM flow_posts WHERE room_id=?", (room_id,))
    members = cnt("SELECT COUNT(*) n FROM people WHERE room_id=?", (room_id,))
    removable = cnt("SELECT COUNT(*) n FROM people WHERE room_id=? AND device_id NOT IN (SELECT DISTINCT sender_id FROM flow_posts WHERE room_id=?)", (room_id, room_id))
    pending = cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending'", (room_id,))
    return {
        "ok": True,
        "version": "V249233-clean-notifications-poll-cards-member-cleanup",
        "notifications_policy": "Only pending friend-request notifications are shown; avatars are removed; accepted/rejected requests disappear from the notification screen.",
        "poll_ui": "ITHAKI poll cards use the same clean side actions as wisdom cards: comments and heart only.",
        "member_cleanup_tool": "06_CLEAN_TEST_MEMBERS_KEEP_POSTERS.cmd removes people rows without feed publications and keeps all members who posted.",
        "counts": {"members": members, "members_with_posts": posters, "removable_members_without_posts": removable, "pending_friend_requests": pending},
    }

@app.post("/api/admin/clean_test_members_keep_posters/v249233")
def api_admin_clean_test_members_keep_posters_v249233(payload: dict, request: Request):
    """Admin-safe cleanup: delete only people rows that have no feed publications.
    Requires explicit confirm phrase. Intended for local/SSH BAT cleanup, not casual UI use.
    """
    confirm = str(payload.get("confirm") or "")
    if confirm != "DELETE_TEST_MEMBERS_KEEP_POSTERS":
        raise HTTPException(400, "confirmation phrase required")
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    before = one("SELECT COUNT(*) n FROM people WHERE room_id=?", (room_id,))["n"]
    keep = [r["sender_id"] for r in rows("SELECT DISTINCT sender_id FROM flow_posts WHERE room_id=? AND sender_id<>''", (room_id,))]
    if keep:
        q = ",".join(["?"]*len(keep))
        deleted = execute(f"DELETE FROM people WHERE room_id=? AND device_id NOT IN ({q})", tuple([room_id]+keep)).rowcount
    else:
        deleted = 0
    after = one("SELECT COUNT(*) n FROM people WHERE room_id=?", (room_id,))["n"]
    return {"ok": True, "room_id": room_id, "deleted_members_without_posts": int(deleted or 0), "before": int(before or 0), "after": int(after or 0), "kept_members_with_posts": len(keep)}


# V249234 diagnostics + repair tools
@app.get("/api/diagnostics/v249234")
def api_diagnostics_v249234(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    def cnt(sql, params=()):
        try:
            r = one(sql, params)
            return int((r["n"] if r and "n" in r.keys() else 0) or 0)
        except Exception:
            return 0
    members = cnt("SELECT COUNT(*) n FROM people WHERE room_id=?", (room_id,))
    posting_ids = cnt("SELECT COUNT(DISTINCT sender_id) n FROM flow_posts WHERE room_id=? AND sender_id<>''", (room_id,))
    members_with_posts = cnt("""
        SELECT COUNT(*) n FROM people p
        WHERE p.room_id=? AND EXISTS(
            SELECT 1 FROM flow_posts f WHERE f.room_id=p.room_id AND f.sender_id=p.device_id
        )
    """, (room_id,))
    ghost_posters = cnt("""
        SELECT COUNT(DISTINCT f.sender_id) n FROM flow_posts f
        WHERE f.room_id=? AND f.sender_id<>'' AND NOT EXISTS(
            SELECT 1 FROM people p WHERE p.room_id=f.room_id AND p.device_id=f.sender_id
        )
    """, (room_id,))
    removable = cnt("""
        SELECT COUNT(*) n FROM people p
        WHERE p.room_id=? AND NOT EXISTS(
            SELECT 1 FROM flow_posts f WHERE f.room_id=p.room_id AND f.sender_id=p.device_id
        )
    """, (room_id,))
    pending = cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending'", (room_id,))
    duplicate_pending = cnt("""
        SELECT COUNT(*) n FROM (
            SELECT room_id, from_id, to_id, COUNT(*) c
            FROM social_events
            WHERE room_id=? AND type='contact' AND status='pending'
            GROUP BY room_id, from_id, to_id HAVING c>1
        )
    """, (room_id,))
    return {
        "ok": True,
        "version": "V249234-notifications-server-filter-counts-repair",
        "notifications_policy": "Server returns only pending friend/contact requests for /api/notifications; accepted/rejected requests disappear from the notification API and UI.",
        "counts": {
            "members": members,
            "posting_ids_in_flow": posting_ids,
            "members_with_posts": members_with_posts,
            "ghost_posters_without_member_row": ghost_posters,
            "removable_members_without_posts": removable,
            "pending_friend_requests": pending,
            "duplicate_pending_request_pairs": duplicate_pending,
        },
        "next_safe_tools": [
            "server/REPAIR_POSTER_MEMBERS_V249234.cmd restores missing people rows for devices that already have posts.",
            "server/CLEAN_PENDING_FRIEND_REQUESTS_V249234.cmd clears test pending friend requests without deleting posts, members or accepted friends.",
            "server/CLEAN_TEST_MEMBERS_KEEP_POSTERS_V249233.cmd may be used after repair if you intentionally want to remove no-post test people rows."
        ]
    }


# V249235 diagnostics: messages open friend list first
@app.get("/api/diagnostics/v249235")
def api_diagnostics_v249235(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    def cnt(sql, params=()):
        try:
            r = one(sql, params)
            return int((r["n"] if r and "n" in r.keys() else 0) or 0)
        except Exception:
            return 0
    try:
        init_social_v249223_db()
    except Exception:
        pass
    return {
        "ok": True,
        "version": "V249235-messages-friend-list-first",
        "messages_policy": "Pressing Messages opens one clean friends list first. Friends appear active or inactive; tapping a name opens the direct chat.",
        "safe_change": "Frontend-only message navigation polish. No database deletion and no change to feed, notifications, calls, posts or friend acceptance.",
        "counts": {
            "members": cnt("SELECT COUNT(*) n FROM people WHERE room_id=?", (room_id,)),
            "accepted_friend_rows": cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='accepted'", (room_id,)),
            "pending_friend_requests": cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending'", (room_id,)),
        },
    }


# V249236 diagnostics: boot repair + realtime start guard
@app.get("/api/diagnostics/v249236")
def api_diagnostics_v249236(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    def cnt(sql, params=()):
        try:
            r = one(sql, params)
            return int((r["n"] if r and "n" in r.keys() else 0) or 0)
        except Exception:
            return 0
    return {
        "ok": True,
        "version": "V249236-boot-repair-realtime-messages",
        "priority_fix": "boot() no longer crashes from missing V249230/V249231 functions; WebSocket, incoming call polling, heartbeat and DM fallback can start in a fresh session without cached window.ITH.",
        "kept_from_v249235": "Messages button opens friends list first; active and inactive friends are shown; tapping a name opens direct chat.",
        "frontend_sync": "server/frontend/index.js, PRO android_studio assets/index.js and standalone Android assets/index.js are byte-identical in this release.",
        "counts": {
            "members": cnt("SELECT COUNT(*) n FROM people WHERE room_id=?", (room_id,)),
            "accepted_friend_rows": cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='accepted'", (room_id,)),
            "pending_friend_requests": cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending'", (room_id,)),
            "posting_ids_in_flow": cnt("SELECT COUNT(DISTINCT sender_id) n FROM flow_posts WHERE room_id=? AND sender_id<>''", (room_id,)),
        },
        "test_order": [
            "Open the app in a fresh/incognito session or after clearing WebView cache.",
            "Confirm no Startup error toast appears.",
            "Confirm Messages opens the friend list first and tapping a friend opens chat.",
            "Confirm incoming call polling works between two devices.",
            "Confirm voice/video call controls open without stale cached window.ITH."
        ]
    }



# V249237 diagnostics + preserve offline member rows
def _v249237_count(sql, params=()):
    try:
        r = one(sql, params)
        return int((r["n"] if r and "n" in r.keys() else 0) or 0)
    except Exception:
        return 0

def _v249237_orphan_friend_ids(room_id: str) -> int:
    return _v249237_count("""
        SELECT COUNT(*) n FROM (
            SELECT from_id AS device_id FROM social_events WHERE room_id=? AND type='contact' AND status='accepted' AND from_id<>''
            UNION
            SELECT to_id AS device_id FROM social_events WHERE room_id=? AND type='contact' AND status='accepted' AND to_id<>''
        ) x
        WHERE NOT EXISTS(SELECT 1 FROM people p WHERE p.room_id=? AND p.device_id=x.device_id)
    """, (room_id, room_id, room_id))

def _v249237_restore_missing_people_rows(room_id: str) -> dict:
    restored_posters = 0
    restored_friends = 0
    t = now()
    # Restore people rows for devices that have posts.
    for r in rows("""
        SELECT f.sender_id AS device_id,
               COALESCE(NULLIF(TRIM(f.nickname),''),'Μέλος με δημοσίευση') AS nickname,
               COALESCE(NULLIF(TRIM(f.language),''),'el') AS language,
               MAX(f.created_at) AS last_post_at
        FROM flow_posts f
        WHERE f.room_id=? AND f.sender_id<>''
          AND NOT EXISTS(SELECT 1 FROM people p WHERE p.room_id=f.room_id AND p.device_id=f.sender_id)
        GROUP BY f.sender_id
    """, (room_id,)):
        res = execute("""
            INSERT OR IGNORE INTO people(room_id,device_id,nickname,language,mood,avatar,last_seen)
            VALUES(?,?,?,?,?,?,?)
        """, (room_id, str(r["device_id"]), str(r["nickname"] or "Μέλος με δημοσίευση")[:80], str(r["language"] or "el")[:20], "poster-restored", "", float(r["last_post_at"] or t)))
        restored_posters += int(res.rowcount or 0)
    # Restore people rows for accepted friendship endpoints that were orphaned by the old 180s purge.
    for r in rows("""
        SELECT device_id, COALESCE(NULLIF(TRIM(nickname),''),'Φίλος ΙΘΑΚΗΣ') AS nickname, MAX(updated_at) AS last_event_at
        FROM (
            SELECT from_id AS device_id, from_name AS nickname, updated_at FROM social_events
            WHERE room_id=? AND type='contact' AND status='accepted' AND from_id<>''
            UNION ALL
            SELECT to_id AS device_id, to_name AS nickname, updated_at FROM social_events
            WHERE room_id=? AND type='contact' AND status='accepted' AND to_id<>''
        )
        WHERE device_id<>''
        GROUP BY device_id
    """, (room_id, room_id)):
        if one("SELECT 1 FROM people WHERE room_id=? AND device_id=? LIMIT 1", (room_id, str(r["device_id"]))):
            continue
        res = execute("""
            INSERT OR IGNORE INTO people(room_id,device_id,nickname,language,mood,avatar,last_seen)
            VALUES(?,?,?,?,?,?,?)
        """, (room_id, str(r["device_id"]), str(r["nickname"] or "Φίλος ΙΘΑΚΗΣ")[:80], "el", "friend-restored", "", float(r["last_event_at"] or t)))
        restored_friends += int(res.rowcount or 0)
    return {"restored_posters": restored_posters, "restored_friends": restored_friends, "restored_total": restored_posters + restored_friends}

@app.get("/api/diagnostics/v249237")
def api_diagnostics_v249237(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    t = now()
    return {
        "ok": True,
        "version": "V249237-preserve-offline-members",
        "priority_fix": "Background maintenance no longer deletes people/profile rows after 180 seconds offline. last_seen now controls online/offline status only.",
        "kept_from_v249236": "Boot repair remains: WebSocket, incoming call polling, heartbeat and DM fallback can start in fresh sessions.",
        "kept_from_v249235": "Messages button opens friends list first; active and inactive friends are shown; tapping a name opens direct chat.",
        "people_policy": "Never delete real people rows by last_seen. Preserve nickname/avatar/settings for inactive friends; compute online as last_seen >= now()-180.",
        "counts": {
            "members": _v249237_count("SELECT COUNT(*) n FROM people WHERE room_id=?", (room_id,)),
            "online_members_180s": _v249237_count("SELECT COUNT(*) n FROM people WHERE room_id=? AND last_seen>=?", (room_id, t-180)),
            "offline_members_over_180s": _v249237_count("SELECT COUNT(*) n FROM people WHERE room_id=? AND last_seen<?", (room_id, t-180)),
            "accepted_friend_rows": _v249237_count("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='accepted'", (room_id,)),
            "orphan_accepted_friend_ids_without_people_row": _v249237_orphan_friend_ids(room_id),
            "pending_friend_requests": _v249237_count("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending'", (room_id,)),
            "posting_ids_in_flow": _v249237_count("SELECT COUNT(DISTINCT sender_id) n FROM flow_posts WHERE room_id=? AND sender_id<>''", (room_id,)),
            "ghost_posters_without_member_row": _v249237_count("""
                SELECT COUNT(*) n FROM (
                    SELECT DISTINCT sender_id FROM flow_posts WHERE room_id=? AND sender_id<>''
                ) x WHERE NOT EXISTS(SELECT 1 FROM people p WHERE p.room_id=? AND p.device_id=x.sender_id)
            """, (room_id, room_id)),
        },
        "next_safe_tools": [
            "server/REPAIR_MEMBER_ROWS_V249237.cmd restores missing people rows from existing posts and accepted friendships.",
            "server/CLEAN_PENDING_FRIEND_REQUESTS_V249234.cmd may be used only for old pending test requests; it does not delete accepted friends."
        ],
        "test_order": [
            "Upload V249237 before testing calls/messages.",
            "Run REPAIR_MEMBER_ROWS_V249237 if orphan_accepted_friend_ids_without_people_row or ghost_posters_without_member_row is above 0.",
            "Confirm members remain after 3+ minutes offline.",
            "Then test Messages friend list, voice call and video call."
        ]
    }

@app.post("/api/admin/repair_member_rows/v249237")
def api_admin_repair_member_rows_v249237(payload: dict, request: Request):
    confirm = str(payload.get("confirm") or "")
    if confirm != "REPAIR_MEMBER_ROWS_V249237":
        raise HTTPException(400, "confirmation phrase required")
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    before = _v249237_count("SELECT COUNT(*) n FROM people WHERE room_id=?", (room_id,))
    repaired = _v249237_restore_missing_people_rows(room_id)
    after = _v249237_count("SELECT COUNT(*) n FROM people WHERE room_id=?", (room_id,))
    return {"ok": True, "room_id": room_id, "before_people": before, "after_people": after, **repaired}

@app.post("/api/admin/repair_poster_members/v249234")
def api_admin_repair_poster_members_v249234(payload: dict, request: Request):
    confirm = str(payload.get("confirm") or "")
    if confirm != "REPAIR_POSTER_MEMBERS":
        raise HTTPException(400, "confirmation phrase required")
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    restored = 0
    for r in rows("""
        SELECT f.sender_id AS device_id,
               COALESCE(NULLIF(TRIM(f.nickname),''),'Μέλος με δημοσίευση') AS nickname,
               COALESCE(NULLIF(TRIM(f.language),''),'el') AS language,
               MAX(f.created_at) AS last_post_at
        FROM flow_posts f
        WHERE f.room_id=? AND f.sender_id<>''
          AND NOT EXISTS(SELECT 1 FROM people p WHERE p.room_id=f.room_id AND p.device_id=f.sender_id)
        GROUP BY f.sender_id
    """, (room_id,)):
        execute("""
        INSERT OR IGNORE INTO people(room_id,device_id,nickname,language,mood,avatar,last_seen)
        VALUES(?,?,?,?,?,?,?)
        """, (room_id, str(r["device_id"]), str(r["nickname"] or "Μέλος με δημοσίευση")[:80], str(r["language"] or "el")[:20], "poster-restored", "", float(r["last_post_at"] or now())))
        restored += 1
    return {"ok": True, "room_id": room_id, "restored_poster_members": restored}


@app.get("/api/diagnostics/v249238")
def api_diag_v249238(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    init_social_v166_db(); init_fcm_db(); init_notification_preferences_v249238()
    cfg = _fcm_cfg()
    def cnt(sql, params=()):
        try:
            return int((one(sql, params) or {"n": 0})["n"] or 0)
        except Exception:
            return 0
    device_id = str(device_id or "").strip()[:160]
    incoming = cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending' AND to_id=?", (room_id, device_id)) if device_id else 0
    outgoing = cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending' AND from_id=?", (room_id, device_id)) if device_id else 0
    return {
        "ok": True,
        "version": "V249238-notifications-open-active-members-fcm-turn-ready",
        "kept_from_v249237": "Offline people/profile rows are preserved; boot repair and messages friend-list-first remain.",
        "fixes": [
            "Notification center has a direct always-visible bell and opens pending friend requests.",
            "Incoming friend requests show Accept/Reject; outgoing requests are clearly marked as waiting.",
            "Home/feed member strip shows only active members; inactive friends remain in Messages list.",
            "Notification category preferences and quiet hours are stored server-side for FCM suppression."
        ],
        "counts": {
            "members": cnt("SELECT COUNT(*) n FROM people WHERE room_id=?", (room_id,)),
            "online_members_180s": cnt("SELECT COUNT(*) n FROM people WHERE room_id=? AND last_seen>=?", (room_id, now()-180)),
            "offline_members_over_180s": cnt("SELECT COUNT(*) n FROM people WHERE room_id=? AND last_seen<?", (room_id, now()-180)),
            "accepted_friend_rows": cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='accepted'", (room_id,)),
            "pending_friend_requests_total": cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending'", (room_id,)),
            "pending_friend_requests_incoming_for_device": incoming,
            "pending_friend_requests_outgoing_for_device": outgoing,
            "active_fcm_tokens": cnt("SELECT COUNT(*) n FROM fcm_tokens WHERE room_id=? AND enabled=1", (room_id,)),
        },
        "fcm": {
            "server_enabled": bool(cfg.get("enabled")),
            "project_id_configured": bool(cfg.get("project_id")),
            "service_account_json": str(cfg.get("service_account_json") or "firebase_service_account.json"),
            "note": "Closed-app Android notifications require google-services.json in the APK, a built APK after enabling FCM, firebase_admin installed on the server, and firebase_service_account.json configured on the server."
        },
        "turn": {
            "status": "configuration-required",
            "required_for_mobile_data": True,
            "note": "For reliable calls on mobile data/CGNAT, run production coturn and expose UDP/TCP 3478 plus TLS 5349, then publish those ICE servers to /api/video/settings."
        }
    }


@app.get("/api/diagnostics/v249239")
def api_diag_v249239(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    r = api_diag_v249238(room_id=room_id, device_id=device_id)
    try:
        r["version"] = "V249239-emergency-502-fix-notification-routes-after-app"
        r["emergency_fix"] = "Fixed 502 crash: V249238 notification @app routes are now registered after app = FastAPI(...), so relay_server_clean.py imports and service starts."
        r["kept_from_v249238"] = "Notification bell, active-members-only strip, notification preferences, FCM/TURN foundation remain."
    except Exception:
        pass
    return r

@app.post("/api/admin/clean_pending_friend_requests/v249234")
def api_admin_clean_pending_friend_requests_v249234(payload: dict, request: Request):
    """Test cleanup only: delete pending contact requests; keep accepted friendships, posts, members and messages."""
    confirm = str(payload.get("confirm") or "")
    if confirm != "DELETE_PENDING_FRIEND_REQUESTS_ONLY":
        raise HTTPException(400, "confirmation phrase required")
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    before = one("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending'", (room_id,))["n"]
    deleted = execute("DELETE FROM social_events WHERE room_id=? AND type='contact' AND status='pending'", (room_id,)).rowcount
    after = one("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending'", (room_id,))["n"]
    return {"ok": True, "room_id": room_id, "deleted_pending_friend_requests": int(deleted or 0), "before": int(before or 0), "after": int(after or 0)}


# ──────────────────────────────────────────────
# V249240 notification accept/repair: pending requests tied to old device IDs
# ──────────────────────────────────────────────
def _v249240_person_name(room_id: str, device_id: str, fallback: str = "Μέλος") -> str:
    try:
        r = one("SELECT nickname FROM people WHERE room_id=? AND device_id=?", (room_id, device_id))
        if r and str(r["nickname"] or "").strip():
            return str(r["nickname"])[:80]
    except Exception:
        pass
    return str(fallback or "Μέλος")[:80]


def _v249240_pending_items(room_id: str, device_id: str = "", include_room_pending: bool = False, safe_limit: int = 300) -> list[dict]:
    init_social_v166_db()
    device_id = str(device_id or "").strip()[:160]
    if safe_limit < 1:
        safe_limit = 1
    if safe_limit > 500:
        safe_limit = 500
    result = []
    if include_room_pending:
        q = """
            SELECT * FROM social_events
            WHERE room_id=? AND type='contact' AND status='pending'
            ORDER BY updated_at DESC, created_at DESC
            LIMIT ?
        """
        params = (room_id, safe_limit)
    else:
        if not device_id:
            return []
        q = """
            SELECT * FROM social_events
            WHERE room_id=? AND type='contact' AND status='pending'
              AND (to_id=? OR from_id=?)
            ORDER BY updated_at DESC, created_at DESC
            LIMIT ?
        """
        params = (room_id, device_id, device_id, safe_limit)
    for r in rows(q, params):
        d = dict(r)
        if device_id and str(d.get("to_id") or "") == device_id:
            d["relation_to_device"] = "incoming"
            d["can_accept_here"] = True
            d["repair_required"] = False
        elif device_id and str(d.get("from_id") or "") == device_id:
            d["relation_to_device"] = "outgoing"
            d["can_accept_here"] = False
            d["repair_required"] = False
        else:
            d["relation_to_device"] = "other_device_or_old_id"
            d["can_accept_here"] = bool(device_id)
            d["repair_required"] = bool(device_id)
        result.append(d)
    return result


@app.get("/api/notifications/v249240")
def api_notifications_v249240(room_id: str = Query("ithaki-phone-test"), device_id: str = Query(""), limit: str = Query("300"), include_room_pending: int = Query(1)):
    """Return pending friend requests for this device, plus stale/old-device pending requests.

    V249239 showed total pending requests in diagnostics but no Accept/Reject when the
    request belonged to an old device_id.  This endpoint gives the notification UI
    enough information to show and repair those requests intentionally.
    """
    try:
        safe_limit = int(str(limit or "300").strip())
    except Exception:
        safe_limit = 300
    device_id = str(device_id or "").strip()[:160]
    all_items = _v249240_pending_items(room_id, device_id, bool(include_room_pending), safe_limit)
    incoming = [x for x in all_items if x.get("relation_to_device") == "incoming"]
    outgoing = [x for x in all_items if x.get("relation_to_device") == "outgoing"]
    repairable = [x for x in all_items if x.get("relation_to_device") == "other_device_or_old_id"]
    return {
        "ok": True,
        "version": "V249241-notifications-no-stuck-loading",
        "device_id": device_id,
        "items": all_items,
        "counts": {
            "incoming": len(incoming),
            "outgoing": len(outgoing),
            "other_device_or_old_id": len(repairable),
            "total_returned": len(all_items),
        },
        "policy": "Show pending requests for the current device and also old-device pending requests with an explicit repair/accept action."
    }


@app.get("/api/notifications/count/v249240")
def api_notifications_count_v249240(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    device_id = str(device_id or "").strip()[:160]
    items = _v249240_pending_items(room_id, device_id, True, 500)
    incoming = len([x for x in items if x.get("relation_to_device") == "incoming"])
    repairable = len([x for x in items if x.get("relation_to_device") == "other_device_or_old_id"])
    outgoing = len([x for x in items if x.get("relation_to_device") == "outgoing"])
    return {
        "ok": True,
        "count": incoming + repairable,
        "incoming_pending_friend_requests": incoming,
        "repairable_old_device_pending_friend_requests": repairable,
        "outgoing_pending_friend_requests": outgoing,
        "policy": "Badge counts incoming requests plus old-device requests that need repair/accept here."
    }


@app.post("/api/social/friend/respond/v249240")
def api_social_friend_respond_v249240(payload: dict):
    """Accept/reject a pending friend request, including old-device repair.

    Normal case: current device is from_id or to_id and the old responder is used.
    Repair case: pending request is tied to another/old to_id; with force_device_repair
    the receiver can accept it as the current device, so DM/calls unlock for the
    real current device instead of a stale ID.
    """
    init_social_v166_db()
    room_id = str(payload.get("room_id") or "ithaki-phone-test").strip()[:120]
    eid = str(payload.get("id") or "").strip()[:160]
    device_id = str(payload.get("device_id") or "").strip()[:160]
    accepted = bool(payload.get("accepted", False))
    force_repair = bool(payload.get("force_device_repair", False) or payload.get("repair", False))
    if not eid or not device_id:
        raise HTTPException(400, "id and device_id required")
    r = one("SELECT * FROM social_events WHERE room_id=? AND id=? AND type='contact'", (room_id, eid))
    if not r:
        raise HTTPException(404, "event not found")
    if str(r["status"] or "pending") != "pending":
        return {"ok": True, "id": eid, "status": str(r["status"]), "already_done": True}
    involved = (str(r["to_id"]) == device_id or str(r["from_id"]) == device_id)
    if involved:
        res = _respond_social("contact", payload)
        try:
            other_id = str(r["from_id"] if str(r["to_id"]) == device_id else r["to_id"])
            other_name = str(r["from_name"] if str(r["to_id"]) == device_id else r["to_name"])
            if accepted and other_id:
                res["friend"] = _person_v249223(room_id, other_id, other_name)
                res["friend_id"] = other_id
        except Exception:
            pass
        return res
    if not force_repair:
        raise HTTPException(403, "not your event; use force_device_repair only for old-device notification repair")
    # Safety guard: never accept a request from yourself into yourself.
    if str(r["from_id"] or "") == device_id:
        raise HTTPException(400, "cannot accept your own outgoing request")
    t = now()
    if accepted:
        to_name = _v249240_person_name(room_id, device_id, str(r["to_name"] or "Μέλος"))
        # Move the pending request to the current device and accept it. This repairs old device IDs.
        execute("""
            UPDATE social_events
            SET to_id=?, to_name=?, status='accepted', updated_at=?
            WHERE room_id=? AND id=? AND type='contact' AND status='pending'
        """, (device_id, to_name, t, room_id, eid))
        try:
            execute("""
                INSERT INTO people(room_id,device_id,nickname,language,mood,avatar,last_seen)
                VALUES(?,?,?,?,?,?,?)
                ON CONFLICT(room_id,device_id) DO UPDATE SET nickname=excluded.nickname, last_seen=excluded.last_seen
            """, (room_id, device_id, to_name, "el", "", "", t))
        except Exception:
            LOG.exception("V249240 ensure current people row failed")
        try:
            push_notify_devices(room_id, [str(r["from_id"])], "OK ITHAKI", f"{to_name} αποδέχτηκε το αίτημα φιλίας", {"kind": "contact", "event_id": eid, "status": "accepted", "room_id": room_id})
        except Exception:
            pass
        friend = _person_v249223(room_id, str(r["from_id"]), str(r["from_name"] or "Μέλος"))
        return {"ok": True, "id": eid, "status": "accepted", "device_repair": True, "old_to_id": str(r["to_id"]), "new_to_id": device_id, "friend": friend, "friend_id": str(r["from_id"])}
    else:
        execute("UPDATE social_events SET status='rejected', updated_at=? WHERE room_id=? AND id=? AND type='contact' AND status='pending'", (t, room_id, eid))
        return {"ok": True, "id": eid, "status": "rejected", "device_repair": True, "old_to_id": str(r["to_id"]), "new_to_id": device_id}




# V249241: lightweight notification endpoints so the UI never stays on "φόρτωση αιτημάτων".
def _v249241_pending_items_light(room_id: str, device_id: str = "", limit: int = 80):
    init_social_v166_db()
    room_id = str(room_id or "ithaki-phone-test").strip()[:120]
    device_id = str(device_id or "").strip()[:160]
    try:
        safe_limit = int(limit)
    except Exception:
        safe_limit = 80
    safe_limit = max(1, min(100, safe_limit))
    result = []
    q = """
        SELECT id,type,status,from_id,from_name,to_id,to_name,created_at,updated_at
        FROM social_events
        WHERE room_id=? AND type='contact' AND status='pending'
        ORDER BY updated_at DESC, created_at DESC
        LIMIT ?
    """
    for r in rows(q, (room_id, safe_limit)):
        d = dict(r)
        if device_id and str(d.get("to_id") or "") == device_id:
            d["relation_to_device"] = "incoming"
            d["can_accept_here"] = True
            d["repair_required"] = False
        elif device_id and str(d.get("from_id") or "") == device_id:
            d["relation_to_device"] = "outgoing"
            d["can_accept_here"] = False
            d["repair_required"] = False
        else:
            d["relation_to_device"] = "other_device_or_old_id"
            d["can_accept_here"] = bool(device_id)
            d["repair_required"] = bool(device_id)
        result.append(d)
    return result


@app.get("/api/notifications/light/v249241")
def api_notifications_light_v249241(room_id: str = Query("ithaki-phone-test"), device_id: str = Query(""), limit: str = Query("80")):
    try:
        safe_limit = int(str(limit or "80").strip())
    except Exception:
        safe_limit = 80
    device_id = str(device_id or "").strip()[:160]
    items = _v249241_pending_items_light(room_id, device_id, safe_limit)
    incoming = [x for x in items if x.get("relation_to_device") == "incoming"]
    outgoing = [x for x in items if x.get("relation_to_device") == "outgoing"]
    repairable = [x for x in items if x.get("relation_to_device") == "other_device_or_old_id"]
    return {
        "ok": True,
        "version": "V249241-notifications-no-stuck-loading",
        "items": items,
        "counts": {
            "incoming": len(incoming),
            "outgoing": len(outgoing),
            "other_device_or_old_id": len(repairable),
            "total_returned": len(items),
        },
        "policy": "Lightweight pending-friend-request endpoint for fast notification UI loading."
    }


@app.get("/api/notifications/count/v249241")
def api_notifications_count_v249241(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    device_id = str(device_id or "").strip()[:160]
    items = _v249241_pending_items_light(room_id, device_id, 100)
    incoming = len([x for x in items if x.get("relation_to_device") == "incoming"])
    repairable = len([x for x in items if x.get("relation_to_device") == "other_device_or_old_id"])
    outgoing = len([x for x in items if x.get("relation_to_device") == "outgoing"])
    return {"ok": True, "count": incoming + repairable, "incoming_pending_friend_requests": incoming, "repairable_old_device_pending_friend_requests": repairable, "outgoing_pending_friend_requests": outgoing}


@app.post("/api/social/friend/respond/v249241")
def api_social_friend_respond_v249241(payload: dict):
    return api_social_friend_respond_v249240(payload)


@app.get("/api/diagnostics/v249241")
def api_diagnostics_v249241(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    cnt = lambda sql, p=(): int((one(sql, p) or {"n": 0})["n"] or 0)
    device_id = str(device_id or "").strip()[:160]
    incoming = cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending' AND to_id=?", (room_id, device_id)) if device_id else 0
    outgoing = cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending' AND from_id=?", (room_id, device_id)) if device_id else 0
    total = cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending'", (room_id,))
    other = max(0, total - incoming - outgoing) if device_id else total
    return {
        "ok": True,
        "version": "V249241-notifications-no-stuck-loading",
        "priority_fix": "Notification center no longer stays on loading forever; it uses a lightweight endpoint and frontend timeout/fallback.",
        "kept_from_v249240": "Accept/reject old-device pending friend requests remains.",
        "kept_from_v249239": "502 fix, notification bell, active-members-only feed strip, FCM/TURN foundation remain.",
        "counts": {
            "members": cnt("SELECT COUNT(*) n FROM people WHERE room_id=?", (room_id,)),
            "online_members_180s": cnt("SELECT COUNT(*) n FROM people WHERE room_id=? AND last_seen>=?", (room_id, now()-180)),
            "offline_members_over_180s": cnt("SELECT COUNT(*) n FROM people WHERE room_id=? AND last_seen<?", (room_id, now()-180)),
            "accepted_friend_rows": cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='accepted'", (room_id,)),
            "pending_friend_requests_total": total,
            "pending_incoming_for_device": incoming,
            "pending_outgoing_for_device": outgoing,
            "pending_other_or_old_device_for_device": other,
            "active_fcm_tokens": cnt("SELECT COUNT(*) n FROM fcm_tokens WHERE room_id=? AND enabled=1", (room_id,)),
        },
        "test_order": [
            "Upload V249241.",
            "Open the bell; it should show requests or an error panel in under 8 seconds, never infinite loading.",
            "Use Αποδοχή εδώ for old-device pending requests."
        ]
    }


@app.get("/api/diagnostics/v249240")
def api_diagnostics_v249240(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    cnt = lambda sql, p=(): int((one(sql, p) or {"n": 0})["n"] or 0)
    device_id = str(device_id or "").strip()[:160]
    incoming = cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending' AND to_id=?", (room_id, device_id)) if device_id else 0
    outgoing = cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending' AND from_id=?", (room_id, device_id)) if device_id else 0
    total = cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending'", (room_id,))
    other = max(0, total - incoming - outgoing) if device_id else total
    return {
        "ok": True,
        "version": "V249241-notifications-no-stuck-loading",
        "priority_fix": "Notification center can now show pending friend requests tied to old/other device IDs and lets the current device accept/reject them with explicit repair.",
        "kept_from_v249239": "502 route-order fix, notification bell, active-members-only feed strip, FCM/TURN foundation.",
        "kept_from_v249237": "Offline members are preserved; people rows are never deleted by last_seen.",
        "counts": {
            "members": cnt("SELECT COUNT(*) n FROM people WHERE room_id=?", (room_id,)),
            "online_members_180s": cnt("SELECT COUNT(*) n FROM people WHERE room_id=? AND last_seen>=?", (room_id, now()-180)),
            "accepted_friend_rows": cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='accepted'", (room_id,)),
            "pending_friend_requests_total": total,
            "pending_incoming_for_device": incoming,
            "pending_outgoing_for_device": outgoing,
            "pending_other_or_old_device_for_device": other,
            "active_fcm_tokens": cnt("SELECT COUNT(*) n FROM fcm_tokens WHERE room_id=? AND enabled=1", (room_id,)),
        },
        "test_order": [
            "Open app on laptop/phone and press the always-visible bell.",
            "If requests were tied to an old device ID, accept with the Αποδοχή εδώ button.",
            "After acceptance, Messages should show the friend and DM/calls should unlock."
        ]
    }


# ──────────────────────────────────────────────
# V249242: Bell opens immediately + notification sound on badge increase
# ──────────────────────────────────────────────
@app.get("/api/notifications/light/v249242")
def api_notifications_light_v249242(room_id: str = Query("ithaki-phone-test"), device_id: str = Query(""), limit: str = Query("80")):
    try:
        safe_limit = int(str(limit or "80").strip())
    except Exception:
        safe_limit = 80
    device_id = str(device_id or "").strip()[:160]
    items = _v249241_pending_items_light(room_id, device_id, safe_limit)
    incoming = [x for x in items if x.get("relation_to_device") == "incoming"]
    outgoing = [x for x in items if x.get("relation_to_device") == "outgoing"]
    repairable = [x for x in items if x.get("relation_to_device") == "other_device_or_old_id"]
    return {
        "ok": True,
        "version": "V249242-bell-open-sound-fix",
        "items": items,
        "counts": {
            "incoming": len(incoming),
            "outgoing": len(outgoing),
            "other_device_or_old_id": len(repairable),
            "total_returned": len(items),
        },
        "policy": "Bell panel opens immediately; network loading uses Promise.race timeout; old-device requests remain repairable."
    }


@app.get("/api/notifications/count/v249242")
def api_notifications_count_v249242(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    device_id = str(device_id or "").strip()[:160]
    items = _v249241_pending_items_light(room_id, device_id, 100)
    incoming = len([x for x in items if x.get("relation_to_device") == "incoming"])
    repairable = len([x for x in items if x.get("relation_to_device") == "other_device_or_old_id"])
    outgoing = len([x for x in items if x.get("relation_to_device") == "outgoing"])
    return {
        "ok": True,
        "version": "V249242-bell-open-sound-fix",
        "count": incoming + repairable,
        "incoming_pending_friend_requests": incoming,
        "repairable_old_device_pending_friend_requests": repairable,
        "outgoing_pending_friend_requests": outgoing,
        "sound_policy": "Frontend plays a short in-app alert when the badge count increases and audio is unlocked/allowed."
    }


@app.post("/api/social/friend/respond/v249242")
def api_social_friend_respond_v249242(payload: dict):
    return api_social_friend_respond_v249240(payload)


@app.get("/api/diagnostics/v249242")
def api_diagnostics_v249242(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    cnt = lambda sql, p=(): int((one(sql, p) or {"n": 0})["n"] or 0)
    device_id = str(device_id or "").strip()[:160]
    incoming = cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending' AND to_id=?", (room_id, device_id)) if device_id else 0
    outgoing = cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending' AND from_id=?", (room_id, device_id)) if device_id else 0
    total = cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending'", (room_id,))
    other = max(0, total - incoming - outgoing) if device_id else total
    return {
        "ok": True,
        "version": "V249242-bell-open-sound-fix",
        "priority_fix": "Bell/notification panel opens immediately and never waits on network before showing UI; hard Promise.race timeout replaces stuck loading.",
        "sound_fix": "In-app alert sound is triggered when the notification badge count increases, when device/browser audio permission allows it.",
        "kept_from_v249241": "Light notification endpoint and old-device accept/reject repair remain.",
        "kept_from_v249239": "502 fix, notification bell, active-members-only feed strip, FCM/TURN foundation remain.",
        "counts": {
            "members": cnt("SELECT COUNT(*) n FROM people WHERE room_id=?", (room_id,)),
            "online_members_180s": cnt("SELECT COUNT(*) n FROM people WHERE room_id=? AND last_seen>=?", (room_id, now()-180)),
            "offline_members_over_180s": cnt("SELECT COUNT(*) n FROM people WHERE room_id=? AND last_seen<?", (room_id, now()-180)),
            "accepted_friend_rows": cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='accepted'", (room_id,)),
            "pending_friend_requests_total": total,
            "pending_incoming_for_device": incoming,
            "pending_outgoing_for_device": outgoing,
            "pending_other_or_old_device_for_device": other,
            "active_fcm_tokens": cnt("SELECT COUNT(*) n FROM fcm_tokens WHERE room_id=? AND enabled=1", (room_id,)),
        },
        "test_order": [
            "Upload V249242.",
            "Force close app/browser refresh so old cached V249241 frontend is gone.",
            "Press the bell: a panel must open immediately, before server data arrives.",
            "Within a few seconds it must show requests, retry/raw buttons, or diagnostics; never endless loading.",
            "If badge increases, in-app sound should play after a user tap has unlocked audio. Closed-app sound still requires FCM APK setup."
        ]
    }


# ──────────────────────────────────────────────
# V249243: Bell badge/message-center consistency
# ──────────────────────────────────────────────
@app.get("/api/diagnostics/v249243")
def api_diagnostics_v249243(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    cnt = lambda sql, p=(): int((one(sql, p) or {"n": 0})["n"] or 0)
    device_id = str(device_id or "").strip()[:160]
    incoming = cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending' AND to_id=?", (room_id, device_id)) if device_id else 0
    outgoing = cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending' AND from_id=?", (room_id, device_id)) if device_id else 0
    total = cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending'", (room_id,))
    other = max(0, total - incoming - outgoing) if device_id else total
    return {
        "ok": True,
        "version": "V249243-bell-badge-message-center",
        "priority_fix": "Bell badge and bell panel now match: the panel shows pending friend requests plus local unread message notices.",
        "badge_policy": "Badge count = server pending friend requests visible/repairable here + local unread message notices that the panel can open.",
        "kept_from_v249242": "Bell opens immediately, hard timeout/fallback remains, in-app sound on badge increase remains.",
        "kept_from_v249237": "Offline members are preserved; people rows are never deleted by last_seen.",
        "counts": {
            "members": cnt("SELECT COUNT(*) n FROM people WHERE room_id=?", (room_id,)),
            "online_members_180s": cnt("SELECT COUNT(*) n FROM people WHERE room_id=? AND last_seen>=?", (room_id, now()-180)),
            "offline_members_over_180s": cnt("SELECT COUNT(*) n FROM people WHERE room_id=? AND last_seen<?", (room_id, now()-180)),
            "accepted_friend_rows": cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='accepted'", (room_id,)),
            "pending_friend_requests_total": total,
            "pending_incoming_for_device": incoming,
            "pending_outgoing_for_device": outgoing,
            "pending_other_or_old_device_for_device": other,
            "active_fcm_tokens": cnt("SELECT COUNT(*) n FROM fcm_tokens WHERE room_id=? AND enabled=1", (room_id,)),
        },
        "test_order": [
            "Upload V249243 and force refresh/force close app to drop old V249242 frontend.",
            "Receive a DM while not inside that conversation; the bell number may increase.",
            "Press the bell; it must show a Messages section with Open conversation.",
            "Friend requests remain in the Friend Requests section with Accept/Reject/Accept here."
        ]
    }


# ──────────────────────────────────────────────
# V249244: Notification render missing helper fix
# ──────────────────────────────────────────────
@app.get("/api/diagnostics/v249244")
def api_diagnostics_v249244(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    cnt = lambda sql, p=(): int((one(sql, p) or {"n": 0})["n"] or 0)
    device_id = str(device_id or "").strip()[:160]
    incoming = cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending' AND to_id=?", (room_id, device_id)) if device_id else 0
    outgoing = cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending' AND from_id=?", (room_id, device_id)) if device_id else 0
    total = cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending'", (room_id,))
    other = max(0, total - incoming - outgoing) if device_id else total
    return {
        "ok": True,
        "version": "V249244-notify-person-helper-fix",
        "priority_fix": "Fixes notification render crash: v249231PersonById is defined and exported, so bell panel can show friend requests and message notices instead of the error panel.",
        "kept_from_v249243": "Bell badge/message-center consistency remains: Friend Requests and Messages are shown in one notification center.",
        "kept_from_v249242": "Bell opens immediately, hard timeout/fallback remains, in-app sound on badge increase remains.",
        "counts": {
            "members": cnt("SELECT COUNT(*) n FROM people WHERE room_id=?", (room_id,)),
            "online_members_180s": cnt("SELECT COUNT(*) n FROM people WHERE room_id=? AND last_seen>=?", (room_id, now()-180)),
            "offline_members_over_180s": cnt("SELECT COUNT(*) n FROM people WHERE room_id=? AND last_seen<?", (room_id, now()-180)),
            "accepted_friend_rows": cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='accepted'", (room_id,)),
            "pending_friend_requests_total": total,
            "pending_incoming_for_device": incoming,
            "pending_outgoing_for_device": outgoing,
            "pending_other_or_old_device_for_device": other,
            "active_fcm_tokens": cnt("SELECT COUNT(*) n FROM fcm_tokens WHERE room_id=? AND enabled=1", (room_id,)),
        },
        "test_order": [
            "Upload V249244 and force refresh/force close app to drop old V249243 frontend.",
            "Press the bell; it must not show v249231PersonById is not defined.",
            "Friend requests should render with Accept/Reject/Accept here, and message notices should render with Open conversation."
        ]
    }


# ──────────────────────────────────────────────
# V249245: Notification panel scroll/layout fix
# ──────────────────────────────────────────────
@app.get("/api/diagnostics/v249245")
def api_diagnostics_v249245(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    base = api_diagnostics_v249244(room_id=room_id, device_id=device_id)
    base["version"] = "V249246-profile-name-friends-repair"
    base["priority_fix"] = "Notification panel layout fixed: cards no longer overlap, panel has internal scroll, sticky header and tappable action buttons."
    base["kept_from_v249244"] = "Notification render helper fix remains; friend requests and message notices still render in one notification center."
    base["test_order"] = [
        "Upload V249245 and force refresh/force close app to drop old V249244 frontend.",
        "Press the bell; the panel must open with one-column cards and internal scroll.",
        "Scroll the notification panel and press Accept/Reject/Accept here.",
        "Buttons must be visible and tappable on phone and laptop."
    ]
    return base


# ──────────────────────────────────────────────
# V249247: notification events for likes/posts + diagnostics + FCM setup visibility
# ──────────────────────────────────────────────
def init_notification_events_v249247():
    execute("""
    CREATE TABLE IF NOT EXISTS notification_events_v249247(
        id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL,
        to_id TEXT NOT NULL,
        from_id TEXT NOT NULL DEFAULT '',
        from_name TEXT NOT NULL DEFAULT '',
        type TEXT NOT NULL,
        title TEXT NOT NULL DEFAULT '',
        body TEXT NOT NULL DEFAULT '',
        post_id TEXT NOT NULL DEFAULT '',
        channel TEXT NOT NULL DEFAULT '',
        url TEXT NOT NULL DEFAULT '',
        status TEXT NOT NULL DEFAULT 'unread',
        created_at REAL NOT NULL,
        updated_at REAL NOT NULL
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_notif_v249247_to ON notification_events_v249247(room_id,to_id,status,updated_at)")
    execute("CREATE INDEX IF NOT EXISTS idx_notif_v249247_post ON notification_events_v249247(room_id,post_id,updated_at)")


def _v249247_add_notification_event(room_id: str, to_id: str, from_id: str, from_name: str, typ: str, title: str, body: str, post_id: str = '', channel: str = '', url: str = '') -> dict:
    init_notification_events_v249247()
    room_id = str(room_id or 'ithaki-phone-test')[:120]
    to_id = str(to_id or '')[:160]
    from_id = str(from_id or '')[:160]
    if not room_id or not to_id or not typ:
        return {"ok": False, "reason": "missing_target"}
    if to_id == from_id:
        return {"ok": True, "skipped": "self"}
    typ = str(typ or 'notice')[:40]
    post_id = str(post_id or '')[:160]
    channel = str(channel or '')[:220]
    title = str(title or 'OK ITHAKI')[:160]
    body = str(body or '')[:500]
    url = str(url or '')[:500]
    recent = one("""
        SELECT id FROM notification_events_v249247
        WHERE room_id=? AND to_id=? AND from_id=? AND type=? AND post_id=? AND status='unread' AND created_at>=?
        ORDER BY created_at DESC LIMIT 1
    """, (room_id, to_id, from_id, typ, post_id, now()-90))
    if recent:
        return {"ok": True, "id": str(recent["id"]), "duplicate_prevented": True}
    eid = nid('ntf')
    t = now()
    execute("""
        INSERT INTO notification_events_v249247(id,room_id,to_id,from_id,from_name,type,title,body,post_id,channel,url,status,created_at,updated_at)
        VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
    """, (eid, room_id, to_id, from_id, str(from_name or '')[:120], typ, title, body, post_id, channel, url, 'unread', t, t))
    try:
        ws_notify(room_id)
    except Exception:
        pass
    return {"ok": True, "id": eid}


@app.get("/api/notifications/social/v249247")
def api_notifications_social_v249247(room_id: str = Query("ithaki-phone-test"), device_id: str = Query(""), limit: int = Query(80, ge=1, le=200)):
    init_notification_events_v249247()
    device_id = str(device_id or '').strip()[:160]
    if not device_id:
        return {"ok": True, "version": "V249247-notifications-chat-scroll-friendship-fcm-bat", "items": [], "count": 0}
    rs = rows("""
        SELECT * FROM notification_events_v249247
        WHERE room_id=? AND to_id=? AND status='unread'
        ORDER BY updated_at DESC, created_at DESC LIMIT ?
    """, (room_id, device_id, limit))
    items = [dict(r) for r in rs]
    return {"ok": True, "version": "V249247-notifications-chat-scroll-friendship-fcm-bat", "items": items, "count": len(items), "policy": "Unread server notifications for likes and friend/follower posts that the bell can open."}


@app.get("/api/notifications/count/v249247")
def api_notifications_count_v249247(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    device_id = str(device_id or '').strip()[:160]
    try:
        friend = api_notifications_count_v249242(room_id=room_id, device_id=device_id)
        friend_count = int(friend.get('count') or 0)
    except Exception:
        friend_count = 0
    init_notification_events_v249247()
    social_count = 0
    if device_id:
        social_count = int((one("SELECT COUNT(*) n FROM notification_events_v249247 WHERE room_id=? AND to_id=? AND status='unread'", (room_id, device_id)) or {"n": 0})["n"] or 0)
    return {"ok": True, "version": "V249247-notifications-chat-scroll-friendship-fcm-bat", "count": friend_count + social_count, "friend_request_count": friend_count, "server_social_count": social_count}


@app.post("/api/notifications/read/v249247")
def api_notifications_read_v249247(payload: dict):
    init_notification_events_v249247()
    room_id = str(payload.get('room_id') or 'ithaki-phone-test')[:120]
    device_id = str(payload.get('device_id') or '')[:160]
    ids = payload.get('ids') or []
    if isinstance(ids, str): ids = [ids]
    ids = [str(x)[:160] for x in ids if str(x or '').strip()]
    if not device_id or not ids:
        return {"ok": False, "updated": 0, "reason": "device_id_and_ids_required"}
    updated = 0
    for eid in ids[:100]:
        execute("UPDATE notification_events_v249247 SET status='read', updated_at=? WHERE room_id=? AND to_id=? AND id=?", (now(), room_id, device_id, eid))
        updated += 1
    return {"ok": True, "updated": updated}


@app.get("/api/diagnostics/v249247")
def api_diagnostics_v249247(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    init_social_v249223_db(); init_fcm_db(); init_notification_events_v249247()
    cnt = lambda sql, p=(): int((one(sql, p) or {"n": 0})["n"] or 0)
    device_id = str(device_id or '').strip()[:160]
    incoming = cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending' AND to_id=?", (room_id, device_id)) if device_id else 0
    outgoing = cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending' AND from_id=?", (room_id, device_id)) if device_id else 0
    total = cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending'", (room_id,))
    other = max(0, total - incoming - outgoing) if device_id else total
    cfg = _fcm_cfg()
    sa_path = ROOT / str(cfg.get('service_account_json') or 'firebase_service_account.json')
    try:
        import firebase_admin  # noqa
        firebase_admin_importable = True
    except Exception:
        firebase_admin_importable = False
    active_tokens = cnt("SELECT COUNT(*) n FROM fcm_tokens WHERE room_id=? AND enabled=1", (room_id,))
    server_social_unread = cnt("SELECT COUNT(*) n FROM notification_events_v249247 WHERE room_id=? AND to_id=? AND status='unread'", (room_id, device_id)) if device_id else 0
    return {
        "ok": True,
        "version": "V249247-notifications-chat-scroll-friendship-fcm-bat",
        "priority_fix": "Message screen scroll, friend re-check, bell notifications for likes/new video-posts, and FCM setup BAT package.",
        "kept_from_v249246": "Profile live name check and friends list repair remain.",
        "counts": {
            "members": cnt("SELECT COUNT(*) n FROM people WHERE room_id=?", (room_id,)),
            "online_members_180s": cnt("SELECT COUNT(*) n FROM people WHERE room_id=? AND last_seen>=?", (room_id, now()-180)),
            "offline_members_over_180s": cnt("SELECT COUNT(*) n FROM people WHERE room_id=? AND last_seen<?", (room_id, now()-180)),
            "accepted_friend_rows": cnt("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='accepted'", (room_id,)),
            "pending_friend_requests_total": total,
            "pending_incoming_for_device": incoming,
            "pending_outgoing_for_device": outgoing,
            "pending_other_or_old_device_for_device": other,
            "server_social_unread_for_device": server_social_unread,
            "notification_events_total": cnt("SELECT COUNT(*) n FROM notification_events_v249247 WHERE room_id=?", (room_id,)),
            "active_fcm_tokens": active_tokens,
        },
        "fcm_closed_app_ready": bool(cfg.get('enabled')) and bool(active_tokens) and sa_path.exists() and firebase_admin_importable,
        "fcm": {
            "server_enabled": bool(cfg.get('enabled')),
            "service_account_exists": sa_path.exists(),
            "service_account_json": str(cfg.get('service_account_json') or 'firebase_service_account.json'),
            "firebase_admin_importable": firebase_admin_importable,
            "active_fcm_tokens": active_tokens,
            "needs_new_apk_with_google_services_json": True,
            "setup_bat": "10_SETUP_FCM_SERVER_V249247.cmd in the Contabo upload package; Android still needs google-services.json and APK rebuild."
        },
        "test_order": [
            "Upload V249247 and force refresh/force close the app.",
            "Open Messages and verify the chat body scrolls while composer stays visible.",
            "Try sending a friend request to an already accepted friend; it should say already friends and not create a duplicate.",
            "Like a post from another member; the owner should see a bell notification that opens the post.",
            "A friend/followed member uploading a video creates a bell notification that opens the post.",
            "Closed-app notifications still require FCM server BAT plus google-services.json APK rebuild."
        ]
    }


@app.get("/api/diagnostics/v249251")
def diagnostics_v249251(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    init_video_call_db(); init_fcm_db(); _video_cleanup(room_id)
    cnt=lambda q,p=(): int((one(q,p) or {"n":0})["n"] or 0)
    active=rows("SELECT status,call_type,COUNT(*) c FROM video_call_sessions WHERE room_id=? AND status IN ('ringing','accepted','active') GROUP BY status,call_type", (room_id,))
    fcm=_fcm_cfg()
    return {
        "ok": True,
        "version": "V249251-call-push-single-session-deeplink",
        "priority_fix": "Closed-app call FCM is one notification per session; accepted/rejected/ended calls send silent cancel/control pushes; message/call notification taps deep-link to Messages or call screen.",
        "do_not_touch": "No database repair/clean and no unrelated UI changes.",
        "counts": {
            "members": cnt("SELECT COUNT(*) n FROM people WHERE room_id=?", (room_id,)),
            "active_fcm_tokens": cnt("SELECT COUNT(*) n FROM fcm_tokens WHERE room_id=? AND enabled=1", (room_id,)),
            "active_call_sessions": cnt("SELECT COUNT(*) n FROM video_call_sessions WHERE room_id=? AND status IN ('ringing','accepted','active') AND expires_at>?", (room_id, now())),
            "ringing_for_device": cnt("SELECT COUNT(*) n FROM video_call_sessions WHERE room_id=? AND callee_id=? AND status='ringing' AND expires_at>?", (room_id, device_id, now())) if device_id else 0
        },
        "active_calls_by_status": [dict(r) for r in active],
        "fcm": {"server_enabled": bool(fcm.get("enabled")), "service_account_exists": (ROOT / str(fcm.get("service_account_json") or "firebase_service_account.json")).exists()},
        "test_order": ["Upload V249251 server.", "Build/install V249251 Android APK.", "With app closed, send one voice/video call; only one notification should ring.", "End/reject from either side; ringing must stop on both devices.", "Tap message notification -> Messages. Tap call notification -> call screen."]
    }


@app.get("/api/diagnostics/v249252")
def diagnostics_v249252(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    init_video_call_db(); init_fcm_db(); _video_cleanup(room_id)
    cnt=lambda q,p=(): int((one(q,p) or {"n":0})["n"] or 0)
    fcm=_fcm_cfg()
    return {
        "ok": True,
        "version": "V249252-sokratakis-turtle-companion",
        "priority_fix": "Adds Sokratakis: an animated little turtle companion with Socratic questions and local phrase builder; no AI, no DB changes, no repair/clean.",
        "kept_from_v249251": "Closed-app call single-session push/deeplink fix remains untouched.",
        "sokratakis": {
            "animal": "small turtle companion",
            "animations": ["neck movement", "eye blink", "shell hide/peek"],
            "mode": "local Socratic question flows; no server AI",
            "actions": ["put generated text into Messages", "put generated text into Post composer", "copy text"]
        },
        "counts": {
            "members": cnt("SELECT COUNT(*) n FROM people WHERE room_id=?", (room_id,)),
            "active_fcm_tokens": cnt("SELECT COUNT(*) n FROM fcm_tokens WHERE room_id=? AND enabled=1", (room_id,)),
            "active_call_sessions": cnt("SELECT COUNT(*) n FROM video_call_sessions WHERE room_id=? AND status IN ('ringing','accepted','active') AND expires_at>?", (room_id, now())),
            "ringing_for_device": cnt("SELECT COUNT(*) n FROM video_call_sessions WHERE room_id=? AND callee_id=? AND status='ringing' AND expires_at>?", (room_id, device_id, now())) if device_id else 0
        },
        "fcm": {"server_enabled": bool(fcm.get("enabled")), "service_account_exists": (ROOT / str(fcm.get("service_account_json") or "firebase_service_account.json")).exists()},
        "test_order": ["Upload V249252 server.", "Force refresh / force close app.", "Tap the S button / turtle.", "Choose a topic and answer the questions.", "Use Put in Messages or Put in Post; verify no unrelated call/message behavior changed."]
    }


@app.get("/api/diagnostics/v249254")
def diagnostics_v249254(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    init_video_call_db(); init_fcm_db(); _video_cleanup(room_id)
    cnt=lambda q,p=(): int((one(q,p) or {"n":0})["n"] or 0)
    fcm=_fcm_cfg()
    sa_path = ROOT / str(fcm.get("service_account_json") or "firebase_service_account.json")
    firebase_admin_importable = False
    try:
        import firebase_admin  # noqa
        firebase_admin_importable = True
    except Exception:
        firebase_admin_importable = False
    active=rows("SELECT status,call_type,COUNT(*) c FROM video_call_sessions WHERE room_id=? AND status IN ('ringing','accepted','active') GROUP BY status,call_type", (room_id,))
    return {
        "ok": True,
        "version": "V249254-fcm-preserve-real-sokratakis",
        "priority_fix": "Keeps V249251 closed-app FCM/call behavior, keeps V249252 Sokratakis, makes the turtle more realistic visually; no database repair/clean.",
        "do_not_touch": "No reset, no clean, no unrelated UI changes.",
        "counts": {
            "members": cnt("SELECT COUNT(*) n FROM people WHERE room_id=?", (room_id,)),
            "active_fcm_tokens": cnt("SELECT COUNT(*) n FROM fcm_tokens WHERE room_id=? AND enabled=1", (room_id,)),
            "active_call_sessions": cnt("SELECT COUNT(*) n FROM video_call_sessions WHERE room_id=? AND status IN ('ringing','accepted','active') AND expires_at>?", (room_id, now())),
            "ringing_for_device": cnt("SELECT COUNT(*) n FROM video_call_sessions WHERE room_id=? AND callee_id=? AND status='ringing' AND expires_at>?", (room_id, device_id, now())) if device_id else 0
        },
        "active_calls_by_status": [dict(r) for r in active],
        "fcm": {
            "server_enabled": bool(fcm.get("enabled")),
            "service_account_exists": sa_path.exists(),
            "firebase_admin_importable": firebase_admin_importable,
            "active_fcm_tokens": cnt("SELECT COUNT(*) n FROM fcm_tokens WHERE room_id=? AND enabled=1", (room_id,)),
            "note": "Closed-app push also requires Android APK built with google-services.json and notification permission granted."
        },
        "sokratakis": {
            "animal": "more realistic small turtle companion",
            "animations": ["neck movement", "eye blink", "shell hide/peek", "realistic shell gradients", "eye highlights"],
            "mode": "local Socratic questions; no AI; no server data"
        },
        "test_order": ["Upload V249254 server.", "Open /api/diagnostics/v249254 and confirm fcm.server_enabled/service_account_exists/firebase_admin_importable are true.", "Build/install V249254 Android APK with google-services.json.", "Open app once and grant notifications.", "Close app and send message/call test."]
    }


@app.get("/api/diagnostics/v249255")
def diagnostics_v249255(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    init_video_call_db(); init_fcm_db(); _video_cleanup(room_id)
    cnt=lambda q,p=(): int((one(q,p) or {"n":0})["n"] or 0)
    fcm=_fcm_cfg()
    sa_path = ROOT / str(fcm.get("service_account_json") or "firebase_service_account.json")
    firebase_admin_importable = False
    try:
        import firebase_admin  # noqa
        firebase_admin_importable = True
    except Exception:
        firebase_admin_importable = False
    active=rows("SELECT status,call_type,COUNT(*) c FROM video_call_sessions WHERE room_id=? AND status IN ('ringing','accepted','active') AND expires_at>? GROUP BY status,call_type", (room_id, now()))
    return {
        "ok": True,
        "version": "V249255-fcm-autoreg-call-stop-fix",
        "priority_fix": "Closed-app push no longer depends on opening the bell; FCM token registers at boot. Call pushes are data-only and controlled by ITHAKI so duplicate/system call notifications can be cancelled.",
        "do_not_touch": "No database repair/clean and no unrelated UI changes.",
        "counts": {
            "members": cnt("SELECT COUNT(*) n FROM people WHERE room_id=?", (room_id,)),
            "active_fcm_tokens": cnt("SELECT COUNT(*) n FROM fcm_tokens WHERE room_id=? AND enabled=1", (room_id,)),
            "enabled_tokens_for_device": cnt("SELECT COUNT(*) n FROM fcm_tokens WHERE room_id=? AND device_id=? AND enabled=1", (room_id, device_id)) if device_id else 0,
            "active_call_sessions": cnt("SELECT COUNT(*) n FROM video_call_sessions WHERE room_id=? AND status IN ('ringing','accepted','active') AND expires_at>?", (room_id, now())),
            "ringing_for_device": cnt("SELECT COUNT(*) n FROM video_call_sessions WHERE room_id=? AND callee_id=? AND status='ringing' AND expires_at>?", (room_id, device_id, now())) if device_id else 0
        },
        "active_calls_by_status": [dict(r) for r in active],
        "fcm": {
            "server_enabled": bool(fcm.get("enabled")),
            "service_account_exists": sa_path.exists(),
            "firebase_admin_importable": firebase_admin_importable,
            "calls_are_data_only": True,
            "boot_token_register": True
        },
        "test_order": [
            "Upload V249255 server.",
            "Build/install V249255 Android APK with google-services.json.",
            "Open app once; do NOT open the bell. Wait 20 seconds and close app.",
            "Send a DM; closed device should receive it.",
            "Send one voice/video call; only one call notification should ring.",
            "End/reject from either side; all call ringing/notifications must stop."
        ]
    }


@app.get("/api/diagnostics/v249258")
@app.get("/api/diagnostics/v249259")
def api_diagnostics_v249258(room_id: str = Query("ithaki-phone-test"), device_id: str = Query("")):
    init_fcm_db(); init_video_call_db()
    def cnt(q, args=()):
        try:
            r = one(q, args); return int((r or {}).get("n") or 0)
        except Exception:
            return -1
    cfg = _fcm_cfg()
    try:
        import firebase_admin  # noqa
        firebase_ok = True
    except Exception:
        firebase_ok = False
    active = rows("SELECT status,call_type,COUNT(*) c FROM video_call_sessions WHERE room_id=? AND status IN ('ringing','accepted','active') AND expires_at>? GROUP BY status,call_type", (room_id, now()))
    return {
        "ok": True,
        "version": "V249260-call-clean-one-ring-no-dupes",
        "priority_fix": "Voice/video call rings once, opens the call screen from push, and writes exactly one missed-call message only when unanswered.",
        "do_not_touch": "No database repair/clean and no unrelated UI changes.",
        "counts": {
            "members": cnt("SELECT COUNT(*) n FROM people WHERE room_id=?", (room_id,)),
            "active_fcm_tokens": cnt("SELECT COUNT(*) n FROM fcm_tokens WHERE room_id=? AND enabled=1", (room_id,)),
            "enabled_tokens_for_device": cnt("SELECT COUNT(*) n FROM fcm_tokens WHERE room_id=? AND device_id=? AND enabled=1", (room_id, device_id)) if device_id else 0,
            "active_call_sessions": cnt("SELECT COUNT(*) n FROM video_call_sessions WHERE room_id=? AND status IN ('ringing','accepted','active') AND expires_at>?", (room_id, now())),
            "ringing_for_device": cnt("SELECT COUNT(*) n FROM video_call_sessions WHERE room_id=? AND callee_id=? AND status='ringing' AND expires_at>?", (room_id, device_id, now())) if device_id else 0
        },
        "active_calls_by_status": [dict(r) for r in active],
        "fcm": {"server_enabled": bool(cfg.get("enabled")), "service_account_exists": (ROOT / str(cfg.get("service_account_json") or "firebase_service_account.json")).exists(), "firebase_admin_importable": firebase_ok, "calls_are_data_only": True, "message_channel": "ok_ithaki_fcm_messages_v249258", "call_channel": "ok_ithaki_fcm_calls_v249258"},
        "call_policy": "Live voice/video calls do not create chat messages. Only missed/timeout unanswered calls create exactly one missed-call message per session.",
        "test_order": ["Upload V249259 server.", "Build/install V249259 Android APK with google-services.json.", "Open app once, grant notifications, wait 20 seconds, close app.", "Send DM: phone should make normal notification sound and open Messages.", "Send voice/video call: phone should ring as call, not message.", "End/reject: ringing stops. Only if unanswered/missed should a missed-call message appear."]
    }


# ──────────────────────────────────────────────
# V249288 — group avatars, silent secure invite, one-time broadcast
# ──────────────────────────────────────────────
@app.post('/api/groups/secure_invite_v249288')
def api_groups_secure_invite_v249288(payload: dict):
    ensure_comm_tables_v249280()
    room_id = str(payload.get('room_id') or 'ithaki-phone-test')[:120]
    from_id = str(payload.get('from_id') or payload.get('sender_id') or '')[:120]
    to_id = str(payload.get('to_id') or '')[:120]
    nickname = str(payload.get('nickname') or 'Μέλος')[:120]
    channel = str(payload.get('channel') or '')[:240]
    group_id = str(payload.get('group_id') or '')[:120]
    if not from_id:
        raise HTTPException(400, 'from_id required')
    if not channel:
        if to_id:
            channel = _dm_channel_v249280(from_id, to_id)
        elif group_id:
            channel = _channel_group_v249280(group_id)
    if not channel:
        raise HTTPException(400, 'channel or to_id required')
    if channel.startswith('dm:') and to_id and not _are_friends_v249227(room_id, from_id, to_id):
        raise HTTPException(status_code=403, detail={'ok': False, 'reason': 'friend_required', 'message': 'Πρώτα χρειάζεται αποδοχή φιλίας.'})
    code = ('ITH-' + secrets.token_urlsafe(18).replace('-', '').replace('_', '')[:18]).upper()
    crypto_code = secrets.token_urlsafe(32)
    expires_at = now() + 7 * 24 * 3600
    qr_text = json.dumps({'app':'OK-ITHAKI','type':'secure_invite_v249288','room_id':room_id,'channel':channel,'code':code,'crypto_code':crypto_code,'from_id':from_id,'expires_at':expires_at}, ensure_ascii=False)
    body = json.dumps({'kind':'secure_invite_v249288','body':'🔐 Πρόσκληση κρυπτογράφησης OK ITHAKI','code':code,'crypto_code':crypto_code,'channel':channel,'qr_text':qr_text,'expires_at':expires_at,'no_notification':True}, ensure_ascii=False)
    mid = nid('msg')
    execute("INSERT INTO messages(message_id,room_id,channel,sender_id,nickname,language,text,is_encrypted,iv,cipher_text,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", (mid, room_id, channel, from_id, nickname, 'el', body, 0, '', '', now()))
    try:
        ws_notify(room_id)
    except Exception:
        pass
    return {'ok': True, 'message_id': mid, 'channel': channel, 'code': code, 'crypto_code': crypto_code, 'qr_text': qr_text, 'silent': True}

@app.post('/api/groups/broadcast_once_v249288')
def api_groups_broadcast_once_v249288(payload: dict):
    ensure_comm_tables_v249280()
    room_id = str(payload.get('room_id') or 'ithaki-phone-test')[:120]
    sender_id = str(payload.get('sender_id') or '')[:120]
    nickname = str(payload.get('nickname') or 'Μέλος')[:120]
    text = str(payload.get('text') or '').strip()
    member_ids = _json_list_v249280(payload.get('member_ids') or [])
    if not sender_id or not text or not member_ids:
        raise HTTPException(400, 'sender_id, text and member_ids required')
    if len(text) > 2500:
        raise HTTPException(400, 'message too long')
    sent = []
    skipped = []
    for mid in member_ids[:300]:
        if not mid or mid == sender_id:
            continue
        if not _are_friends_v249227(room_id, sender_id, mid):
            skipped.append(mid)
            continue
        ch = _dm_channel_v249280(sender_id, mid)
        msg = _insert_plain_message_v249280(room_id, ch, sender_id, nickname, text)
        sent.append({'to_id': mid, 'channel': ch, 'message_id': msg})
    try:
        ws_notify(room_id)
    except Exception:
        pass
    return {'ok': True, 'sent_count': len(sent), 'sent': sent, 'skipped_not_friends': skipped}


# ──────────────────────────────────────────────
# V249289 — Server relay live beta for classic message groups
# Host uploads one light video stream to server. Viewers pull from server.
# This is intentionally low-bandwidth beta: JPEG frame relay + optional audio chunks + comments.
# ──────────────────────────────────────────────
def ensure_server_relay_live_tables_v249289():
    ensure_comm_tables_v249280()
    execute("""
    CREATE TABLE IF NOT EXISTS live_server_sessions_v249289(
        session_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL,
        group_id TEXT NOT NULL,
        host_id TEXT NOT NULL,
        nickname TEXT NOT NULL DEFAULT '',
        title TEXT NOT NULL DEFAULT 'Live παρέα',
        status TEXT NOT NULL DEFAULT 'live',
        frame_data TEXT NOT NULL DEFAULT '',
        frame_seq INTEGER NOT NULL DEFAULT 0,
        audio_data TEXT NOT NULL DEFAULT '',
        audio_seq INTEGER NOT NULL DEFAULT 0,
        last_frame_at REAL NOT NULL DEFAULT 0,
        last_audio_at REAL NOT NULL DEFAULT 0,
        created_at REAL NOT NULL,
        updated_at REAL NOT NULL,
        expires_at REAL NOT NULL
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS live_server_comments_v249289(
        comment_id TEXT PRIMARY KEY,
        session_id TEXT NOT NULL,
        room_id TEXT NOT NULL,
        group_id TEXT NOT NULL,
        sender_id TEXT NOT NULL,
        nickname TEXT NOT NULL DEFAULT '',
        text TEXT NOT NULL DEFAULT '',
        created_at REAL NOT NULL
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_live_server_session_group_v249289 ON live_server_sessions_v249289(room_id,group_id,updated_at)")
    execute("CREATE INDEX IF NOT EXISTS idx_live_server_comments_v249289 ON live_server_comments_v249289(session_id,created_at)")

def _live_session_dict_v249289(r):
    if not r:
        return None
    d=dict(r)
    # keep heavy fields out of normal status payload
    d.pop('frame_data', None)
    d.pop('audio_data', None)
    return d

def _live_check_group_member_v249289(room_id: str, group_id: str, device_id: str) -> bool:
    if not device_id:
        return False
    try:
        rr = one("SELECT 1 FROM group_members WHERE room_id=? AND group_id=? AND device_id=? LIMIT 1", (room_id, group_id, device_id))
        return bool(rr)
    except Exception:
        return True

def _live_cleanup_v249289(room_id: str = ''):
    try:
        t=now()
        if room_id:
            execute("UPDATE live_server_sessions_v249289 SET status='ended', updated_at=? WHERE room_id=? AND status='live' AND expires_at<?", (t, room_id, t))
        else:
            execute("UPDATE live_server_sessions_v249289 SET status='ended', updated_at=? WHERE status='live' AND expires_at<?", (t, t))
    except Exception:
        pass

@app.post('/api/live/server_relay/create_v249289')
def api_live_server_relay_create_v249289(payload: dict):
    ensure_server_relay_live_tables_v249289(); _live_cleanup_v249289(str(payload.get('room_id') or ''))
    room_id=str(payload.get('room_id') or 'ithaki-phone-test')[:120]
    group_id=str(payload.get('group_id') or '')[:120]
    host_id=str(payload.get('host_id') or payload.get('sender_id') or '')[:120]
    nickname=str(payload.get('nickname') or 'Μέλος')[:120]
    title=str(payload.get('title') or 'Live παρέα')[:160].strip() or 'Live παρέα'
    if not room_id or not group_id or not host_id:
        raise HTTPException(400, 'room_id, group_id and host_id required')
    if not _live_check_group_member_v249289(room_id, group_id, host_id):
        raise HTTPException(403, 'not group member')
    # one live per host/group at a time: end older live sessions by same host in same group
    execute("UPDATE live_server_sessions_v249289 SET status='ended', updated_at=? WHERE room_id=? AND group_id=? AND host_id=? AND status='live'", (now(), room_id, group_id, host_id))
    sid=nid('live')
    t=now(); exp=t+4*3600
    execute("""
    INSERT INTO live_server_sessions_v249289(session_id,room_id,group_id,host_id,nickname,title,status,created_at,updated_at,expires_at)
    VALUES(?,?,?,?,?,?,?,?,?,?)
    """, (sid, room_id, group_id, host_id, nickname, title, 'live', t, t, exp))
    # Put one visible invite card in the classic group chat. This is NOT a call notification.
    body=json.dumps({'kind':'server_relay_live_v249289','session_id':sid,'group_id':group_id,'title':title,'host_id':host_id,'nickname':nickname,'body':'🎥 Ζωντανή Παρέα ξεκίνησε','server_relay':True,'no_call_ring':True}, ensure_ascii=False)
    try:
        _insert_plain_message_v249280(room_id, _channel_group_v249280(group_id), host_id, nickname, body)
    except Exception:
        mid=nid('msg')
        execute("INSERT INTO messages(message_id,room_id,channel,sender_id,nickname,language,text,is_encrypted,iv,cipher_text,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", (mid, room_id, 'group:'+group_id, host_id, nickname, 'el', body, 0, '', '', t))
    try: ws_notify(room_id)
    except Exception: pass
    return {'ok': True, 'session_id': sid, 'session': {'session_id':sid,'room_id':room_id,'group_id':group_id,'host_id':host_id,'nickname':nickname,'title':title,'status':'live','created_at':t,'expires_at':exp}, 'server_relay': True}

@app.get('/api/live/server_relay/status_v249289')
def api_live_server_relay_status_v249289(room_id: str = Query('ithaki-phone-test'), session_id: str = Query(''), group_id: str = Query(''), device_id: str = Query('')):
    ensure_server_relay_live_tables_v249289(); _live_cleanup_v249289(room_id)
    if session_id:
        r=one("SELECT * FROM live_server_sessions_v249289 WHERE room_id=? AND session_id=?", (room_id, session_id))
    else:
        r=one("SELECT * FROM live_server_sessions_v249289 WHERE room_id=? AND group_id=? AND status='live' ORDER BY updated_at DESC LIMIT 1", (room_id, group_id))
    if not r:
        return {'ok': False, 'status':'missing', 'session': None}
    if device_id and not _live_check_group_member_v249289(room_id, str(r['group_id']), device_id):
        raise HTTPException(403, 'not group member')
    c=one("SELECT COUNT(*) AS n FROM live_server_comments_v249289 WHERE session_id=?", (str(r['session_id']),))
    d=_live_session_dict_v249289(r); d['comment_count']=int(c['n'] or 0) if c else 0
    return {'ok': True, 'status': d.get('status'), 'session': d}

@app.post('/api/live/server_relay/frame_v249289')
def api_live_server_relay_frame_v249289(payload: dict):
    ensure_server_relay_live_tables_v249289()
    room_id=str(payload.get('room_id') or 'ithaki-phone-test')[:120]
    sid=str(payload.get('session_id') or '')[:120]
    host_id=str(payload.get('host_id') or '')[:120]
    frame=str(payload.get('frame_data') or '')
    if not sid or not host_id or not frame:
        raise HTTPException(400, 'session_id, host_id and frame_data required')
    if len(frame) > 1250000:
        raise HTTPException(413, 'frame too large')
    r=one("SELECT * FROM live_server_sessions_v249289 WHERE room_id=? AND session_id=?", (room_id, sid))
    if not r:
        raise HTTPException(404, 'live missing')
    if str(r['host_id']) != host_id:
        raise HTTPException(403, 'only host can send frames')
    if str(r['status']) != 'live':
        raise HTTPException(409, 'live ended')
    seq=int(r['frame_seq'] or 0)+1; t=now()
    execute("UPDATE live_server_sessions_v249289 SET frame_data=?, frame_seq=?, last_frame_at=?, updated_at=?, expires_at=? WHERE session_id=?", (frame, seq, t, t, t+4*3600, sid))
    return {'ok': True, 'frame_seq': seq, 'at': t}

@app.get('/api/live/server_relay/frame_v249289')
def api_live_server_relay_frame_get_v249289(room_id: str = Query('ithaki-phone-test'), session_id: str = Query(''), device_id: str = Query(''), after_seq: int = Query(0)):
    ensure_server_relay_live_tables_v249289(); _live_cleanup_v249289(room_id)
    r=one("SELECT * FROM live_server_sessions_v249289 WHERE room_id=? AND session_id=?", (room_id, session_id))
    if not r:
        return {'ok': False, 'status':'missing'}
    if device_id and not _live_check_group_member_v249289(room_id, str(r['group_id']), device_id):
        raise HTTPException(403, 'not group member')
    seq=int(r['frame_seq'] or 0)
    if seq <= int(after_seq or 0):
        return {'ok': True, 'status': r['status'], 'frame_seq': seq, 'frame_data': '', 'last_frame_at': float(r['last_frame_at'] or 0)}
    return {'ok': True, 'status': r['status'], 'frame_seq': seq, 'frame_data': r['frame_data'] or '', 'last_frame_at': float(r['last_frame_at'] or 0)}

@app.post('/api/live/server_relay/audio_v249289')
def api_live_server_relay_audio_v249289(payload: dict):
    ensure_server_relay_live_tables_v249289()
    room_id=str(payload.get('room_id') or 'ithaki-phone-test')[:120]
    sid=str(payload.get('session_id') or '')[:120]
    host_id=str(payload.get('host_id') or '')[:120]
    audio=str(payload.get('audio_data') or '')
    if not sid or not host_id or not audio:
        raise HTTPException(400, 'session_id, host_id and audio_data required')
    if len(audio) > 1450000:
        raise HTTPException(413, 'audio chunk too large')
    r=one("SELECT * FROM live_server_sessions_v249289 WHERE room_id=? AND session_id=?", (room_id, sid))
    if not r:
        raise HTTPException(404, 'live missing')
    if str(r['host_id']) != host_id:
        raise HTTPException(403, 'only host can send audio')
    if str(r['status']) != 'live':
        raise HTTPException(409, 'live ended')
    seq=int(r['audio_seq'] or 0)+1; t=now()
    execute("UPDATE live_server_sessions_v249289 SET audio_data=?, audio_seq=?, last_audio_at=?, updated_at=?, expires_at=? WHERE session_id=?", (audio, seq, t, t, t+4*3600, sid))
    return {'ok': True, 'audio_seq': seq, 'at': t}

@app.get('/api/live/server_relay/audio_v249289')
def api_live_server_relay_audio_get_v249289(room_id: str = Query('ithaki-phone-test'), session_id: str = Query(''), device_id: str = Query(''), after_seq: int = Query(0)):
    ensure_server_relay_live_tables_v249289(); _live_cleanup_v249289(room_id)
    r=one("SELECT * FROM live_server_sessions_v249289 WHERE room_id=? AND session_id=?", (room_id, session_id))
    if not r:
        return {'ok': False, 'status':'missing'}
    if device_id and not _live_check_group_member_v249289(room_id, str(r['group_id']), device_id):
        raise HTTPException(403, 'not group member')
    seq=int(r['audio_seq'] or 0)
    if seq <= int(after_seq or 0):
        return {'ok': True, 'status': r['status'], 'audio_seq': seq, 'audio_data': '', 'last_audio_at': float(r['last_audio_at'] or 0)}
    return {'ok': True, 'status': r['status'], 'audio_seq': seq, 'audio_data': r['audio_data'] or '', 'last_audio_at': float(r['last_audio_at'] or 0)}

@app.post('/api/live/server_relay/comment_v249289')
def api_live_server_relay_comment_v249289(payload: dict):
    ensure_server_relay_live_tables_v249289()
    room_id=str(payload.get('room_id') or 'ithaki-phone-test')[:120]
    sid=str(payload.get('session_id') or '')[:120]
    sender_id=str(payload.get('sender_id') or '')[:120]
    nickname=str(payload.get('nickname') or 'Μέλος')[:120]
    text=str(payload.get('text') or '').strip()[:420]
    if not sid or not sender_id or not text:
        raise HTTPException(400, 'session_id, sender_id and text required')
    r=one("SELECT * FROM live_server_sessions_v249289 WHERE room_id=? AND session_id=?", (room_id, sid))
    if not r:
        raise HTTPException(404, 'live missing')
    if not _live_check_group_member_v249289(room_id, str(r['group_id']), sender_id):
        raise HTTPException(403, 'not group member')
    cid=nid('lc'); t=now()
    execute("INSERT INTO live_server_comments_v249289(comment_id,session_id,room_id,group_id,sender_id,nickname,text,created_at) VALUES(?,?,?,?,?,?,?,?)", (cid, sid, room_id, str(r['group_id']), sender_id, nickname, text, t))
    execute("UPDATE live_server_sessions_v249289 SET updated_at=? WHERE session_id=?", (t, sid))
    try: ws_notify(room_id)
    except Exception: pass
    return {'ok': True, 'comment_id': cid, 'created_at': t}

@app.get('/api/live/server_relay/comments_v249289')
def api_live_server_relay_comments_v249289(room_id: str = Query('ithaki-phone-test'), session_id: str = Query(''), device_id: str = Query(''), after: float = Query(0.0)):
    ensure_server_relay_live_tables_v249289(); _live_cleanup_v249289(room_id)
    r=one("SELECT * FROM live_server_sessions_v249289 WHERE room_id=? AND session_id=?", (room_id, session_id))
    if not r:
        return {'ok': False, 'comments': [], 'status':'missing'}
    if device_id and not _live_check_group_member_v249289(room_id, str(r['group_id']), device_id):
        raise HTTPException(403, 'not group member')
    rs=rows("SELECT * FROM live_server_comments_v249289 WHERE session_id=? AND created_at>? ORDER BY created_at ASC LIMIT 80", (session_id, float(after or 0)))
    return {'ok': True, 'status': r['status'], 'comments': [dict(x) for x in rs]}

@app.post('/api/live/server_relay/end_v249289')
def api_live_server_relay_end_v249289(payload: dict):
    ensure_server_relay_live_tables_v249289()
    room_id=str(payload.get('room_id') or 'ithaki-phone-test')[:120]
    sid=str(payload.get('session_id') or '')[:120]
    actor_id=str(payload.get('actor_id') or payload.get('host_id') or '')[:120]
    if not sid or not actor_id:
        raise HTTPException(400, 'session_id and actor_id required')
    r=one("SELECT * FROM live_server_sessions_v249289 WHERE room_id=? AND session_id=?", (room_id, sid))
    if not r:
        return {'ok': True, 'ended': True}
    if str(r['host_id']) != actor_id and not _group_is_admin_v249284(room_id, str(r['group_id']), actor_id):
        raise HTTPException(403, 'only host or group admin can end live')
    execute("UPDATE live_server_sessions_v249289 SET status='ended', updated_at=? WHERE session_id=?", (now(), sid))
    try: ws_notify(room_id)
    except Exception: pass
    return {'ok': True, 'ended': True}

@app.get('/api/diagnostics/v249289')
def diagnostics_v249289(room_id: str = Query('ithaki-phone-test')):
    ensure_server_relay_live_tables_v249289(); _live_cleanup_v249289(room_id)
    def cnt(q,p=()):
        try:
            r=one(q,p); return int(r[0] if r else 0)
        except Exception: return -1
    return {'ok': True, 'version':'V249289-server-relay-live-beta','room_id':room_id,'active_server_relay_lives':cnt("SELECT COUNT(*) FROM live_server_sessions_v249289 WHERE room_id=? AND status='live'", (room_id,)), 'live_comments':cnt("SELECT COUNT(*) FROM live_server_comments_v249289 WHERE room_id=?", (room_id,)), 'model':'host -> server -> viewers, low bandwidth beta'}


# V249290 diagnostics - emergency neighborhood media UI
@app.get("/api/diagnostics/v249290")
def diagnostics_v249290():
    return {
        "ok": True,
        "version": VERSION,
        "feature": "emergency-neighborhood-media-ui",
        "emergency_neighborhood": {
            "back_arrow": "fixed",
            "quick_texts": True,
            "message_scroll": True,
            "text_messages": True,
            "media_buttons": ["photo", "video", "file"],
            "note": "Media uses the existing server/local-server upload path when a server or hotspot/local server is reachable; pure store-and-forward phone relay remains safest for short text."
        }
    }


# V249299 — hard page separation diagnostic
@app.get("/api/diagnostics/v249299")
def diagnostics_v249299():
    return {
        "ok": True,
        "version": VERSION,
        "feature": "Home and Feed hard-separated; Feed toolbar only on feed page; home cleans stale feed fragments",
        "rule": "Home never renders feed toolbar/feed list. Feed is one full-screen post per screen with classic search."
    }

# V249303 — Clean Feed: no plain ITHAKI texts/bars, classic search options in UI.
# The endpoint itself still returns only real video posts; golden wisdom cards are generated in the UI.
@app.get("/api/flow/clean_v249303")
def api_flow_clean_v249303(request: Request, room_id: str = Query(...), visibility: str = Query("public"),
                          limit: int = Query(900, ge=1, le=900),
                          scan_limit: int = Query(4000, ge=50, le=4000),
                          viewer_id: str = Query("")):
    try:
        data = api_flow_clean_v249302(request=request, room_id=room_id, visibility=visibility,
                                      limit=min(int(limit), 900), scan_limit=min(int(scan_limit), 4000),
                                      viewer_id=viewer_id)
    except Exception:
        try:
            data = api_flow_clean_v249301(request=request, room_id=room_id, visibility=visibility,
                                          limit=min(int(limit), 900), scan_limit=min(int(scan_limit), 3500),
                                          viewer_id=viewer_id)
        except Exception:
            data = api_flow(request=request, room_id=room_id, visibility=visibility,
                            limit=min(int(limit), 900), lite=False, hide_polls=True,
                            no304=True, viewer_id=viewer_id)
    out = []
    seen_ids, seen_media, seen_sig = set(), set(), set()
    hidden_plain_ithaki = 0
    hidden_dupes = 0
    for d in data.get("posts", []) or []:
        try:
            if not _v249302_has_real_video(d):
                hidden_plain_ithaki += 1
                continue
            pid = str(d.get("post_id") or d.get("message_id") or "")
            if pid and pid in seen_ids:
                hidden_dupes += 1
                continue
            if pid:
                seen_ids.add(pid)
            body, media = _v249295_parse_feed_text(str(d.get("text") or ""))
            media = media if isinstance(media, dict) else {}
            media_url = str(media.get("url") or media.get("file_url") or media.get("media_url") or media.get("filename") or "")
            if media_url:
                mk = media_url.split("?")[0]
                if mk in seen_media:
                    hidden_dupes += 1
                    continue
                seen_media.add(mk)
            sig = ithaki_v248713_post_signature(d.get("room_id"), d.get("sender_id"), d.get("post_kind"), d.get("anonymous"), d.get("visibility"), d.get("text"))
            if sig in seen_sig:
                hidden_dupes += 1
                continue
            seen_sig.add(sig)
            out.append(d)
            if len(out) >= int(limit):
                break
        except Exception:
            continue
    data["version"] = "V249303-feed-classic-search-video-wisdom-no-plain-ithaki"
    data["posts"] = out
    data["returned"] = len(out)
    data["hidden_plain_ithaki_or_non_video_v249303"] = hidden_plain_ithaki
    data["hidden_duplicate_v249303"] = hidden_dupes
    data["lite"] = False
    data["note"] = "Clean Feed returns real videos only; UI adds golden ITHAKI wisdom cards and keeps old classic search options. Plain ITHAKI text/bars/polls are hidden from live Feed only."
    return data

@app.get("/api/flow/video_quality_status_v249303")
def api_flow_video_quality_status_v249303(room_id: str = Query(default="ithaki-phone-test")):
    return {
        "ok": True,
        "version": "V249303-feed-classic-search-video-wisdom-no-plain-ithaki",
        "room_id": room_id,
        "feed": "Live Feed shows videos plus generated golden ITHAKI wisdom cards only.",
        "hidden": "Plain ITHAKI texts, bars, polls and old system strips are hidden from the live Feed; nothing is deleted from DB.",
        "search": "Classic search options are visible again in the Feed toolbar.",
        "media": "Video uses lite=false, metadata first, full quality when visible/played."
    }



# V249305 — Stable single Feed renderer support. Returns only real video rows; UI adds golden wisdom cards.
@app.get("/api/flow/clean_v249305")
def api_flow_clean_v249305(request: Request, room_id: str = Query(...), visibility: str = Query("public"),
                          limit: int = Query(700, ge=1, le=700),
                          scan_limit: int = Query(6000, ge=50, le=6000),
                          viewer_id: str = Query("")):
    try:
        data = api_flow_clean_v249304(request=request, room_id=room_id, visibility=visibility,
                                      limit=min(int(limit), 700), scan_limit=min(int(scan_limit), 6000),
                                      viewer_id=viewer_id)
    except Exception:
        data = api_flow(request=request, room_id=room_id, visibility=visibility,
                        limit=min(int(limit), 700), lite=False, hide_polls=True,
                        no304=True, viewer_id=viewer_id)
    out = []
    seen_ids, seen_media, seen_sig = set(), set(), set()
    hidden_dupes = 0
    for d in data.get("posts", []) or []:
        try:
            if not _v249304_has_video(d):
                continue
            pid = str(d.get("post_id") or d.get("message_id") or "")
            if pid and pid in seen_ids:
                hidden_dupes += 1
                continue
            if pid:
                seen_ids.add(pid)
            body, media = _v249295_parse_feed_text(str(d.get("text") or ""))
            media_url = _v249304_video_url_from_any(media) or _v249304_video_url_from_any(d)
            if media_url:
                mk = str(media_url).split("?")[0]
                if mk in seen_media:
                    hidden_dupes += 1
                    continue
                seen_media.add(mk)
            sig = ithaki_v248713_post_signature(d.get("room_id"), d.get("sender_id"), d.get("post_kind"), d.get("anonymous"), d.get("visibility"), d.get("text"))
            if sig in seen_sig:
                hidden_dupes += 1
                continue
            seen_sig.add(sig)
            out.append(d)
            if len(out) >= int(limit):
                break
        except Exception:
            continue
    data["ok"] = True
    data["version"] = "V249306-feed-hard-reset-one-render-search-visible"
    data["posts"] = out
    data["returned"] = len(out)
    data["hidden_duplicate_v249305"] = hidden_dupes
    data["lite"] = False
    data["note"] = "Stable Feed: server returns only real videos; client renders one full-screen list, one toolbar, no plain bars."
    return data

@app.get("/api/flow/video_quality_status_v249305")
def api_flow_video_quality_status_v249305(room_id: str = Query(default="ithaki-phone-test")):
    return {"ok": True, "version": "V249306-feed-hard-reset-one-render-search-visible", "room_id": room_id,
            "feed": "One clean Feed renderer. Only videos plus golden wisdom cards in the UI.",
            "performance": "No repeated rebuild interval from V249305; old bars hidden and pruned by observer.",
            "media": "Videos use lite=false/raw scan, metadata first, full quality when played."}

@app.get("/api/diagnostics/v249305")
def diagnostics_v249305():
    return {"ok": True, "version": VERSION,
            "feature": "Feed stable single toolbar, no blinking, no duplicate bars",
            "rule": "Feed UI is rendered by V249305 only; old V249293-V249304 toolbars are hidden/pruned."}


# V249306 - emergency stable feed: video-only endpoint for one-render frontend
@app.get("/api/flow/clean_v249306")
def api_flow_clean_v249306(request: Request, room_id: str = Query(...), visibility: str = Query("public"),
                           limit: int = Query(800), scan_limit: int = Query(8000), viewer_id: str = Query(default="")):
    """Return raw flow posts for the V249306 client-side video extractor.
    Keeps DB untouched; the frontend allows only videos + golden wisdom cards.
    """
    try:
        data = api_flow(request=request, room_id=room_id, visibility=visibility,
                        limit=min(max(int(scan_limit or limit or 800), 200), 8000),
                        lite=0, viewer_id=viewer_id)
    except TypeError:
        data = api_flow(request=request, room_id=room_id, visibility=visibility,
                        limit=min(max(int(scan_limit or limit or 800), 200), 8000),
                        viewer_id=viewer_id)
    posts = list((data or {}).get("posts") or [])
    # server-side light dedupe; do not delete anything
    out, seen = [], set()
    for p in posts:
        try:
            raw = str(p.get("text") or "") + " " + str(p.get("media_url") or p.get("file_url") or p.get("url") or "")
            low = raw.lower()
            if not any(x in low for x in (".mp4", ".webm", ".mov", ".m4v", ".3gp", "video/", "/api/media/")):
                continue
            key = str(p.get("post_id") or p.get("message_id") or p.get("id") or "") or raw[:300]
            if key in seen:
                continue
            seen.add(key)
            out.append(p)
            if len(out) >= int(limit or 800):
                break
        except Exception:
            continue
    return {"ok": True, "version": "V249306-feed-hard-reset-one-render-search-visible", "room_id": room_id, "posts": out, "count": len(out), "source_count": len(posts), "policy": "video posts only; golden wisdom cards are client-generated; DB untouched"}

@app.get("/api/flow/video_quality_status_v249306")
def api_flow_video_quality_status_v249306(room_id: str = Query(default="ithaki-phone-test")):
    return {"ok": True, "version": "V249306-feed-hard-reset-one-render-search-visible", "room_id": room_id,
            "frontend": "single renderer, no old feed timers, visible search toolbar",
            "video": "metadata first; full source on play; server FFmpeg compression remains available if configured"}


# V249307 - TikTok-style feed: server alias for video-only raw posts
@app.get("/api/flow/clean_v249307")
def api_flow_clean_v249307(request: Request, room_id: str = Query(...), visibility: str = Query("public"),
                           limit: int = Query(900), scan_limit: int = Query(9000), viewer_id: str = Query(default="")):
    try:
        return api_flow_clean_v249306(request=request, room_id=room_id, visibility=visibility,
                                      limit=limit, scan_limit=scan_limit, viewer_id=viewer_id)
    except Exception:
        data = api_flow(request=request, room_id=room_id, visibility=visibility,
                        limit=min(max(int(scan_limit or limit or 900), 200), 9000), lite=0, viewer_id=viewer_id)
        posts = list((data or {}).get("posts") or [])
        out, seen = [], set()
        for p in posts:
            try:
                raw = str(p.get("text") or "") + " " + str(p.get("media_url") or p.get("file_url") or p.get("url") or "")
                low = raw.lower()
                if not any(x in low for x in (".mp4", ".webm", ".mov", ".m4v", ".3gp", "video/", "/api/media/")):
                    continue
                key = str(p.get("post_id") or p.get("message_id") or p.get("id") or "") or raw[:300]
                if key in seen:
                    continue
                seen.add(key)
                out.append(p)
                if len(out) >= int(limit or 900):
                    break
            except Exception:
                continue
        return {"ok": True, "version": "V249307-feed-tiktok-fullscreen-search-visible", "room_id": room_id,
                "posts": out, "count": len(out), "policy": "TikTok-style feed: videos only; wisdom cards are client-side; DB untouched"}

@app.get("/api/flow/video_quality_status_v249307")
def api_flow_video_quality_status_v249307(room_id: str = Query(default="ithaki-phone-test")):
    return {"ok": True, "version": "V249307-feed-tiktok-fullscreen-search-visible", "room_id": room_id,
            "frontend": "TikTok-style one post per full screen with one visible search/display toolbar",
            "allowed": ["videos", "golden wisdom cards"],
            "hidden": ["plain Ithaki text", "polls", "old bars", "system strips"],
            "video": "metadata first; full source on play; server FFmpeg compression remains available if configured"}


# V249308 - feed fullscreen cover centered search aliases
@app.get("/api/flow/clean_v249308")
def api_flow_clean_v249308(request: Request, room_id: str = Query(...), visibility: str = Query("public"),
                           limit: int = Query(900), scan_limit: int = Query(9000), viewer_id: str = Query(default="")):
    try:
        data = api_flow_clean_v249307(request, room_id=room_id, visibility=visibility, limit=limit, scan_limit=scan_limit, viewer_id=viewer_id)
        if isinstance(data, dict):
            data["version"] = "V249308-feed-fullscreen-cover-centered-search"
            data["policy"] = "TikTok-style feed; videos cover full mobile screen; search toolbar persistent."
        return data
    except Exception as e:
        return {"ok": False, "version": "V249308-feed-fullscreen-cover-centered-search", "error": str(e), "posts": []}

@app.get("/api/flow/video_quality_status_v249308")
def api_flow_video_quality_status_v249308(room_id: str = Query(default="ithaki-phone-test")):
    try:
        data = api_flow_video_quality_status_v249307(room_id=room_id)
        if isinstance(data, dict):
            data["version"] = "V249308-feed-fullscreen-cover-centered-search"
            data["display"] = "mobile fullscreen cover + centered scroll snap + persistent search bar"
        return data
    except Exception as e:
        return {"ok": False, "version": "V249308-feed-fullscreen-cover-centered-search", "room_id": room_id, "error": str(e)}


# V249309 - complete feed rebuild: video-only API alias for a clean TikTok renderer.
@app.get("/api/flow/clean_v249309")
def api_flow_clean_v249309(request: Request, room_id: str = Query(...), visibility: str = Query("public"),
                           limit: int = Query(900), scan_limit: int = Query(9000), viewer_id: str = Query(default="")):
    try:
        data = api_flow_clean_v249308(request=request, room_id=room_id, visibility=visibility,
                                      limit=limit, scan_limit=scan_limit, viewer_id=viewer_id)
        if isinstance(data, dict):
            data["version"] = "V249309-feed-tiktok-clean-rebuild"
            data["policy"] = "Clean rebuilt Feed: videos only from server; golden wisdom cards are client-side; old Ithaki text bars/polls/system cards are not returned here. Database untouched."
            data["allowed"] = ["video_posts"]
            data["hidden"] = ["plain_ithaki_text", "poll_cards", "old_bars", "system_strips", "non_video_posts"]
        return data
    except Exception as e:
        return {"ok": False, "version": "V249309-feed-tiktok-clean-rebuild", "room_id": room_id, "error": str(e), "posts": []}

@app.get("/api/flow/video_quality_status_v249309")
def api_flow_video_quality_status_v249309(room_id: str = Query(default="ithaki-phone-test")):
    return {"ok": True, "version": "V249309-feed-tiktok-clean-rebuild", "room_id": room_id,
            "frontend": "New renderer clears the old feed and renders one full-screen centered video per screen.",
            "search": "single persistent search/options toolbar",
            "allowed": ["fullscreen videos", "golden wisdom cards with boat logo, heart and comments"],
            "hidden": ["Ithaki text bars", "polls", "old toolbars", "plain text posts", "photo/file cards"]}

# V249310 - clean feed API alias + group-entry APK/server marker.
@app.get("/api/flow/clean_v249310")
def api_flow_clean_v249310(request: Request, room_id: str = Query(...), visibility: str = Query("public"),
                           limit: int = Query(900), scan_limit: int = Query(9000), viewer_id: str = Query(default="")):
    try:
        data = api_flow_clean_v249309(request=request, room_id=room_id, visibility=visibility,
                                      limit=limit, scan_limit=scan_limit, viewer_id=viewer_id)
        if isinstance(data, dict):
            data["version"] = "V249310-clean-feed-group-entry"
            data["policy"] = "Client renders only fullscreen videos and golden wisdom cards; old Ithaki text bars/polls/system cards are excluded. Message-group entry fixed in frontend. Database untouched."
            data["allowed"] = ["fullscreen_video_posts", "client_golden_wisdom_cards"]
            data["hidden"] = ["plain_ithaki_text", "poll_cards", "old_bars", "system_strips", "non_video_posts"]
        return data
    except Exception as e:
        return {"ok": False, "version": "V249310-clean-feed-group-entry", "room_id": room_id, "error": str(e), "posts": []}

@app.get("/api/flow/video_quality_status_v249310")
def api_flow_video_quality_status_v249310(room_id: str = Query(default="ithaki-phone-test")):
    return {"ok": True, "version": "V249310-clean-feed-group-entry", "room_id": room_id,
            "frontend": "Final hard override: one active TikTok Feed renderer with V249310 badge; old feed blocks are hidden and re-rendered if they come back.",
            "search": "single persistent search/options toolbar: Όλα / Βίντεο / Σοφά / Δικά μου",
            "message_groups": "Group rows/buttons are clickable through capture/data handlers and open group messages directly.",
            "allowed": ["fullscreen centered videos", "golden wisdom cards with boat logo, heart and comments"],
            "hidden": ["Ithaki text bars", "polls", "old toolbars", "plain text posts", "photo/file cards"]}
# V249311 - professional spaces workspace + mobile feed centering alias.
@app.get("/api/flow/clean_v249311")
def api_flow_clean_v249311(request: Request, room_id: str = Query(...), visibility: str = Query("public"),
                           limit: int = Query(900), scan_limit: int = Query(9000), viewer_id: str = Query(default="")):
    try:
        data = api_flow_clean_v249310(request=request, room_id=room_id, visibility=visibility,
                                      limit=limit, scan_limit=scan_limit, viewer_id=viewer_id)
        if isinstance(data, dict):
            data["version"] = "V249311-pro-spaces-mobile-feed-center"
            data["policy"] = "Client keeps the clean TikTok video feed, centers mobile videos full-screen, and renders rooms/communities in a cleaner professional workspace."
        return data
    except Exception as e:
        return {"ok": False, "version": "V249311-pro-spaces-mobile-feed-center", "room_id": room_id, "error": str(e), "posts": []}

@app.get("/api/flow/video_quality_status_v249311")
def api_flow_video_quality_status_v249311(room_id: str = Query(default="ithaki-phone-test")):
    return {"ok": True, "version": "V249311-pro-spaces-mobile-feed-center", "room_id": room_id,
            "frontend": "Professional rooms/communities workspace with cleaner cards and less crowded actions. Feed videos stay centered full-screen on mobile.",
            "spaces": "Sidebar + main workspace on bigger screens, stacked simple layout on mobile.",
            "search": "Persistent search and sort for rooms/communities.",
            "message_groups": "Keeps the V249310 group-entry click fix.",
            "allowed": ["fullscreen centered videos on mobile", "golden wisdom cards", "professional rooms/communities cards"],
            "hidden": ["crowded room/community actions", "old Ithaki text bars", "polls", "plain text feed cards", "photo/file feed cards"]}


def _v249312_is_allowed_feed_post(d: dict) -> bool:
    try:
        body, media = _v249295_parse_feed_text(str(d.get("text") or ""))
        media = media if isinstance(media, dict) else {}
        kind = str((media or {}).get("kind") or d.get("post_kind") or "").lower()
        body_l = str(body or "").lower()
        note_l = str(d.get("text") or "").lower()
        if kind == "poll" or "ανωνυμη ψηφοφορια" in body_l or "poll_options" in note_l or '"kind": "poll"' in note_l or '"kind":"poll"' in note_l:
            return True
        if kind == "video":
            return True
        url = str((media or {}).get("url") or (media or {}).get("file_url") or (media or {}).get("media_url") or "")
        if any(url.lower().endswith(ext) or (ext + "?") in url.lower() for ext in [".mp4", ".webm", ".mov", ".m4v", ".3gp", ".mkv", ".avi"]):
            return True
    except Exception:
        return False
    return False

@app.get("/api/flow/clean_v249312")
def api_flow_clean_v249312(request: Request, room_id: str = Query(...), visibility: str = Query("public"),
                           limit: int = Query(900), scan_limit: int = Query(9000), viewer_id: str = Query(default="")):
    try:
        data = get_flow(request=request, room_id=room_id, visibility=visibility, limit=min(int(scan_limit), 500), lite=0, max_media_chars=12000000, viewer_id=viewer_id, author_id="", no304=1)
        posts = []
        hidden = 0
        for d in (data.get("posts") or []):
            if _v249312_is_allowed_feed_post(d):
                posts.append(d)
            else:
                hidden += 1
            if len(posts) >= int(limit):
                break
        data["ok"] = True
        data["version"] = "V249313-translation-book-dictionaries"
        data["posts"] = posts
        data["returned"] = len(posts)
        data["hidden_other_cards_v249312"] = hidden
        data["policy"] = "Client renders fullscreen centered videos, golden wisdom cards and golden member poll cards. Plain text/photo/file/system cards stay hidden in the live Feed only."
        data["allowed"] = ["fullscreen_video_posts", "member_poll_cards", "client_golden_wisdom_cards"]
        data["hidden"] = ["plain_ithaki_text", "old_bars", "system_strips", "photo_posts", "file_cards"]
        return data
    except Exception as e:
        return {"ok": False, "version": "V249313-translation-book-dictionaries", "room_id": room_id, "error": str(e), "posts": []}

@app.get("/api/flow/video_quality_status_v249312")
def api_flow_video_quality_status_v249312(room_id: str = Query(default="ithaki-phone-test")):
    return {"ok": True, "version": "V249313-translation-book-dictionaries", "room_id": room_id,
            "frontend": "One active TikTok-style Feed renderer with centered fullscreen videos on mobile, golden wisdom cards and golden member poll cards with correct geometry.",
            "spaces": "Professional rooms/communities workspace plus leave, rename and owner kick actions.",
            "calls": "TURN-aware call config, direct WebSocket call signaling with HTTP polling fallback, and automatic eco retry on connection failure.",
            "message_groups": "Keeps the V249310 group-entry click fix.",
            "feed_video_target": "720p / about 2500 kbps faststart for faster first frame on mobile data.",
            "allowed": ["fullscreen centered videos", "golden wisdom cards", "golden member poll cards"],
            "hidden": ["Ithaki text bars", "old toolbars", "plain text posts", "photo/file cards"]}

@app.get("/api/video/diagnostics/v249312")
def api_video_diagnostics_v249312(room_id: str = Query("ithaki-phone-test")):
    cfg = _video_cfg(room_id)
    return {"ok": True, "version": "V249313-translation-book-dictionaries", "room_id": room_id,
            "ws_call_signaling": bool(cfg.get("ws_call_signaling", True)),
            "eco_retry_on_fail": bool(cfg.get("eco_retry_on_fail", True)),
            "ice_servers": cfg.get("ice_servers") or [],
            "default_quality": cfg.get("default_quality"),
            "quality_profiles": cfg.get("quality_profiles") or {}}




@app.get("/api/flow/video_quality_status_v249314")
def api_flow_video_quality_status_v249314(room_id: str = Query(default="ithaki-phone-test")):
    try:
        base = api_flow_video_quality_status_v249312(room_id=room_id)
    except Exception:
        base = {"ok": True, "room_id": room_id}
    base.update({
        "version": "V249314-visible-spaces-dm-feed-fix",
        "frontend": "Final visible override for Communities/Rooms cards, Messages friends+groups scroll tabs, and Feed search toolbar.",
        "spaces": "professional cards with open/rules/announcements/members and owner/member management buttons",
        "messages": "friends and groups now render in separate scrollable tabs inside Messages",
        "feed": "full-screen centered videos with persistent search bar and mode chips: Όλα / Βίντεο / Ψηφ. / Σοφά / Δικά μου"
    })
    return base


@app.get("/api/flow/video_quality_status_v249318")
def video_quality_status_v249318():
    return {
        "ok": True,
        "version": "V249319-feed-dm-book-real-fix",
        "frontend": "Full-screen feed videos with fixed toolbar/root, real translation-book modal, DM friends/avatar/groups panels with independent scroll.",
        "feed": ["videos use full-frame cover and centered snap", "captions appear only when text exists", "gold wisdom/poll cards are compact"],
        "messages": ["friends tab", "avatar tab", "groups tab", "independent scroll lists"],
        "translation_book": ["fixed top book button", "modal opens independently from member menu", "local dictionary import/export"],
    }


# ──────────────────────────────────────────────
# V249342 — Real Feed / Lighthouse / Anonymous Wall code
# Additive and data-safe. Does not reset old DB tables.
# ──────────────────────────────────────────────
_ws_live_loop_v249342 = None
_ws_live_rooms_v249342 = {}

def _ws_live_register_v249342(room_id: str, websocket):
    room_id = str(room_id or "ithaki-phone-test")[:120]
    _ws_live_rooms_v249342.setdefault(room_id, set()).add(websocket)

def _ws_live_unregister_v249342(room_id: str, websocket):
    room_id = str(room_id or "ithaki-phone-test")[:120]
    s = _ws_live_rooms_v249342.get(room_id)
    if s:
        s.discard(websocket)
        if not s:
            _ws_live_rooms_v249342.pop(room_id, None)

async def _ws_live_send_v249342(room_id: str, frame: dict):
    room_id = str(room_id or "ithaki-phone-test")[:120]
    targets = list(_ws_live_rooms_v249342.get(room_id) or [])
    stale = []
    for ws in targets:
        try:
            await ws.send_json(frame)
        except Exception:
            stale.append(ws)
    for ws in stale:
        _ws_live_unregister_v249342(room_id, ws)

def ws_live_notify_v249342(room_id: str, event_type: str, payload: dict):
    """Instant WebSocket notify for Lighthouse and Wall. No polling required for updates."""
    loop = _ws_live_loop_v249342
    if loop is None:
        return
    frame = {"type": str(event_type or "event"), "room_id": str(room_id or "ithaki-phone-test")[:120], "ts": now(), "payload": payload or {}}
    try:
        loop.call_soon_threadsafe(lambda: asyncio.create_task(_ws_live_send_v249342(frame["room_id"], frame)))
    except Exception:
        pass

@app.websocket("/ws/live_v249342")
async def ws_live_v249342(websocket: WebSocket):
    await websocket.accept()
    room_id = "ithaki-phone-test"
    global _ws_live_loop_v249342
    _ws_live_loop_v249342 = asyncio.get_event_loop()
    try:
        hello = await asyncio.wait_for(websocket.receive_json(), timeout=20)
        room_id = str((hello or {}).get("room_id") or "ithaki-phone-test")[:120]
        _ws_live_register_v249342(room_id, websocket)
        await websocket.send_json({"type":"ready", "feature":"live_v249342", "room_id":room_id, "ts":now()})
        while True:
            msg = await websocket.receive_json()
            if (msg or {}).get("type") == "ping":
                await websocket.send_json({"type":"pong", "ts":now()})
            elif (msg or {}).get("type") == "sub":
                new_room = str((msg or {}).get("room_id") or room_id)[:120]
                if new_room != room_id:
                    _ws_live_unregister_v249342(room_id, websocket)
                    room_id = new_room
                    _ws_live_register_v249342(room_id, websocket)
                    await websocket.send_json({"type":"subscribed", "room_id":room_id, "ts":now()})
    except WebSocketDisconnect:
        _ws_live_unregister_v249342(room_id, websocket)
    except Exception:
        try:
            _ws_live_unregister_v249342(room_id, websocket)
        except Exception:
            pass


def _owner_hash_v249342(token: str) -> str:
    token = str(token or "").strip()
    if not token:
        token = "anonymous-" + secrets.token_hex(12)
    return hashlib.sha256(("ok-ithaki-v249342:" + token).encode("utf-8")).hexdigest()

def _night_id_v249342() -> str:
    return time.strftime("%Y-%m-%d")

def ensure_lighthouse_tables_v249342():
    ensure_lighthouse_tables_v249278()
    try:
        execute("CREATE INDEX IF NOT EXISTS idx_lighthouse_v249342_unique_lookup ON lighthouse_lights(room_id,night_id,owner_hash)")
        execute("CREATE INDEX IF NOT EXISTS idx_lighthouse_v249342_touch_lookup ON lighthouse_touches(room_id,night_id,light_id,toucher_hash)")
    except Exception:
        pass

def _light_pos_v249342(owner_hash: str, night_id: str):
    h = int(hashlib.sha256((owner_hash + ':' + night_id).encode('utf-8')).hexdigest()[:14], 16)
    x = 8 + (h % 84)
    y = 18 + ((h // 97) % 54)
    return float(x), float(y)

@app.get("/api/lighthouse/sky_v249342")
def api_lighthouse_sky_v249342(room_id: str = Query("ithaki-phone-test"), owner_token: str = Query(""), limit: int = Query(240)):
    ensure_lighthouse_tables_v249342()
    room_id = str(room_id or "ithaki-phone-test")[:120]
    night = _night_id_v249342()
    owner_hash = _owner_hash_v249342(owner_token)
    rs = rows("""
        SELECT light_id, x, y, touched_count, created_at
        FROM lighthouse_lights
        WHERE room_id=? AND night_id=?
        ORDER BY created_at DESC LIMIT ?
    """, (room_id, night, max(1, min(int(limit or 240), 500))))
    mine = one("SELECT light_id, x, y, touched_count, created_at FROM lighthouse_lights WHERE room_id=? AND night_id=? AND owner_hash=? ORDER BY created_at DESC LIMIT 1", (room_id, night, owner_hash))
    live_now = one("SELECT COUNT(*) AS c FROM lighthouse_lights WHERE room_id=? AND night_id=? AND created_at>?", (room_id, night, now()-600))
    total_today = one("SELECT COUNT(*) AS c, COALESCE(SUM(touched_count),0) AS touches FROM lighthouse_lights WHERE room_id=? AND night_id=?", (room_id, night))
    return {"ok": True, "version":"V249342", "night_id": night, "one_light_per_person_per_night": True, "ws":"/ws/live_v249342", "mine": dict(mine) if mine else None, "lights": [dict(r) for r in rs], "live_now": int((live_now or {"c":0})["c"] or 0), "total_today": int((total_today or {"c":0})["c"] or 0), "touches_today": int((total_today or {"touches":0})["touches"] or 0)}

@app.post("/api/lighthouse/light_v249342")
def api_lighthouse_light_v249342(payload: dict):
    ensure_lighthouse_tables_v249342()
    room_id = str((payload or {}).get("room_id") or "ithaki-phone-test")[:120]
    owner_token = str((payload or {}).get("owner_token") or (payload or {}).get("device_id") or "")[:240]
    night = _night_id_v249342()
    owner_hash = _owner_hash_v249342(owner_token)
    existing = one("SELECT light_id, x, y, touched_count, created_at FROM lighthouse_lights WHERE room_id=? AND night_id=? AND owner_hash=? ORDER BY created_at DESC LIMIT 1", (room_id, night, owner_hash))
    if existing:
        return {"ok": True, "created": False, "already": True, "night_id": night, "light": dict(existing), "message":"Έχεις ήδη ανάψει το φως σου απόψε."}
    x, y = _light_pos_v249342(owner_hash, night)
    lid = nid("light")
    execute("INSERT INTO lighthouse_lights(light_id,room_id,night_id,owner_hash,x,y,touched_count,created_at) VALUES(?,?,?,?,?,?,0,?)", (lid, room_id, night, owner_hash, x, y, now()))
    light = {"light_id": lid, "x": x, "y": y, "touched_count": 0, "created_at": now()}
    try: ws_notify(room_id)
    except Exception: pass
    ws_live_notify_v249342(room_id, "lighthouse_light", {"night_id": night, "light": light})
    return {"ok": True, "created": True, "already": False, "night_id": night, "light": light, "message":"Το φως σου άναψε για απόψε."}

@app.post("/api/lighthouse/touch_v249342")
def api_lighthouse_touch_v249342(payload: dict):
    ensure_lighthouse_tables_v249342()
    room_id = str((payload or {}).get("room_id") or "ithaki-phone-test")[:120]
    light_id = str((payload or {}).get("light_id") or "")[:120]
    owner_token = str((payload or {}).get("owner_token") or (payload or {}).get("device_id") or "")[:240]
    if not light_id:
        raise HTTPException(400, "light_id required")
    night = _night_id_v249342()
    toucher_hash = _owner_hash_v249342(owner_token)
    light = one("SELECT light_id, owner_hash, touched_count FROM lighthouse_lights WHERE room_id=? AND night_id=? AND light_id=? LIMIT 1", (room_id, night, light_id))
    if not light:
        raise HTTPException(404, "Light not found")
    if str(light["owner_hash"]) == toucher_hash:
        return {"ok": True, "already": True, "self": True, "touched_count": int(light["touched_count"] or 0)}
    old = one("SELECT touch_id FROM lighthouse_touches WHERE room_id=? AND night_id=? AND light_id=? AND toucher_hash=? LIMIT 1", (room_id, night, light_id, toucher_hash))
    if old:
        return {"ok": True, "already": True, "self": False, "touched_count": int(light["touched_count"] or 0)}
    execute("INSERT INTO lighthouse_touches(touch_id,light_id,room_id,night_id,toucher_hash,created_at) VALUES(?,?,?,?,?,?)", (nid("touch"), light_id, room_id, night, toucher_hash, now()))
    execute("UPDATE lighthouse_lights SET touched_count=touched_count+1 WHERE light_id=?", (light_id,))
    cnt = one("SELECT touched_count FROM lighthouse_lights WHERE light_id=?", (light_id,))
    touched_count = int((cnt or {"touched_count":0})["touched_count"] or 0)
    try: ws_notify(room_id)
    except Exception: pass
    ws_live_notify_v249342(room_id, "lighthouse_touch", {"night_id": night, "light_id": light_id, "touched_count": touched_count})
    return {"ok": True, "already": False, "self": False, "touched_count": touched_count}


def ensure_wall_tables_v249342():
    execute("""
    CREATE TABLE IF NOT EXISTS anonymous_wall_posts_v249342(
        post_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL,
        author_hash TEXT NOT NULL,
        text TEXT NOT NULL,
        likes INTEGER NOT NULL DEFAULT 0,
        created_at REAL NOT NULL,
        updated_at REAL NOT NULL
    )""")
    execute("""
    CREATE TABLE IF NOT EXISTS anonymous_wall_likes_v249342(
        room_id TEXT NOT NULL,
        post_id TEXT NOT NULL,
        liker_hash TEXT NOT NULL,
        created_at REAL NOT NULL,
        PRIMARY KEY(room_id,post_id,liker_hash)
    )""")
    try:
        execute("CREATE INDEX IF NOT EXISTS idx_wall_posts_v249342_room ON anonymous_wall_posts_v249342(room_id,created_at)")
    except Exception:
        pass

@app.get("/api/wall/list_v249342")
def api_wall_list_v249342(room_id: str = Query("ithaki-phone-test"), limit: int = Query(80)):
    ensure_wall_tables_v249342()
    room_id = str(room_id or "ithaki-phone-test")[:120]
    rs = rows("SELECT post_id, text, likes, created_at, updated_at FROM anonymous_wall_posts_v249342 WHERE room_id=? ORDER BY created_at DESC LIMIT ?", (room_id, max(1, min(int(limit or 80), 200))))
    live_now = one("SELECT COUNT(*) AS c FROM anonymous_wall_posts_v249342 WHERE room_id=? AND created_at>?", (room_id, now()-900))
    return {"ok": True, "version":"V249342", "anonymous": True, "ws":"/ws/live_v249342", "live_now": int((live_now or {"c":0})["c"] or 0), "posts": [dict(r) for r in rs]}

@app.post("/api/wall/post_v249342")
def api_wall_post_v249342(payload: dict):
    ensure_wall_tables_v249342()
    room_id = str((payload or {}).get("room_id") or "ithaki-phone-test")[:120]
    token = str((payload or {}).get("owner_token") or (payload or {}).get("device_id") or "")[:240]
    text = str((payload or {}).get("text") or "").strip()
    if not text:
        raise HTTPException(400, "empty wall text")
    if len(text) > 700:
        text = text[:700]
    pid = nid("wall")
    ts = now()
    execute("INSERT INTO anonymous_wall_posts_v249342(post_id,room_id,author_hash,text,likes,created_at,updated_at) VALUES(?,?,?,?,0,?,?)", (pid, room_id, _owner_hash_v249342(token), text, ts, ts))
    post = {"post_id": pid, "text": text, "likes": 0, "created_at": ts, "updated_at": ts}
    try: ws_notify(room_id)
    except Exception: pass
    ws_live_notify_v249342(room_id, "wall_post", {"post": post})
    return {"ok": True, "post": post}

@app.post("/api/wall/like_v249342")
def api_wall_like_v249342(payload: dict):
    ensure_wall_tables_v249342()
    room_id = str((payload or {}).get("room_id") or "ithaki-phone-test")[:120]
    post_id = str((payload or {}).get("post_id") or "")[:120]
    token = str((payload or {}).get("owner_token") or (payload or {}).get("device_id") or "")[:240]
    if not post_id:
        raise HTTPException(400, "post_id required")
    if not one("SELECT post_id FROM anonymous_wall_posts_v249342 WHERE room_id=? AND post_id=?", (room_id, post_id)):
        raise HTTPException(404, "wall post not found")
    h = _owner_hash_v249342(token)
    old = one("SELECT post_id FROM anonymous_wall_likes_v249342 WHERE room_id=? AND post_id=? AND liker_hash=?", (room_id, post_id, h))
    if not old:
        execute("INSERT INTO anonymous_wall_likes_v249342(room_id,post_id,liker_hash,created_at) VALUES(?,?,?,?)", (room_id, post_id, h, now()))
        execute("UPDATE anonymous_wall_posts_v249342 SET likes=likes+1, updated_at=? WHERE room_id=? AND post_id=?", (now(), room_id, post_id))
    p = one("SELECT post_id, likes FROM anonymous_wall_posts_v249342 WHERE room_id=? AND post_id=?", (room_id, post_id))
    likes = int((p or {"likes":0})["likes"] or 0)
    try: ws_notify(room_id)
    except Exception: pass
    ws_live_notify_v249342(room_id, "wall_like", {"post_id": post_id, "likes": likes})
    return {"ok": True, "post_id": post_id, "likes": likes, "already": bool(old)}

@app.get("/api/v249342/status")
def api_v249342_status():
    return {"ok": True, "version": VERSION, "features": ["feed_tiktok_fullscreen", "special_cards_every_7_videos", "lighthouse_one_light_per_night", "lighthouse_wall_live_websocket", "anonymous_wall_real"]}



# ===== OK ITHAKI V249344 WALL + LIGHTHOUSE BACKEND =====
_V249344_WS_CLIENTS = set()

def _v249344_now() -> int:
    return int(time.time())

def _v249344_hash_token(token: str) -> str:
    token = str(token or '').strip() or secrets.token_hex(16)
    return hashlib.sha256(("ok-ithaki-v249344:" + token).encode("utf-8")).hexdigest()

def _v249344_night_id(ts: int | None = None) -> str:
    dt = datetime.datetime.utcfromtimestamp(ts or time.time()) + datetime.timedelta(hours=3)
    return dt.strftime("%Y-%m-%d")

def _v249344_db_init():
    c = get_conn()
    c.execute("""CREATE TABLE IF NOT EXISTS wall_posts_v249344(
        post_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL,
        owner_hash TEXT NOT NULL,
        text TEXT NOT NULL,
        created_at INTEGER NOT NULL,
        hidden INTEGER DEFAULT 0
    )""")
    c.execute("""CREATE TABLE IF NOT EXISTS wall_reactions_v249344(
        post_id TEXT NOT NULL,
        owner_hash TEXT NOT NULL,
        kind TEXT NOT NULL,
        created_at INTEGER NOT NULL,
        PRIMARY KEY(post_id, owner_hash, kind)
    )""")
    c.execute("""CREATE TABLE IF NOT EXISTS wall_reports_v249344(
        report_id TEXT PRIMARY KEY,
        post_id TEXT NOT NULL,
        owner_hash TEXT NOT NULL,
        reason TEXT,
        created_at INTEGER NOT NULL
    )""")
    c.execute("""CREATE TABLE IF NOT EXISTS lighthouse_lights_v249344(
        light_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL,
        night_id TEXT NOT NULL,
        owner_hash TEXT NOT NULL,
        x REAL NOT NULL,
        y REAL NOT NULL,
        created_at INTEGER NOT NULL,
        UNIQUE(room_id, night_id, owner_hash)
    )""")
    c.execute("""CREATE TABLE IF NOT EXISTS lighthouse_touches_v249344(
        touch_id TEXT PRIMARY KEY,
        light_id TEXT NOT NULL,
        room_id TEXT NOT NULL,
        night_id TEXT NOT NULL,
        owner_hash TEXT NOT NULL,
        created_at INTEGER NOT NULL
    )""")
    c.commit()

async def _v249344_broadcast(payload: dict):
    dead = []
    data = json.dumps(payload, ensure_ascii=False)
    for ws in list(_V249344_WS_CLIENTS):
        try:
            await ws.send_text(data)
        except Exception:
            dead.append(ws)
    for ws in dead:
        _V249344_WS_CLIENTS.discard(ws)

@app.websocket("/ws/live_v249344")
async def ws_live_v249344(ws: WebSocket):
    await ws.accept()
    _V249344_WS_CLIENTS.add(ws)
    try:
        await ws.send_json({"ok": True, "type": "ready", "version": "V249344"})
        while True:
            try:
                msg = await ws.receive_text()
                if "ping" in str(msg).lower():
                    await ws.send_json({"ok": True, "type": "pong", "ts": _v249344_now()})
            except WebSocketDisconnect:
                break
            except Exception:
                await asyncio.sleep(0.2)
    finally:
        _V249344_WS_CLIENTS.discard(ws)

@app.get("/api/wall/list_v249344")
def api_wall_list_v249344(room_id: str = Query("ithaki-phone-test"), limit: int = Query(80)):
    _v249344_db_init()
    limit = max(1, min(int(limit or 80), 200))
    rows = _conn.execute("""
        SELECT p.post_id,p.text,p.created_at,
               SUM(CASE WHEN r.kind='support' THEN 1 ELSE 0 END) AS support,
               SUM(CASE WHEN r.kind='understand' THEN 1 ELSE 0 END) AS understand,
               SUM(CASE WHEN r.kind='reply' THEN 1 ELSE 0 END) AS replies
        FROM wall_posts_v249344 p
        LEFT JOIN wall_reactions_v249344 r ON r.post_id=p.post_id
        WHERE p.room_id=? AND COALESCE(p.hidden,0)=0
        GROUP BY p.post_id
        ORDER BY p.created_at DESC
        LIMIT ?
    """, (room_id, limit)).fetchall()
    online = 0
    try:
        online = int(_conn.execute("SELECT COUNT(*) FROM people WHERE room_id=? AND last_seen>=?", (room_id, _v249344_now()-180)).fetchone()[0] or 0)
    except Exception:
        online = 0
    posts = [{"post_id": r["post_id"], "text": r["text"], "created_at": int(r["created_at"] or 0),
              "support": int(r["support"] or 0), "understand": int(r["understand"] or 0), "replies": int(r["replies"] or 0)} for r in rows]
    return {"ok": True, "version": "V249344", "room_id": room_id, "live_now": online, "posts": posts}

@app.post("/api/wall/post_v249344")
async def api_wall_post_v249344(req: Request):
    _v249344_db_init()
    body = await req.json()
    room_id = str(body.get("room_id") or "ithaki-phone-test")[:160]
    owner = _v249344_hash_token(str(body.get("owner_token") or body.get("device_id") or ""))
    text = re.sub(r"\s+", " ", str(body.get("text") or "")).strip()
    if len(text) < 2:
        return {"ok": False, "reason": "empty_text", "message": "Γράψε πρώτα κάτι."}
    text = text[:500]
    post_id = "wall_" + secrets.token_urlsafe(14)
    now = _v249344_now()
    _conn.execute("INSERT INTO wall_posts_v249344(post_id,room_id,owner_hash,text,created_at,hidden) VALUES(?,?,?,?,?,0)", (post_id, room_id, owner, text, now))
    _conn.commit()
    await _v249344_broadcast({"type": "wall_post", "room_id": room_id, "post_id": post_id, "created_at": now})
    return {"ok": True, "post_id": post_id, "created_at": now}

@app.post("/api/wall/react_v249344")
async def api_wall_react_v249344(req: Request):
    _v249344_db_init()
    body = await req.json()
    room_id = str(body.get("room_id") or "ithaki-phone-test")[:160]
    post_id = str(body.get("post_id") or "")[:120]
    kind = str(body.get("kind") or "support").strip().lower()
    if kind not in ("support", "understand", "reply"):
        kind = "support"
    owner = _v249344_hash_token(str(body.get("owner_token") or body.get("device_id") or ""))
    if not post_id:
        return {"ok": False, "reason": "missing_post"}
    _conn.execute("INSERT OR IGNORE INTO wall_reactions_v249344(post_id,owner_hash,kind,created_at) VALUES(?,?,?,?)", (post_id, owner, kind, _v249344_now()))
    _conn.commit()
    counts = _conn.execute("""
        SELECT SUM(CASE WHEN kind='support' THEN 1 ELSE 0 END) support,
               SUM(CASE WHEN kind='understand' THEN 1 ELSE 0 END) understand,
               SUM(CASE WHEN kind='reply' THEN 1 ELSE 0 END) replies
        FROM wall_reactions_v249344 WHERE post_id=?
    """, (post_id,)).fetchone()
    await _v249344_broadcast({"type": "wall_react", "room_id": room_id, "post_id": post_id, "kind": kind})
    return {"ok": True, "post_id": post_id, "support": int(counts["support"] or 0), "understand": int(counts["understand"] or 0), "replies": int(counts["replies"] or 0)}

@app.post("/api/wall/report_v249344")
async def api_wall_report_v249344(req: Request):
    _v249344_db_init()
    body = await req.json()
    post_id = str(body.get("post_id") or "")[:120]
    owner = _v249344_hash_token(str(body.get("owner_token") or body.get("device_id") or ""))
    rid = "rep_" + secrets.token_urlsafe(12)
    _conn.execute("INSERT OR IGNORE INTO wall_reports_v249344(report_id,post_id,owner_hash,reason,created_at) VALUES(?,?,?,?,?)", (rid, post_id, owner, str(body.get("reason") or "")[:240], _v249344_now()))
    _conn.commit()
    return {"ok": True, "report_id": rid}

@app.get("/api/lighthouse/sky_v249344")
def api_lighthouse_sky_v249344(room_id: str = Query("ithaki-phone-test"), owner_token: str = Query("")):
    _v249344_db_init()
    night = _v249344_night_id()
    rows = _conn.execute("SELECT light_id,x,y,created_at FROM lighthouse_lights_v249344 WHERE room_id=? AND night_id=? ORDER BY created_at DESC LIMIT 220", (room_id, night)).fetchall()
    touches = _conn.execute("SELECT COUNT(*) FROM lighthouse_touches_v249344 WHERE room_id=? AND night_id=?", (room_id, night)).fetchone()[0]
    mine = _conn.execute("SELECT light_id FROM lighthouse_lights_v249344 WHERE room_id=? AND night_id=? AND owner_hash=?", (room_id, night, _v249344_hash_token(owner_token))).fetchone()
    return {"ok": True, "version": "V249344", "night_id": night, "mine_lit": bool(mine), "total_today": len(rows), "touches_today": int(touches or 0), "lights": [dict(r) for r in rows]}

@app.post("/api/lighthouse/light_v249344")
async def api_lighthouse_light_v249344(req: Request):
    _v249344_db_init()
    body = await req.json()
    room_id = str(body.get("room_id") or "ithaki-phone-test")[:160]
    owner = _v249344_hash_token(str(body.get("owner_token") or body.get("device_id") or ""))
    night = _v249344_night_id()
    existing = _conn.execute("SELECT light_id FROM lighthouse_lights_v249344 WHERE room_id=? AND night_id=? AND owner_hash=?", (room_id, night, owner)).fetchone()
    if existing:
        return {"ok": True, "already": True, "message": "Έχεις ήδη ανάψει το φως σου για αυτή τη νύχτα.", "light_id": existing["light_id"], "night_id": night}
    light_id = "light_" + secrets.token_urlsafe(12)
    seed = int(hashlib.sha256((owner + night).encode()).hexdigest()[:8], 16)
    x = 8 + (seed % 8400) / 100.0
    y = 12 + ((seed // 97) % 6800) / 100.0
    now = _v249344_now()
    _conn.execute("INSERT INTO lighthouse_lights_v249344(light_id,room_id,night_id,owner_hash,x,y,created_at) VALUES(?,?,?,?,?,?,?)", (light_id, room_id, night, owner, x, y, now))
    _conn.commit()
    await _v249344_broadcast({"type": "lighthouse_light", "room_id": room_id, "light_id": light_id, "night_id": night})
    return {"ok": True, "light_id": light_id, "night_id": night, "x": x, "y": y, "created_at": now}

@app.post("/api/lighthouse/touch_v249344")
async def api_lighthouse_touch_v249344(req: Request):
    _v249344_db_init()
    body = await req.json()
    room_id = str(body.get("room_id") or "ithaki-phone-test")[:160]
    light_id = str(body.get("light_id") or "")[:140]
    tid = "touch_" + secrets.token_urlsafe(12)
    _conn.execute("INSERT OR IGNORE INTO lighthouse_touches_v249344(touch_id,light_id,room_id,night_id,owner_hash,created_at) VALUES(?,?,?,?,?,?)", (tid, light_id, room_id, _v249344_night_id(), _v249344_hash_token(str(body.get("owner_token") or body.get("device_id") or "")), _v249344_now()))
    _conn.commit()
    await _v249344_broadcast({"type": "lighthouse_touch", "room_id": room_id, "light_id": light_id})
    return {"ok": True, "touch_id": tid}

@app.get("/clean_rebuild_v249344.js")
def frontend_clean_rebuild_v249344_js():
    p = FRONTEND / "clean_rebuild_v249344.js"
    if p.exists():
        return Response(p.read_text(encoding="utf-8"), media_type="application/javascript; charset=utf-8", headers={"Cache-Control": "no-store"})
    raise HTTPException(status_code=404, detail="clean_rebuild_v249344.js missing")
# ===== END OK ITHAKI V249344 WALL + LIGHTHOUSE BACKEND =====


# ===== OK ITHAKI V249345 WALL + LIGHTHOUSE BACKEND =====
_V249345_WS_CLIENTS = set()

def _v249345_now() -> int:
    return int(time.time())

def _v249345_hash_token(token: str) -> str:
    token = str(token or '').strip() or secrets.token_hex(16)
    return hashlib.sha256(("ok-ithaki-v249345:" + token).encode("utf-8")).hexdigest()

def _v249345_night_id(ts: int | None = None) -> str:
    dt = datetime.datetime.utcfromtimestamp(ts or time.time()) + datetime.timedelta(hours=3)
    return dt.strftime("%Y-%m-%d")

def _v249345_db_init():
    c = get_conn()
    c.execute("""CREATE TABLE IF NOT EXISTS wall_posts_v249345(
        post_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL,
        owner_hash TEXT NOT NULL,
        text TEXT NOT NULL,
        created_at INTEGER NOT NULL,
        hidden INTEGER DEFAULT 0
    )""")
    c.execute("""CREATE TABLE IF NOT EXISTS wall_reactions_v249345(
        post_id TEXT NOT NULL,
        owner_hash TEXT NOT NULL,
        kind TEXT NOT NULL,
        created_at INTEGER NOT NULL,
        PRIMARY KEY(post_id, owner_hash, kind)
    )""")
    c.execute("""CREATE TABLE IF NOT EXISTS wall_reports_v249345(
        report_id TEXT PRIMARY KEY,
        post_id TEXT NOT NULL,
        owner_hash TEXT NOT NULL,
        reason TEXT,
        created_at INTEGER NOT NULL
    )""")
    c.execute("""CREATE TABLE IF NOT EXISTS lighthouse_lights_v249345(
        light_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL,
        night_id TEXT NOT NULL,
        owner_hash TEXT NOT NULL,
        x REAL NOT NULL,
        y REAL NOT NULL,
        created_at INTEGER NOT NULL,
        UNIQUE(room_id, night_id, owner_hash)
    )""")
    c.execute("""CREATE TABLE IF NOT EXISTS lighthouse_touches_v249345(
        touch_id TEXT PRIMARY KEY,
        light_id TEXT NOT NULL,
        room_id TEXT NOT NULL,
        night_id TEXT NOT NULL,
        owner_hash TEXT NOT NULL,
        created_at INTEGER NOT NULL
    )""")
    c.commit()

async def _v249345_broadcast(payload: dict):
    dead = []
    data = json.dumps(payload, ensure_ascii=False)
    for ws in list(_V249345_WS_CLIENTS):
        try:
            await ws.send_text(data)
        except Exception:
            dead.append(ws)
    for ws in dead:
        _V249345_WS_CLIENTS.discard(ws)

@app.websocket("/ws/live_v249345")
async def ws_live_v249345(ws: WebSocket):
    await ws.accept()
    _V249345_WS_CLIENTS.add(ws)
    try:
        await ws.send_json({"ok": True, "type": "ready", "version": "V249345"})
        while True:
            try:
                msg = await ws.receive_text()
                if "ping" in str(msg).lower():
                    await ws.send_json({"ok": True, "type": "pong", "ts": _v249345_now()})
            except WebSocketDisconnect:
                break
            except Exception:
                await asyncio.sleep(0.2)
    finally:
        _V249345_WS_CLIENTS.discard(ws)

@app.get("/api/wall/list_v249345")
def api_wall_list_v249345(room_id: str = Query("ithaki-phone-test"), limit: int = Query(80)):
    _v249345_db_init()
    _v249359_migrate_old_wall_posts()
    limit = max(1, min(int(limit or 80), 200))
    rows = _conn.execute("""
        SELECT p.post_id,p.text,p.created_at,
               SUM(CASE WHEN r.kind='support' THEN 1 ELSE 0 END) AS support,
               SUM(CASE WHEN r.kind='understand' THEN 1 ELSE 0 END) AS understand,
               SUM(CASE WHEN r.kind='reply' THEN 1 ELSE 0 END) AS replies
        FROM wall_posts_v249345 p
        LEFT JOIN wall_reactions_v249345 r ON r.post_id=p.post_id
        WHERE p.room_id=? AND COALESCE(p.hidden,0)=0
        GROUP BY p.post_id
        ORDER BY p.created_at DESC
        LIMIT ?
    """, (room_id, limit)).fetchall()
    online = 0
    try:
        online = int(_conn.execute("SELECT COUNT(*) FROM people WHERE room_id=? AND last_seen>=?", (room_id, _v249345_now()-180)).fetchone()[0] or 0)
    except Exception:
        online = 0
    posts = [{"post_id": r["post_id"], "text": r["text"], "created_at": int(r["created_at"] or 0),
              "support": int(r["support"] or 0), "understand": int(r["understand"] or 0), "replies": int(r["replies"] or 0)} for r in rows]
    return {"ok": True, "version": "V249345", "room_id": room_id, "live_now": online, "posts": posts}

@app.post("/api/wall/post_v249345")
async def api_wall_post_v249345(req: Request):
    _v249345_db_init()
    body = await req.json()
    room_id = str(body.get("room_id") or "ithaki-phone-test")[:160]
    owner = _v249345_hash_token(str(body.get("owner_token") or body.get("device_id") or ""))
    text = re.sub(r"\s+", " ", str(body.get("text") or "")).strip()
    if len(text) < 2:
        return {"ok": False, "reason": "empty_text", "message": "Γράψε πρώτα κάτι."}
    text = text[:500]
    post_id = "wall_" + secrets.token_urlsafe(14)
    now = _v249345_now()
    _conn.execute("INSERT INTO wall_posts_v249345(post_id,room_id,owner_hash,text,created_at,hidden) VALUES(?,?,?,?,?,0)", (post_id, room_id, owner, text, now))
    _conn.commit()
    await _v249345_broadcast({"type": "wall_post", "room_id": room_id, "post_id": post_id, "created_at": now})
    return {"ok": True, "post_id": post_id, "created_at": now}

@app.post("/api/wall/react_v249345")
async def api_wall_react_v249345(req: Request):
    _v249345_db_init()
    body = await req.json()
    room_id = str(body.get("room_id") or "ithaki-phone-test")[:160]
    post_id = str(body.get("post_id") or "")[:120]
    kind = str(body.get("kind") or "support").strip().lower()
    if kind not in ("support", "understand", "reply"):
        kind = "support"
    owner = _v249345_hash_token(str(body.get("owner_token") or body.get("device_id") or ""))
    if not post_id:
        return {"ok": False, "reason": "missing_post"}
    _conn.execute("INSERT OR IGNORE INTO wall_reactions_v249345(post_id,owner_hash,kind,created_at) VALUES(?,?,?,?)", (post_id, owner, kind, _v249345_now()))
    _conn.commit()
    counts = _conn.execute("""
        SELECT SUM(CASE WHEN kind='support' THEN 1 ELSE 0 END) support,
               SUM(CASE WHEN kind='understand' THEN 1 ELSE 0 END) understand,
               SUM(CASE WHEN kind='reply' THEN 1 ELSE 0 END) replies
        FROM wall_reactions_v249345 WHERE post_id=?
    """, (post_id,)).fetchone()
    await _v249345_broadcast({"type": "wall_react", "room_id": room_id, "post_id": post_id, "kind": kind})
    return {"ok": True, "post_id": post_id, "support": int(counts["support"] or 0), "understand": int(counts["understand"] or 0), "replies": int(counts["replies"] or 0)}

@app.post("/api/wall/report_v249345")
async def api_wall_report_v249345(req: Request):
    _v249345_db_init()
    body = await req.json()
    post_id = str(body.get("post_id") or "")[:120]
    owner = _v249345_hash_token(str(body.get("owner_token") or body.get("device_id") or ""))
    rid = "rep_" + secrets.token_urlsafe(12)
    _conn.execute("INSERT OR IGNORE INTO wall_reports_v249345(report_id,post_id,owner_hash,reason,created_at) VALUES(?,?,?,?,?)", (rid, post_id, owner, str(body.get("reason") or "")[:240], _v249345_now()))
    _conn.commit()
    return {"ok": True, "report_id": rid}

@app.get("/api/lighthouse/sky_v249345")
def api_lighthouse_sky_v249345(room_id: str = Query("ithaki-phone-test"), owner_token: str = Query("")):
    _v249345_db_init()
    night = _v249345_night_id()
    rows = _conn.execute("SELECT light_id,x,y,created_at FROM lighthouse_lights_v249345 WHERE room_id=? AND night_id=? ORDER BY created_at DESC LIMIT 220", (room_id, night)).fetchall()
    touches = _conn.execute("SELECT COUNT(*) FROM lighthouse_touches_v249345 WHERE room_id=? AND night_id=?", (room_id, night)).fetchone()[0]
    mine = _conn.execute("SELECT light_id FROM lighthouse_lights_v249345 WHERE room_id=? AND night_id=? AND owner_hash=?", (room_id, night, _v249345_hash_token(owner_token))).fetchone()
    return {"ok": True, "version": "V249345", "night_id": night, "mine_lit": bool(mine), "total_today": len(rows), "touches_today": int(touches or 0), "lights": [dict(r) for r in rows]}

@app.post("/api/lighthouse/light_v249345")
async def api_lighthouse_light_v249345(req: Request):
    _v249345_db_init()
    body = await req.json()
    room_id = str(body.get("room_id") or "ithaki-phone-test")[:160]
    owner = _v249345_hash_token(str(body.get("owner_token") or body.get("device_id") or ""))
    night = _v249345_night_id()
    existing = _conn.execute("SELECT light_id FROM lighthouse_lights_v249345 WHERE room_id=? AND night_id=? AND owner_hash=?", (room_id, night, owner)).fetchone()
    if existing:
        return {"ok": True, "already": True, "message": "Έχεις ήδη ανάψει το φως σου για αυτή τη νύχτα.", "light_id": existing["light_id"], "night_id": night}
    light_id = "light_" + secrets.token_urlsafe(12)
    seed = int(hashlib.sha256((owner + night).encode()).hexdigest()[:8], 16)
    x = 8 + (seed % 8400) / 100.0
    y = 12 + ((seed // 97) % 6800) / 100.0
    now = _v249345_now()
    _conn.execute("INSERT INTO lighthouse_lights_v249345(light_id,room_id,night_id,owner_hash,x,y,created_at) VALUES(?,?,?,?,?,?,?)", (light_id, room_id, night, owner, x, y, now))
    _conn.commit()
    await _v249345_broadcast({"type": "lighthouse_light", "room_id": room_id, "light_id": light_id, "night_id": night})
    return {"ok": True, "light_id": light_id, "night_id": night, "x": x, "y": y, "created_at": now}

@app.post("/api/lighthouse/touch_v249345")
async def api_lighthouse_touch_v249345(req: Request):
    _v249345_db_init()
    body = await req.json()
    room_id = str(body.get("room_id") or "ithaki-phone-test")[:160]
    light_id = str(body.get("light_id") or "")[:140]
    tid = "touch_" + secrets.token_urlsafe(12)
    _conn.execute("INSERT OR IGNORE INTO lighthouse_touches_v249345(touch_id,light_id,room_id,night_id,owner_hash,created_at) VALUES(?,?,?,?,?,?)", (tid, light_id, room_id, _v249345_night_id(), _v249345_hash_token(str(body.get("owner_token") or body.get("device_id") or "")), _v249345_now()))
    _conn.commit()
    await _v249345_broadcast({"type": "lighthouse_touch", "room_id": room_id, "light_id": light_id})
    return {"ok": True, "touch_id": tid}

@app.get("/clean_rebuild_v249345.js")
def frontend_clean_rebuild_v249345_js():
    p = FRONTEND / "clean_rebuild_v249345.js"
    if p.exists():
        return Response(p.read_text(encoding="utf-8"), media_type="application/javascript; charset=utf-8", headers={"Cache-Control": "no-store"})
    raise HTTPException(status_code=404, detail="clean_rebuild_v249345.js missing")
# ===== END OK ITHAKI V249345 WALL + LIGHTHOUSE BACKEN


# ===== OK ITHAKI V249346 FRONTEND DIAGNOSTIC ASSET =====
@app.get("/clean_rebuild_v249346.js")
def frontend_clean_rebuild_v249346_js():
    p = FRONTEND / "clean_rebuild_v249346.js"
    if p.exists():
        return Response(p.read_text(encoding="utf-8"), media_type="application/javascript; charset=utf-8", headers={"Cache-Control": "no-store"})
    raise HTTPException(status_code=404, detail="clean_rebuild_v249346.js missing")
# ===== END OK ITHAKI V249346 FRONTEND DIAGNOSTIC ASSET =====


# ===== OK ITHAKI V249359 REAL FIX: WALL MIGRATION + FULL FEED SEARCH + SHARED POLL VOTES =====

# ---------- 1) Τοίχος: bring back posts from the older V249342/V249344 table generations ----------
_V249359_WALL_MIGRATED = False

def _v249359_migrate_old_wall_posts():
    """One-time, idempotent migration of wall posts from the older V249342 and
    V249344 generations into the currently-served wall_posts_v249345 table.
    Every previous wall rewrite created a brand-new table instead of migrating
    the old one, so anything posted before V249345 became invisible in the app
    even though it was still sitting in the database."""
    global _V249359_WALL_MIGRATED
    if _V249359_WALL_MIGRATED:
        return
    try:
        _v249345_db_init()
        try:
            ensure_wall_tables_v249342()
            old342 = _conn.execute("SELECT post_id, room_id, author_hash, text, created_at FROM anonymous_wall_posts_v249342").fetchall()
            for r in old342:
                _conn.execute(
                    "INSERT OR IGNORE INTO wall_posts_v249345(post_id,room_id,owner_hash,text,created_at,hidden) VALUES(?,?,?,?,?,0)",
                    (r["post_id"], r["room_id"], r["author_hash"], r["text"], int(r["created_at"] or 0)),
                )
        except Exception:
            pass
        try:
            _v249344_db_init()
            old344 = _conn.execute("SELECT post_id, room_id, owner_hash, text, created_at, hidden FROM wall_posts_v249344").fetchall()
            for r in old344:
                _conn.execute(
                    "INSERT OR IGNORE INTO wall_posts_v249345(post_id,room_id,owner_hash,text,created_at,hidden) VALUES(?,?,?,?,?,?)",
                    (r["post_id"], r["room_id"], r["owner_hash"], r["text"], int(r["created_at"] or 0), int(r["hidden"] or 0)),
                )
                try:
                    old_reacts = _conn.execute("SELECT post_id, owner_hash, kind, created_at FROM wall_reactions_v249344 WHERE post_id=?", (r["post_id"],)).fetchall()
                    for rr in old_reacts:
                        _conn.execute(
                            "INSERT OR IGNORE INTO wall_reactions_v249345(post_id,owner_hash,kind,created_at) VALUES(?,?,?,?)",
                            (rr["post_id"], rr["owner_hash"], rr["kind"], int(rr["created_at"] or 0)),
                        )
                except Exception:
                    pass
        except Exception:
            pass
        _conn.commit()
        _V249359_WALL_MIGRATED = True
    except Exception:
        pass


# ---------- 2) Ροή: real server-side search across ALL posts, not just the ones already loaded ----------
@app.get("/api/flow/search_v249359")
def api_flow_search_v249359(
    room_id: str = Query(...),
    q: str = Query(..., min_length=1, max_length=200),
    visibility: str = Query("public"),
    limit: int = Query(60, ge=1, le=200),
    offset: int = Query(0, ge=0),
    viewer_id: str = Query(""),
):
    viewer_id = str(viewer_id or "")[:120]
    needle = "%" + q.strip().replace("%", "").replace("_", "") + "%"
    where = ["room_id=?", "COALESCE(is_encrypted,0)=0", "text LIKE ? ESCAPE '\\'"]
    params: list = [room_id, needle]
    if visibility != "all":
        where.append("visibility=?")
        params.append(visibility)
    where_sql = " AND ".join(where)
    total = int((one("SELECT COUNT(*) AS c FROM flow_posts WHERE " + where_sql, tuple(params)) or {"c": 0})["c"] or 0)
    sql = "SELECT * FROM flow_posts WHERE " + where_sql + " ORDER BY created_at DESC LIMIT ? OFFSET ?"
    result = []
    for r in rows(sql, tuple(params + [limit, offset])):
        d = dict(r)
        if viewer_id and d.get("sender_id") and str(d.get("sender_id") or "") != viewer_id and _is_user_blocked_between(room_id, viewer_id, str(d.get("sender_id"))):
            continue
        d["is_encrypted"] = bool(d["is_encrypted"])
        d["anonymous"] = bool(d["anonymous"])
        d["message_id"] = d["post_id"]
        try:
            pr = one("SELECT avatar FROM people WHERE room_id=? AND device_id=? LIMIT 1", (room_id, str(d.get("sender_id") or "")))
            d["avatar"] = str((pr["avatar"] if pr else "") or "")
            d["photo"] = d["avatar"]
            d["profile_photo"] = d["avatar"]
        except Exception:
            d["avatar"] = str(d.get("avatar") or "")
        result.append(d)
    try:
        post_ids = [str(d.get("post_id") or "") for d in result if d.get("post_id")]
        if post_ids:
            marks = ",".join(["?"] * len(post_ids))
            like_counts = {}
            for rr in rows("SELECT target_id, COUNT(*) AS n FROM reactions WHERE emoji='❤️' AND target_id IN (" + marks + ") GROUP BY target_id", tuple(post_ids)):
                like_counts[str(rr["target_id"])] = int(rr["n"] or 0)
            comment_counts = {}
            channels = ["comment:" + pid for pid in post_ids]
            marks2 = ",".join(["?"] * len(channels))
            for rr in rows("SELECT channel, COUNT(*) AS total FROM messages WHERE room_id=? AND channel IN (" + marks2 + ") GROUP BY channel", tuple([room_id] + channels)):
                comment_counts[str(rr["channel"])] = int(rr["total"] or 0)
            for d in result:
                pid = str(d.get("post_id") or "")
                d["like_count"] = int(like_counts.get(pid, 0))
                d["likes"] = d["like_count"]
                d["comment_count"] = int(comment_counts.get("comment:" + pid, 0))
    except Exception:
        pass
    return {"ok": True, "room_id": room_id, "query": q, "total": total, "count": len(result), "limit": limit, "offset": offset, "posts": result}


# ---------- 3) Ψηφοφορίες: shared poll per stable key, so votes from different members land in ONE poll ----------
def _v249359_polls_ensure_key_column():
    try:
        cols = [r["name"] for r in _conn.execute("PRAGMA table_info(polls)").fetchall()]
        if "external_key" not in cols:
            _conn.execute("ALTER TABLE polls ADD COLUMN external_key TEXT NOT NULL DEFAULT ''")
        _conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_polls_room_external_key ON polls(room_id, external_key) WHERE external_key != ''")
        _conn.commit()
    except Exception:
        pass


@app.post("/api/polls/get_or_create_v249359")
def api_polls_get_or_create_v249359(payload: dict):
    """Same job as /api/polls/create, except it is idempotent per (room_id, external_key).
    The gold poll cards in the Ροή use a stable, deterministic key (e.g. the daily poll
    id) instead of caching a poll_id only in each device's own localStorage. Before this,
    every member who happened to be the *first* voter on their own phone created their own
    separate poll, so votes from different members never landed in the same tally."""
    _v249359_polls_ensure_key_column()
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    external_key = str(payload.get("external_key") or "")[:160]
    creator_id = str(payload.get("creator_id") or payload.get("sender_id") or "")[:120]
    if is_banned(room_id, creator_id):
        raise HTTPException(403, "member blocked")
    if not external_key:
        raise HTTPException(400, "external_key required")
    existing = one("SELECT poll_id FROM polls WHERE room_id=? AND external_key=?", (room_id, external_key))
    if existing:
        return {"ok": True, "poll_id": existing["poll_id"], "created": False, "stats": _poll_stats(existing["poll_id"])}
    question = str(payload.get("question") or "").strip()[:1000]
    options = _safe_poll_options(payload.get("options") or [])
    if not question:
        raise HTTPException(400, "question required")
    if len(options) < 2:
        raise HTTPException(400, "at least 2 options required")
    community_id = str(payload.get("community_id") or payload.get("community") or "")[:160]
    nickname = str(payload.get("nickname") or "")[:80]
    anonymous = 1 if bool(payload.get("anonymous", True)) else 0
    allow_stats = 1 if bool(payload.get("allow_stats", True)) else 0
    poll_id = nid("poll")
    try:
        execute("""
        INSERT INTO polls(poll_id,room_id,community_id,creator_id,nickname,question,options_json,anonymous,allow_stats,created_at,closed,external_key)
        VALUES(?,?,?,?,?,?,?,?,?,?,0,?)
        """, (poll_id, room_id, community_id, creator_id, nickname, question, json.dumps(options, ensure_ascii=False), anonymous, allow_stats, now(), external_key))
    except Exception:
        # another device won the race and inserted first — fetch its poll_id instead of erroring.
        again = one("SELECT poll_id FROM polls WHERE room_id=? AND external_key=?", (room_id, external_key))
        if again:
            return {"ok": True, "poll_id": again["poll_id"], "created": False, "stats": _poll_stats(again["poll_id"])}
        raise
    publish_feed = bool(payload.get("publish_feed", payload.get("post_to_feed", False)))
    if publish_feed:
        feed_payload = {
            "kind": "poll", "pollId": poll_id, "title": "🗳️ Ψηφοφορία", "question": question,
            "body": question, "community": community_id, "targetCommunity": community_id,
            "options": options, "allowStats": bool(allow_stats)
        }
        execute("""
        INSERT INTO flow_posts(post_id,room_id,sender_id,nickname,language,text,is_encrypted,iv,cipher_text,post_kind,anonymous,visibility,created_at)
        VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
        """, (nid("flow"), room_id, creator_id, nickname if not anonymous else "Ανώνυμη ψηφοφορία", "el", json.dumps(feed_payload, ensure_ascii=False), 0, "", "", "poll", anonymous, "public", now()))
    return {"ok": True, "poll_id": poll_id, "created": True, "stats": _poll_stats(poll_id)}


@app.get("/clean_rebuild_v249359.js")
def frontend_clean_rebuild_v249359_js():
    p = FRONTEND / "clean_rebuild_v249359.js"
    if p.exists():
        return Response(p.read_text(encoding="utf-8"), media_type="application/javascript; charset=utf-8", headers={"Cache-Control": "no-store"})
    raise HTTPException(status_code=404, detail="clean_rebuild_v249359.js missing")
# ===== END OK ITHAKI V249359 REAL FIX =====


# ===== OK ITHAKI V249360 CRITICAL FIX: boot() crash halted all code after it =====
# root cause: "boot().catch(...)" assumed boot() always returns a real Promise. In
# production this call threw "boot(...).catch(...) is not a function" as an UNCAUGHT
# top-level error, which stops the rest of that <script> from executing at all — every
# patch physically located after that line in index.js (the real Wall wiring, the final
# Lexicon routing, and the V249359 real-fix layer) silently never ran. Fixed by wrapping
# the call in Promise.resolve(...), which always yields a real Promise regardless of what
# boot() returns, so .catch() can never crash again.
@app.get("/api/frontend/build_v249360")
def frontend_build_v249360():
    js = FRONTEND / "index.js"
    js_txt = js.read_text(encoding="utf-8", errors="replace") if js.exists() else ""
    return JSONResponse({
        "ok": True,
        "version": VERSION,
        "boot_crash_fix_present": "Promise.resolve(boot())" in js_txt,
        "old_broken_call_still_present": ("\nboot().catch(" in js_txt),
    }, headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"})
# ===== END OK ITHAKI V249360 CRITICAL FIX =====


# ===== OK ITHAKI V249361 CLEAN FEED REBUILD =====
@app.get("/api/frontend/build_v249361")
def frontend_build_v249361():
    js = FRONTEND / "index.js"
    js_txt = js.read_text(encoding="utf-8", errors="replace") if js.exists() else ""
    return JSONResponse({
        "ok": True,
        "version": VERSION,
        "feed_rebuild_present": "__ITHAKI_V249361_FEED__" in js_txt,
        "boot_crash_fix_present": "Promise.resolve(boot())" in js_txt,
    }, headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"})
# ===== END OK ITHAKI V249361 CLEAN FEED REBUILD =====


# ===== OK ITHAKI V249362: serve the Ροή rebuild as its OWN independent <script> tag =====
# root cause finally understood: index.js has an uncaught top-level error somewhere in
# its middle (still unidentified exactly where — line-number reports from the browser
# don't match a plain text search of the file for reasons we could not pin down). An
# uncaught error in a top-level statement of a classic <script> halts ALL LATER top-level
# statements in that SAME script from ever running — which is why appending the Ροή
# rebuild to the end of index.js meant it never executed, no matter how many times it
# was fixed. The robust solution: stop appending it inside index.js. Serve it as its own,
# separate <script src="clean_rebuild_v249361.js"> tag in index.html instead. Each
# <script> element is its own independent top-level execution context — a crash inside
# index.js cannot prevent a separate <script> tag from running.
@app.get("/clean_rebuild_v249361.js")
def frontend_clean_rebuild_v249361_js():
    p = FRONTEND / "clean_rebuild_v249361.js"
    if p.exists():
        return Response(p.read_text(encoding="utf-8"), media_type="application/javascript; charset=utf-8", headers={"Cache-Control": "no-store"})
    raise HTTPException(status_code=404, detail="clean_rebuild_v249361.js missing")


@app.get("/api/frontend/build_v249362")
def frontend_build_v249362():
    html = FRONTEND / "index.html"
    html_txt = html.read_text(encoding="utf-8", errors="replace") if html.exists() else ""
    return JSONResponse({
        "ok": True,
        "version": VERSION,
        "standalone_feed_script_tag_present": 'src="clean_rebuild_v249361.js' in html_txt,
    }, headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"})
# ===== END OK ITHAKI V249362 =====




# V249401_CREATOR_ACCOUNT_SERVER_FOUNDATION (V249363 SECURITY HARDENED)
# Fixes applied vs the original V249401 foundation, found during audit:
#  1) save_v249401 used to let ANYONE who knew a device_id overwrite that
#     account's creator_code / phone / email with no proof of ownership at
#     all -> full account takeover. Now: once a creator_code already exists,
#     changing ANY field requires the CURRENT code to verify first.
#  2) get_v249401 used to return phone/email to any caller who knew the
#     device_id, directly contradicting the endpoint's own
#     "phone_email_public: False" claim. Now: the public endpoint never
#     returns phone/email; a new, code-gated endpoint returns them only to
#     someone who proves they know the current creator_code.
#  3) verify_v249401 had no attempt limiting at all (only PBKDF2 cost stood
#     between an attacker and unlimited guesses). Now: a simple in-DB
#     attempt counter locks further attempts out for 15 minutes after 5
#     wrong codes for the same room+device.
from pydantic import BaseModel as _CreatorBaseModelV249401, Field as _CreatorFieldV249401
import hashlib as _creator_hashlib_v249401
import hmac as _creator_hmac_v249401
import re as _creator_re_v249401
import secrets as _creator_secrets_v249401
import time as _creator_time_v249401

_CREATOR_MAX_ATTEMPTS_V249363 = 5
_CREATOR_LOCKOUT_SECONDS_V249363 = 15 * 60

def _creator_table_v249401():
    execute("""
    CREATE TABLE IF NOT EXISTS creator_accounts_v249401(
        room_id TEXT NOT NULL, device_id TEXT NOT NULL, account_type TEXT NOT NULL DEFAULT 'user',
        code_salt TEXT NOT NULL DEFAULT '', code_hash TEXT NOT NULL DEFAULT '',
        phone TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '',
        failed_attempts INTEGER NOT NULL DEFAULT 0, locked_until REAL NOT NULL DEFAULT 0,
        created_at REAL NOT NULL DEFAULT 0, updated_at REAL NOT NULL DEFAULT 0,
        PRIMARY KEY(room_id,device_id)
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_creator_accounts_type_v249401 ON creator_accounts_v249401(room_id,account_type,updated_at DESC)")
    # additive columns for installs that already had the old V249401 table
    try:
        cols = [r["name"] for r in rows("PRAGMA table_info(creator_accounts_v249401)")]
        if "failed_attempts" not in cols:
            execute("ALTER TABLE creator_accounts_v249401 ADD COLUMN failed_attempts INTEGER NOT NULL DEFAULT 0")
        if "locked_until" not in cols:
            execute("ALTER TABLE creator_accounts_v249401 ADD COLUMN locked_until REAL NOT NULL DEFAULT 0")
    except Exception:
        pass

def _creator_hash_v249401(code: str, salt_hex: str) -> str:
    return _creator_hashlib_v249401.pbkdf2_hmac("sha256",code.encode("utf-8"),bytes.fromhex(salt_hex),240000).hex()

def _creator_row_v249401(room_id: str, device_id: str):
    _creator_table_v249401()
    return one("SELECT * FROM creator_accounts_v249401 WHERE room_id=? AND device_id=?",(room_id,device_id))

def _creator_public_v249401(row):
    # SECURITY: never include phone/email here — this is the response shape
    # returned to ANY caller who merely knows a device_id, not just the owner.
    if not row: return {"account_type":"user","has_creator_code":False}
    return {"account_type":"creator" if str(row["account_type"])=="creator" else "user","has_creator_code":bool(str(row["code_hash"] or "")),"updated_at":float(row["updated_at"] or 0)}

def _creator_is_locked_v249401(row) -> bool:
    if not row: return False
    return float(row["locked_until"] or 0) > now()

def _creator_register_failure_v249401(room_id: str, device_id: str):
    row = _creator_row_v249401(room_id, device_id)
    attempts = int((row["failed_attempts"] if row else 0) or 0) + 1
    locked_until = now() + _CREATOR_LOCKOUT_SECONDS_V249363 if attempts >= _CREATOR_MAX_ATTEMPTS_V249363 else 0
    if row:
        execute("UPDATE creator_accounts_v249401 SET failed_attempts=?,locked_until=? WHERE room_id=? AND device_id=?",(attempts, locked_until, room_id, device_id))

def _creator_clear_failures_v249401(room_id: str, device_id: str):
    execute("UPDATE creator_accounts_v249401 SET failed_attempts=0,locked_until=0 WHERE room_id=? AND device_id=?",(room_id, device_id))

def _creator_verify_code_v249401(room_id: str, device_id: str, code: str) -> dict:
    """Returns {'ok':bool,'reason':str|None}. Enforces the lockout and updates counters."""
    row = _creator_row_v249401(room_id, device_id)
    if not row or str(row["account_type"]) != "creator" or not str(row["code_hash"] or ""):
        return {"ok": False, "reason": "no_creator_code_set"}
    if _creator_is_locked_v249401(row):
        return {"ok": False, "reason": "locked_temporarily"}
    got = _creator_hash_v249401(str(code or ""), str(row["code_salt"]))
    if _creator_hmac_v249401.compare_digest(got, str(row["code_hash"])):
        _creator_clear_failures_v249401(room_id, device_id)
        return {"ok": True, "reason": None}
    _creator_register_failure_v249401(room_id, device_id)
    return {"ok": False, "reason": "wrong_code"}

class _CreatorSaveV249401(_CreatorBaseModelV249401):
    room_id: str = _CreatorFieldV249401(default="ithaki-phone-test",min_length=1,max_length=120)
    device_id: str = _CreatorFieldV249401(min_length=3,max_length=180)
    account_type: str = _CreatorFieldV249401(default="user",max_length=24)
    creator_code: str = _CreatorFieldV249401(default="",max_length=64)
    current_code: str = _CreatorFieldV249401(default="",max_length=64)

class _CreatorVerifyV249401(_CreatorBaseModelV249401):
    room_id: str = _CreatorFieldV249401(default="ithaki-phone-test",min_length=1,max_length=120)
    device_id: str = _CreatorFieldV249401(min_length=3,max_length=180)
    creator_code: str = _CreatorFieldV249401(min_length=6,max_length=64)

@app.get("/api/creator/account/get_v249401")
def creator_account_get_v249401(room_id: str = Query("ithaki-phone-test"), device_id: str = Query(...)):
    room_id=str(room_id or "ithaki-phone-test").strip()[:120];device_id=str(device_id or "").strip()[:180]
    if len(device_id)<3: raise HTTPException(400,"device_id_required")
    return {"ok":True,**_creator_public_v249401(_creator_row_v249401(room_id,device_id))}

@app.post("/api/creator/account/save_v249401")
def creator_account_save_v249401(body: _CreatorSaveV249401):
    # V249365: phone/email removed entirely from the professional account —
    # no contact fields are collected, stored, or requested anywhere anymore.
    room_id=str(body.room_id or "ithaki-phone-test").strip()[:120];device_id=str(body.device_id or "").strip()[:180]
    typ="creator" if str(body.account_type).lower()=="creator" else "user";code=str(body.creator_code or "")
    if len(device_id)<3: raise HTTPException(400,"device_id_required")
    old=_creator_row_v249401(room_id,device_id)
    had_code = bool(old and str(old["code_hash"] or ""))
    # SECURITY (V249363): once a creator_code already exists for this account,
    # changing it must prove the CURRENT code first. This is the fix for the
    # account-takeover bug: previously nothing stopped someone who merely knew
    # the device_id from overwriting the code.
    type_is_changing = bool(old and str(old["account_type"] or "user") != typ)
    if had_code and (code or type_is_changing):
        check = _creator_verify_code_v249401(room_id, device_id, str(body.current_code or ""))
        if not check["ok"]:
            raise HTTPException(423 if check["reason"]=="locked_temporarily" else 401, "current_code_required_" + str(check["reason"]))
    if typ=="creator" and not had_code and len(code)<6:
        raise HTTPException(400,"creator_code_min_6")
    created=float(old["created_at"]) if old else now();salt="";hashed=""
    if typ=="creator" and code:
        salt=_creator_secrets_v249401.token_hex(16);hashed=_creator_hash_v249401(code,salt)
    elif old:
        salt=str(old["code_salt"] or "");hashed=str(old["code_hash"] or "")
    execute("""INSERT INTO creator_accounts_v249401(room_id,device_id,account_type,code_salt,code_hash,phone,email,failed_attempts,locked_until,created_at,updated_at) VALUES(?,?,?,?,?,'','',0,0,?,?) ON CONFLICT(room_id,device_id) DO UPDATE SET account_type=excluded.account_type,code_salt=excluded.code_salt,code_hash=excluded.code_hash,phone='',email='',failed_attempts=0,locked_until=0,updated_at=excluded.updated_at""",(room_id,device_id,typ,salt,hashed,created,now()))
    LOG.info("V249401 creator account saved room=%s device=%s type=%s",room_id,device_id[:24],typ)
    return {"ok":True,**_creator_public_v249401(_creator_row_v249401(room_id,device_id))}

@app.post("/api/creator/account/verify_v249401")
def creator_account_verify_v249401(body: _CreatorVerifyV249401):
    room_id=str(body.room_id).strip()[:120];device_id=str(body.device_id).strip()[:180]
    check = _creator_verify_code_v249401(room_id, device_id, str(body.creator_code))
    if check["reason"] == "locked_temporarily":
        raise HTTPException(423, "locked_temporarily")
    return {"ok":True,"verified":check["ok"]}

@app.get("/api/creator/account/diagnostics_v249401")
def creator_account_diagnostics_v249401():
    _creator_table_v249401();total=one("SELECT COUNT(*) n FROM creator_accounts_v249401");creators=one("SELECT COUNT(*) n FROM creator_accounts_v249401 WHERE account_type='creator'")
    return {"ok":True,"version":"V249401-creator-account-foundation-v249365-no-contact-fields","total_accounts":int(total["n"] if total else 0),"creator_accounts":int(creators["n"] if creators else 0),"password_storage":"pbkdf2_sha256_240000_salted","phone_email_collected":False,"current_code_required_for_changes":True,"attempt_lockout":{"max_attempts":_CREATOR_MAX_ATTEMPTS_V249363,"lockout_seconds":_CREATOR_LOCKOUT_SECONDS_V249363}}
# V249405_TO_V249410_CREATOR_ANALYTICS_SERVER
# Additive only: V249401 account foundation remains unchanged.

from pydantic import BaseModel as _CreatorBaseModelV249410, Field as _CreatorFieldV249410
from fastapi.responses import HTMLResponse as _CreatorHTMLResponseV249410
import datetime as _creator_datetime_v249410
import hashlib as _creator_hashlib_v249410
import json as _creator_json_v249410
import os as _creator_os_v249410
import threading as _creator_threading_v249410
import time as _creator_time_v249410
import urllib.request as _creator_urllib_request_v249410
import urllib.error as _creator_urllib_error_v249410

def _creator_clean_v249410(value, limit=240):
    return str(value or "").strip()[:limit]


def _creator_rows_v249410(sql, args=()):
    fn = globals().get("all")
    if callable(fn):
        try:
            return fn(sql, args) or []
        except Exception:
            pass
    try:
        cur = execute(sql, args)
        return cur.fetchall() if cur is not None and hasattr(cur, "fetchall") else []
    except Exception:
        return []


def _creator_one_v249410(sql, args=()):
    try:
        return one(sql, args)
    except Exception:
        rows = _creator_rows_v249410(sql, args)
        return rows[0] if rows else None


def _creator_value_v249410(row, key, default=None):
    if row is None:
        return default
    try:
        value = row[key]
    except Exception:
        try:
            value = getattr(row, key)
        except Exception:
            return default
    return default if value is None else value


def _creator_table_columns_v249410(table_name):
    safe = "".join(ch for ch in str(table_name) if ch.isalnum() or ch == "_")
    if not safe:
        return set()
    rows = _creator_rows_v249410("PRAGMA table_info(" + safe + ")")
    result = set()
    for row in rows:
        name = _creator_value_v249410(row, "name", "")
        if name:
            result.add(str(name))
    return result


def _creator_post_info_v249410(room_id, post_id):
    room_id = _creator_clean_v249410(room_id, 120) or "ithaki-phone-test"
    post_id = _creator_clean_v249410(post_id, 220)
    if not post_id:
        return {"creator_id": "", "caption": "", "created_at": 0.0}

    for table in ("flow_posts", "posts", "flow"):
        cols = _creator_table_columns_v249410(table)
        if not cols:
            continue
        id_col = next((x for x in ("post_id", "message_id", "id") if x in cols), None)
        owner_col = next((x for x in ("sender_id", "creator_id", "device_id", "author_id") if x in cols), None)
        if not id_col or not owner_col:
            continue
        room_col = "room_id" if "room_id" in cols else None
        text_col = next((x for x in ("text", "body", "caption", "content") if x in cols), None)
        time_col = next((x for x in ("created_at", "timestamp", "ts") if x in cols), None)

        select = [owner_col + " AS creator_id"]
        select.append((text_col + " AS caption") if text_col else "'' AS caption")
        select.append((time_col + " AS created_at") if time_col else "0 AS created_at")

        sql = "SELECT " + ",".join(select) + " FROM " + table + " WHERE " + id_col + "=?"
        args = [post_id]
        if room_col:
            sql += " AND " + room_col + "=?"
            args.append(room_id)
        sql += " LIMIT 1"
        row = _creator_one_v249410(sql, tuple(args))
        if row:
            return {
                "creator_id": _creator_clean_v249410(_creator_value_v249410(row, "creator_id", ""), 180),
                "caption": _creator_clean_v249410(_creator_value_v249410(row, "caption", ""), 500),
                "created_at": float(_creator_value_v249410(row, "created_at", 0) or 0),
            }

    return {"creator_id": "", "caption": "", "created_at": 0.0}


def _creator_require_account_v249410(room_id, device_id):
    row = _creator_row_v249401(
        _creator_clean_v249410(room_id, 120) or "ithaki-phone-test",
        _creator_clean_v249410(device_id, 180),
    )
    return bool(row and str(_creator_value_v249410(row, "account_type", "")) == "creator")




# ===== V249405_REAL_VIDEO_VIEW_FOUNDATION_SERVER (V249365 build, written to match the
# contracts expected by the already-audited V406D/V407/V408/V409/V410 creator-analytics
# fragments — those fragments read via globals().get(...) so they degrade gracefully,
# but this gives them the real, validated data they were designed for). =====
import hmac as _v405_hmac
import hashlib as _v405_hashlib
import secrets as _v405_secrets
import re as _v405_re

def _creator_views_secret_v249405():
    """A stable, server-only secret used to derive one-way viewer pseudonyms.
    Generated once and kept in the database — never sent to any client."""
    row = one("SELECT value FROM app_settings_v249405 WHERE key='views_secret' LIMIT 1")
    if row and row["value"]:
        return str(row["value"]).encode("utf-8")
    execute("CREATE TABLE IF NOT EXISTS app_settings_v249405(key TEXT PRIMARY KEY, value TEXT NOT NULL)")
    row = one("SELECT value FROM app_settings_v249405 WHERE key='views_secret' LIMIT 1")
    if row and row["value"]:
        return str(row["value"]).encode("utf-8")
    secret = _v405_secrets.token_hex(32)
    execute("INSERT OR IGNORE INTO app_settings_v249405(key,value) VALUES('views_secret',?)", (secret,))
    row = one("SELECT value FROM app_settings_v249405 WHERE key='views_secret' LIMIT 1")
    return str(row["value"] if row else secret).encode("utf-8")

def _creator_viewer_hash_v249405(room_id: str, device_id: str) -> str:
    secret = _creator_views_secret_v249405()
    msg = (str(room_id) + "\x00" + str(device_id)).encode("utf-8")
    return _v405_hmac.new(secret, msg, _v405_hashlib.sha256).hexdigest()

def _creator_active_v249405(room_id: str, device_id: str) -> bool:
    try:
        row = one("SELECT account_type FROM creator_accounts_v249401 WHERE room_id=? AND device_id=? LIMIT 1", (room_id, device_id))
        if row and str(row["account_type"] or "") == "creator":
            return True
    except Exception:
        pass
    try:
        import sqlite3 as _v405_sqlite3
        from pathlib import Path as _v405_Path
        account_db = _v405_Path(ROOT) / "data" / "account_security_v249382.sqlite3"
        if not account_db.exists():
            return False
        con = _v405_sqlite3.connect(str(account_db), timeout=5)
        try:
            row = con.execute(
                "SELECT professional_enabled FROM accounts_v249382 WHERE room_id=? AND device_id=? LIMIT 1",
                (str(room_id or "ithaki-phone-test")[:120], str(device_id or "")[:180]),
            ).fetchone()
            return bool(row and int(row[0] or 0) == 1)
        finally:
            con.close()
    except Exception:
        return False

_V405_VIDEO_HINT_RE = _v405_re.compile(r"\.(mp4|webm|mov|m4v|3gp)(?:\?|#|$)", _v405_re.I)

def _creator_video_info_v249403(text, post_kind):
    token = (str(post_kind or "") + " " + str(text or "")).lower()
    is_video = bool("video" in token or _V405_VIDEO_HINT_RE.search(token) or "data:video/" in token)
    caption = str(text or "").strip()
    try:
        import json as _v405_json
        if caption.startswith("{"):
            obj = _v405_json.loads(caption)
            if isinstance(obj, dict):
                caption = str(obj.get("caption") or obj.get("body") or obj.get("text") or "Βίντεο").strip()
    except Exception:
        pass
    return is_video, (caption[:260] or "Βίντεο")

def _creator_video_post_v249405(room_id: str, post_id: str):
    row = one("SELECT post_id,room_id,sender_id,nickname,text,post_kind,created_at FROM flow_posts WHERE room_id=? AND post_id=? LIMIT 1", (room_id, post_id))
    if not row:
        return None
    data = dict(row)
    is_video, caption = _creator_video_info_v249403(data.get("text"), data.get("post_kind"))
    data["is_video"] = is_video
    data["caption"] = caption
    return data

def _creator_views_table_v249405():
    execute("""
    CREATE TABLE IF NOT EXISTS creator_video_sessions_v249405(
        session_id TEXT PRIMARY KEY,
        room_id TEXT NOT NULL,
        post_id TEXT NOT NULL,
        creator_id TEXT NOT NULL,
        viewer_hash TEXT NOT NULL,
        is_owner INTEGER NOT NULL DEFAULT 0,
        valid_view INTEGER NOT NULL DEFAULT 0,
        play_count INTEGER NOT NULL DEFAULT 1,
        replay_count INTEGER NOT NULL DEFAULT 0,
        completion_count INTEGER NOT NULL DEFAULT 0,
        pause_count INTEGER NOT NULL DEFAULT 0,
        resume_count INTEGER NOT NULL DEFAULT 0,
        milestone_mask INTEGER NOT NULL DEFAULT 0,
        active_watch_ms INTEGER NOT NULL DEFAULT 0,
        duration_ms INTEGER NOT NULL DEFAULT 0,
        max_position_ms INTEGER NOT NULL DEFAULT 0,
        started_at REAL NOT NULL,
        updated_at REAL NOT NULL
    )""")
    execute("CREATE INDEX IF NOT EXISTS idx_v405_sessions_post ON creator_video_sessions_v249405(room_id,creator_id,post_id,valid_view)")
    execute("CREATE INDEX IF NOT EXISTS idx_v405_sessions_viewer ON creator_video_sessions_v249405(room_id,viewer_hash,started_at)")


class _CreatorViewSessionV249405(_CreatorBaseModelV249410):
    room_id: str = _CreatorFieldV249410(default="ithaki-phone-test", max_length=120)
    device_id: str = _CreatorFieldV249410(min_length=3, max_length=180)
    post_id: str = _CreatorFieldV249410(min_length=1, max_length=220)
    session_id: str = _CreatorFieldV249410(min_length=8, max_length=150)
    play_count: int = 1
    replay_count: int = 0
    completion_count: int = 0
    pause_count: int = 0
    resume_count: int = 0
    active_watch_ms: int = 0
    duration_ms: int = 0
    max_position_ms: int = 0
    visible_ratio: float = 1.0


@app.post("/api/creator/view/session_v249405")
def creator_view_session_v249405(body: _CreatorViewSessionV249405):
    """Records (upserts) one continuous viewing session. Deliberately does NOT
    trust the client's own opinion of what counts as a 'real' view — validity is
    recomputed here from the raw numbers, and impossible/oversized numbers are
    clamped rather than trusted verbatim. A client can still exaggerate to some
    degree (this is a general limitation of any client-reported metric, not
    unique to this app), but it can no longer fabricate views out of thin air
    for a post that doesn't exist, spam without limit, or inflate its own
    creator's numbers by watching its own content."""
    _creator_views_table_v249405()
    room_id = str(body.room_id or "ithaki-phone-test").strip()[:120]
    device_id = str(body.device_id or "").strip()[:180]
    post_id = str(body.post_id or "").strip()[:220]
    session_id = str(body.session_id or "").strip()[:150]

    if not _v405_re.fullmatch(r"[A-Za-z0-9_-]{8,150}", session_id):
        raise HTTPException(400, "invalid_session_id")
    if len(device_id) < 3:
        raise HTTPException(400, "device_id_required")

    try:
        if is_banned(room_id, device_id):
            raise HTTPException(403, "device_restricted")
    except HTTPException:
        raise
    except Exception:
        pass

    post = _creator_video_post_v249405(room_id, post_id)
    if not post:
        raise HTTPException(404, "post_not_found")
    if not bool(post.get("is_video")):
        raise HTTPException(400, "post_is_not_video")
    creator_id = str(post.get("sender_id") or "")
    if not creator_id:
        raise HTTPException(400, "post_creator_missing")

    # rate limit: at most 30 heartbeat writes per viewer per 60 seconds, across all sessions
    recent = one(
        "SELECT COUNT(*) AS n FROM creator_video_sessions_v249405 WHERE room_id=? AND viewer_hash=? AND updated_at>=?",
        (room_id, _creator_viewer_hash_v249405(room_id, device_id), now() - 60),
    )
    existing = one("SELECT session_id FROM creator_video_sessions_v249405 WHERE session_id=?", (session_id,))
    if not existing and int((recent["n"] if recent else 0) or 0) >= 30:
        raise HTTPException(429, "too_many_view_sessions")

    viewer_hash = _creator_viewer_hash_v249405(room_id, device_id)
    is_owner = 1 if device_id == creator_id else 0

    duration_ms = max(0, min(int(body.duration_ms or 0), 6 * 60 * 60 * 1000))
    active_watch_ms = max(0, min(int(body.active_watch_ms or 0), 6 * 60 * 60 * 1000))
    max_position_ms = max(0, min(int(body.max_position_ms or 0), duration_ms or (6 * 60 * 60 * 1000)))
    play_count = max(1, min(int(body.play_count or 1), 500))
    replay_count = max(0, min(int(body.replay_count or 0), 500))
    completion_count = max(0, min(int(body.completion_count or 0), 50))
    pause_count = max(0, min(int(body.pause_count or 0), 500))
    resume_count = max(0, min(int(body.resume_count or 0), 500))
    visible_ratio = max(0.0, min(float(body.visible_ratio or 0.0), 1.0))

    milestone_mask = 0
    if duration_ms > 0:
        if max_position_ms >= int(duration_ms * 0.25): milestone_mask |= 1
        if max_position_ms >= int(duration_ms * 0.50): milestone_mask |= 2
        if max_position_ms >= int(duration_ms * 0.75): milestone_mask |= 4
        if max_position_ms >= int(duration_ms * 0.95): milestone_mask |= 8

    # transparent validity rule (matches the wording already shown in the V406D UI):
    # a real view needs at least 2 active seconds of watch time, OR a completion,
    # and the video must have been at least 65% visible on screen while watching.
    valid_view = 1 if (visible_ratio >= 0.65 and (active_watch_ms >= 2000 or completion_count > 0)) else 0

    row = one("SELECT started_at FROM creator_video_sessions_v249405 WHERE session_id=?", (session_id,))
    started_at = float(row["started_at"]) if row else now()

    execute("""
        INSERT INTO creator_video_sessions_v249405(
            session_id,room_id,post_id,creator_id,viewer_hash,is_owner,valid_view,
            play_count,replay_count,completion_count,pause_count,resume_count,
            milestone_mask,active_watch_ms,duration_ms,max_position_ms,started_at,updated_at
        ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
        ON CONFLICT(session_id) DO UPDATE SET
            play_count=excluded.play_count, replay_count=excluded.replay_count,
            completion_count=excluded.completion_count, pause_count=excluded.pause_count,
            resume_count=excluded.resume_count, milestone_mask=excluded.milestone_mask,
            active_watch_ms=excluded.active_watch_ms, duration_ms=excluded.duration_ms,
            max_position_ms=excluded.max_position_ms, valid_view=excluded.valid_view,
            updated_at=excluded.updated_at
    """, (
        session_id, room_id, post_id, creator_id, viewer_hash, is_owner, valid_view,
        play_count, replay_count, completion_count, pause_count, resume_count,
        milestone_mask, active_watch_ms, duration_ms, max_position_ms, started_at, now(),
    ))
    return {"ok": True, "recorded": True, "valid_view": bool(valid_view), "session_id": session_id}


@app.get("/api/creator/views/summary_v249405")
def creator_views_summary_v249405(
    room_id: str = Query("ithaki-phone-test"),
    device_id: str = Query(...),
    days: int = Query(30, ge=1, le=365),
):
    _creator_views_table_v249405()
    room_id = str(room_id or "ithaki-phone-test").strip()[:120]
    device_id = str(device_id or "").strip()[:180]
    if len(device_id) < 3:
        raise HTTPException(400, "device_id_required")

    if not _creator_active_v249405(room_id, device_id):
        return {"ok": True, "version": "V249405-real-video-view-foundation", "active_creator": False,
                "summary": {"valid_views": 0, "unique_viewers": 0, "active_watch_ms": 0, "completions": 0, "replays": 0},
                "posts": [], "message": "creator_account_not_active"}

    since = now() - max(1, min(int(days or 30), 3650)) * 86400
    session_rows = rows("""
        SELECT post_id,viewer_hash,play_count,replay_count,completion_count,pause_count,
               resume_count,milestone_mask,active_watch_ms,duration_ms
        FROM creator_video_sessions_v249405
        WHERE room_id=? AND creator_id=? AND is_owner=0 AND valid_view=1 AND started_at>=?
    """, (room_id, device_id, since))

    per_post = {}
    total_watch = 0
    total_completions = 0
    total_replays = 0
    unique_viewers_global = set()

    for r in session_rows:
        d = dict(r)
        pid = str(d.get("post_id") or "")
        if not pid:
            continue
        vh = str(d.get("viewer_hash") or "")
        unique_viewers_global.add(vh)
        bucket = per_post.setdefault(pid, {"views": 0, "viewers": set(), "watch": 0, "completions": 0,
                                            "replays": 0, "pauses": 0, "resumes": 0,
                                            "r25": 0, "r50": 0, "r75": 0, "r95": 0})
        bucket["views"] += 1
        bucket["viewers"].add(vh)
        bucket["watch"] += int(d.get("active_watch_ms") or 0)
        bucket["completions"] += int(d.get("completion_count") or 0)
        bucket["replays"] += int(d.get("replay_count") or 0)
        bucket["pauses"] += int(d.get("pause_count") or 0)
        bucket["resumes"] += int(d.get("resume_count") or 0)
        mask = int(d.get("milestone_mask") or 0)
        if mask & 1: bucket["r25"] += 1
        if mask & 2: bucket["r50"] += 1
        if mask & 4: bucket["r75"] += 1
        if mask & 8: bucket["r95"] += 1
        total_watch += int(d.get("active_watch_ms") or 0)
        total_completions += int(d.get("completion_count") or 0)
        total_replays += int(d.get("replay_count") or 0)

    posts = []
    for pid, b in per_post.items():
        post = _creator_video_post_v249405(room_id, pid) or {}
        views = int(b["views"])
        posts.append({
            "post_id": pid,
            "caption": post.get("caption") or "Βίντεο",
            "created_at": float(post.get("created_at") or 0),
            "valid_views": views,
            "unique_viewers": len(b["viewers"]),
            "active_watch_ms": int(b["watch"]),
            "average_watch_ms": int(b["watch"] / views) if views else 0,
            "completions": int(b["completions"]),
            "completion_rate": round(b["completions"] * 100.0 / views, 1) if views else 0.0,
            "replays": int(b["replays"]),
            "pauses": int(b["pauses"]),
            "resumes": int(b["resumes"]),
            "reached_25": int(b["r25"]), "reached_50": int(b["r50"]),
            "reached_75": int(b["r75"]), "reached_95": int(b["r95"]),
        })
    posts.sort(key=lambda p: -p["valid_views"])

    return {
        "ok": True, "version": "V249405-real-video-view-foundation", "active_creator": True,
        "days": int(days),
        "summary": {
            "valid_views": sum(p["valid_views"] for p in posts),
            "unique_viewers": len(unique_viewers_global),
            "active_watch_ms": total_watch,
            "completions": total_completions,
            "replays": total_replays,
        },
        "posts": posts,
        "privacy": {"viewer_identity": "server-side HMAC pseudonym only", "raw_device_id_stored": False},
    }


@app.get("/api/creator/views/diagnostics_v249405")
def creator_views_diagnostics_v249405():
    _creator_views_table_v249405()
    row = one("SELECT COUNT(*) n FROM creator_video_sessions_v249405 WHERE valid_view=1 AND is_owner=0")
    return {"ok": True, "version": "V249405-real-video-view-foundation",
            "valid_non_owner_sessions": int(row["n"] if row else 0),
            "validity_rule": "visible_ratio>=0.65 and (active_watch_ms>=2000 or completion_count>0)",
            "viewer_identity_stored": "hmac_pseudonym_only", "raw_device_id_stored": False}

# V249406D_CREATOR_ANALYTICS_CHARTS
from fastapi.responses import HTMLResponse as _HTMLResponse_v249406d

_CREATOR_ANALYTICS_HTML_V249406D = r"""<!doctype html>
<html lang="el"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#07101a"><title>ΙΘΑΚΗ · Στατιστικά Δημιουργού</title>
<style>
:root{color-scheme:dark;font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}*{box-sizing:border-box}
body{margin:0;min-height:100vh;color:#eef7ff;background:radial-gradient(circle at 10% 0,rgba(231,184,81,.19),transparent 32%),linear-gradient(150deg,#050912,#0d1522 62%,#04070c)}
button,a{font:inherit}.shell{width:min(1160px,100%);margin:auto;padding:14px;display:grid;gap:12px}
.top{position:sticky;top:0;z-index:8;display:grid;grid-template-columns:auto 1fr auto;gap:8px;align-items:center;padding:8px;border-radius:18px;background:rgba(4,9,16,.94);border:1px solid rgba(231,184,81,.23);backdrop-filter:blur(15px)}
.back{width:44px;height:44px;display:grid;place-items:center;border-radius:50%;border:1px solid rgba(255,255,255,.13);background:rgba(255,255,255,.07);color:#fff;text-decoration:none}.top h1{margin:0;text-align:center;color:#f2d596;font-size:20px}
.rules{padding:12px 14px;border-radius:18px;border:1px solid rgba(71,207,255,.17);background:rgba(71,207,255,.055);color:#c3d3dd;font-size:12px;line-height:1.55}.rules b{color:#e8f6ff}
.filters{display:flex;gap:8px;flex-wrap:wrap}.filters button{min-height:42px;padding:9px 14px;border-radius:999px;border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.055);color:#dce8f0;cursor:pointer}.filters button.active{color:#211508;background:linear-gradient(135deg,#a66f1f,#e0bd69);font-weight:900}
.metrics{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:9px}.metric{padding:14px 9px;text-align:center;border-radius:19px;border:1px solid rgba(255,255,255,.11);background:rgba(255,255,255,.045)}.metric b{display:block;color:#f1d28d;font-size:23px}.metric span{display:block;color:#b9c7d1;font-size:11px;margin-top:4px}
.charts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.chart{padding:14px;border-radius:22px;border:1px solid rgba(255,255,255,.11);background:rgba(255,255,255,.045);min-width:0}.chart h2{margin:0;color:#f2d596;font-size:16px}.chart p{margin:5px 0 12px;color:#9fb0bc;font-size:11px;line-height:1.45}.rows{display:grid;gap:9px}.row{display:grid;grid-template-columns:minmax(0,1fr) 58px;gap:9px;align-items:center}.name{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.track{height:12px;margin-top:5px;border-radius:999px;background:rgba(255,255,255,.07);overflow:hidden}.fill{height:100%;border-radius:999px;background:linear-gradient(90deg,#59b9da,#e0bd65)}.value{text-align:right;color:#dce8ef;font-size:11px}.funnel .row{grid-template-columns:42px minmax(0,1fr) 74px}.funnel .fill{background:linear-gradient(90deg,#a96f1f,#e6c979)}
.list{display:grid;gap:10px}.empty{padding:25px;text-align:center;border-radius:22px;border:1px dashed rgba(255,255,255,.16);color:#b9c6cf;line-height:1.55}.item{display:grid;gap:10px;padding:14px;border-radius:22px;border:1px solid rgba(255,255,255,.11);background:rgba(255,255,255,.045)}.head{display:grid;grid-template-columns:50px 1fr auto;gap:10px;align-items:center}.rank{width:46px;height:46px;display:grid;place-items:center;border-radius:16px;background:rgba(234,188,91,.14);color:#f2d596;font-weight:900}.title{min-width:0}.title b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.title small{display:block;color:#aebdc8;margin-top:5px}.open{padding:9px 12px;border-radius:14px;text-decoration:none;border:1px solid rgba(54,240,160,.24);background:rgba(54,240,160,.08);color:#d8f7e8}
.nums{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:7px}.num{padding:9px 6px;text-align:center;border-radius:14px;background:rgba(255,255,255,.05)}.num b{display:block}.num span{font-size:10px;color:#aebdc8}.foot{font-size:11px;color:#9fb0bc}.profileLink{display:inline-block;margin-top:12px;padding:10px 14px;border-radius:14px;color:#f3d99b;border:1px solid rgba(234,188,91,.25);background:rgba(234,188,91,.07);text-decoration:none}
@media(max-width:900px){.charts{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.metrics .metric:last-child{grid-column:1/-1}.nums{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(max-width:620px){.head{grid-template-columns:46px 1fr}.open{grid-column:1/-1;text-align:center}}
</style></head><body><main class="shell">
<div class="top"><a class="back" href="/?v=249406d_creator_charts">‹</a><h1>📊 Στατιστικά Δημιουργού</h1><a class="back" href="/?v=249406d_creator_charts">×</a></div>
<div class="rules"><b>Χωρίς μαύρο κουτί:</b> πραγματική προβολή σημαίνει τουλάχιστον 2 ενεργά δευτερόλεπτα ή ολοκλήρωση. Μετρά μόνο όταν η σελίδα είναι ορατή και το βίντεο βρίσκεται τουλάχιστον 65% στην οθόνη. Οι δικές σου προβολές εξαιρούνται. Τα δεδομένα ξεκινούν από τη V405.</div>
<div class="filters"><button data-days="7">7 ημέρες</button><button data-days="30" class="active">30 ημέρες</button><button data-days="90">90 ημέρες</button></div>
<section class="metrics"><div class="metric"><b id="views">–</b><span>Προβολές</span></div><div class="metric"><b id="unique">–</b><span>Μοναδικοί</span></div><div class="metric"><b id="watch">–</b><span>Ενεργός χρόνος</span></div><div class="metric"><b id="complete">–</b><span>Ολοκληρώσεις</span></div><div class="metric"><b id="replay">–</b><span>Επαναλήψεις</span></div></section>
<section class="charts"><article class="chart"><h2>Προβολές ανά βίντεο</h2><p>Σύγκριση μόνο ανάμεσα στα δικά σου βίντεο.</p><div id="viewsChart" class="rows"></div></article><article class="chart"><h2>Ενεργός χρόνος</h2><p>Πόσο πραγματικό χρόνο παρακολούθησης συγκέντρωσε κάθε βίντεο.</p><div id="watchChart" class="rows"></div></article><article class="chart"><h2>Completion rate</h2><p>Ποσοστό πραγματικών προβολών που ολοκληρώθηκαν.</p><div id="completionChart" class="rows"></div></article><article class="chart"><h2>Διαδρομή παρακολούθησης</h2><p>Συνολική διατήρηση στο 25%, 50%, 75% και 95%.</p><div id="retentionChart" class="rows funnel"></div></article></section>
<section id="list" class="list"><div class="empty">Φόρτωση πραγματικών στοιχείων…</div></section></main>
<script>(function(){"use strict";
function esc(v){return String(v==null?"":v).replace(/[&<>"']/g,function(c){return {"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]})}
function dev(){try{return String(localStorage.getItem("ith_device")||localStorage.getItem("device_id")||"")}catch(e){return ""}}
function room(){try{return String(localStorage.getItem("ith_room")||"ithaki-phone-test")}catch(e){return "ithaki-phone-test"}}
function dur(ms){var n=Math.max(0,Math.round(Number(ms||0))),s=Math.floor(n/1000),h=Math.floor(s/3600),m=Math.floor((s%3600)/60),x=s%60;return h?h+"ω "+m+"λ":m?m+"λ "+x+"δ":x+"δ"}
function pct(v,t){var a=Number(v||0),b=Number(t||0);return b>0?Math.max(0,Math.min(100,Math.round(a*1000/b)/10)):0}
function chart(id,items,value,label,format,maxOverride){var host=document.getElementById(id);if(!items.length){host.innerHTML='<div class="empty">Δεν υπάρχουν ακόμη δεδομένα.</div>';return}var vals=items.map(value),max=maxOverride||Math.max.apply(null,[1].concat(vals));host.innerHTML=items.map(function(p,i){var v=vals[i],w=Math.max(2,Math.round(v*100/max));return '<div class="row"><div><div class="name" title="'+esc(label(p))+'">'+esc(label(p))+'</div><div class="track"><div class="fill" style="width:'+w+'%"></div></div></div><div class="value">'+esc(format(v,p))+'</div></div>'}).join("")}
function retention(posts,total){var sums=[0,0,0,0];posts.forEach(function(p){sums[0]+=Number(p.reached_25||0);sums[1]+=Number(p.reached_50||0);sums[2]+=Number(p.reached_75||0);sums[3]+=Number(p.reached_95||0)});var labels=["25%","50%","75%","95%"],host=document.getElementById("retentionChart");host.innerHTML=labels.map(function(l,i){var q=pct(sums[i],total);return '<div class="row"><b>'+l+'</b><div class="track"><div class="fill" style="width:'+q+'%"></div></div><div class="value">'+sums[i]+' · '+q+'%</div></div>'}).join("")}
async function load(days){document.querySelectorAll("[data-days]").forEach(function(b){b.classList.toggle("active",Number(b.dataset.days)===Number(days))});var list=document.getElementById("list"),d=dev();list.innerHTML='<div class="empty">Φόρτωση πραγματικών στοιχείων…</div>';if(d.length<3){list.innerHTML='<div class="empty">Δεν βρέθηκε η συσκευή. Άνοιξε πρώτα το Προφίλ.<br><a class="profileLink" href="/?v=249406d_creator_charts">Επιστροφή</a></div>';return}try{var r=await fetch('/api/creator/views/summary_v249405?room_id='+encodeURIComponent(room())+'&device_id='+encodeURIComponent(d)+'&days='+days,{cache:'no-store',credentials:'same-origin'}),data=await r.json();if(!r.ok||!data||data.ok===false)throw new Error((data&&(data.detail||data.message))||('HTTP '+r.status));var s=data.summary||{};views.textContent=s.valid_views||0;unique.textContent=s.unique_viewers||0;watch.textContent=dur(s.active_watch_ms||0);complete.textContent=s.completions||0;replay.textContent=s.replays||0;if(!data.active_creator){list.innerHTML='<div class="empty"><b>Ο λογαριασμός δεν έχει αποθηκευτεί ως Δημιουργός.</b><br><br>Επίλεξε Δημιουργός / Επαγγελματικός, βάλε ιδιωτικό κωδικό και πάτησε Αποθήκευση.<br><a class="profileLink" href="/?v=249406d_creator_charts">Επιστροφή στο Προφίλ</a></div>';return}var posts=Array.isArray(data.posts)?data.posts:[],top=posts.slice().sort(function(a,b){return Number(b.valid_views||0)-Number(a.valid_views||0)}).slice(0,8);chart('viewsChart',top,function(p){return Number(p.valid_views||0)},function(p){return p.caption||'Βίντεο'},function(v){return String(v)});chart('watchChart',top,function(p){return Number(p.active_watch_ms||0)},function(p){return p.caption||'Βίντεο'},function(v){return dur(v)});chart('completionChart',top,function(p){return Number(p.completion_rate||0)},function(p){return p.caption||'Βίντεο'},function(v){return v+'%'},100);retention(posts,Number(s.valid_views||0));if(!posts.length){list.innerHTML='<div class="empty"><b>Η συλλογή είναι ενεργή.</b><br><br>Δεν υπάρχουν ακόμη πραγματικές μετρήσεις. Τα στοιχεία ξεκινούν από τη V405.</div>';return}list.innerHTML=posts.map(function(p,i){return '<article class="item"><div class="head"><div class="rank">#'+(i+1)+'</div><div class="title"><b>'+esc(p.caption||'Βίντεο')+'</b><small>'+Number(p.valid_views||0)+' προβολές · '+Number(p.unique_viewers||0)+' μοναδικοί</small></div><a class="open" href="/?v=249406d_creator_charts&open_post='+encodeURIComponent(String(p.post_id||''))+'">Άνοιγμα στη Ροή</a></div><div class="nums"><div class="num"><b>'+dur(p.average_watch_ms||0)+'</b><span>Μέσος χρόνος</span></div><div class="num"><b>'+Number(p.completions||0)+'</b><span>Ολοκληρώσεις</span></div><div class="num"><b>'+Number(p.completion_rate||0)+'%</b><span>Completion</span></div><div class="num"><b>'+Number(p.replays||0)+'</b><span>Επαναλήψεις</span></div><div class="num"><b>'+Number(p.pauses||0)+'</b><span>Παύσεις</span></div><div class="num"><b>'+Number(p.resumes||0)+'</b><span>Επιστροφές</span></div></div><div class="foot">Ενεργός χρόνος: '+dur(p.active_watch_ms||0)+' · Retention 25/50/75/95: '+Number(p.reached_25||0)+' / '+Number(p.reached_50||0)+' / '+Number(p.reached_75||0)+' / '+Number(p.reached_95||0)+'</div></article>'}).join('')}catch(e){list.innerHTML='<div class="empty">Τα στατιστικά δεν φόρτωσαν: '+esc(e.message||e)+'</div>'}}
document.querySelectorAll('[data-days]').forEach(function(b){b.onclick=function(){load(Number(b.dataset.days||30))}});load(30)})();</script></body></html>"""

@app.get("/creator-analytics-v249406d", response_class=_HTMLResponse_v249406d)
def creator_analytics_page_v249406d():
    return _HTMLResponse_v249406d(content=_CREATOR_ANALYTICS_HTML_V249406D, headers={"Cache-Control":"no-store, max-age=0"})

@app.get("/api/creator/analytics/page/diagnostics_v249406d")
def creator_analytics_page_diagnostics_v249406d():
    return {"ok":True,"version":"V249406D-creator-analytics-charts","standalone_route":"/creator-analytics-v249406d","charts":["views_per_video","watch_time_per_video","completion_rate","retention_funnel"],"floating_yellow_button":False,"uses_v405_real_data":True,"database_rows_modified":False}

# V249407_TRANSPARENT_VIEWING_QUALITY
from collections import defaultdict as _defaultdict_v249407
from fastapi.responses import HTMLResponse as _HTMLResponse_v249407
import json as _json_v249407
import time as _time_v249407

def _creator_quality_caption_v249407(room_id, post_id):
    try:
        post = _creator_video_post_v249405(room_id, post_id)
        if post:
            return str(post.get("caption") or "Βίντεο")[:260], float(post.get("created_at") or 0)
    except Exception:
        pass

    try:
        row = one(
            "SELECT text,post_kind,created_at FROM flow_posts "
            "WHERE room_id=? AND post_id=? LIMIT 1",
            (room_id, post_id),
        )
        if not row:
            return "Βίντεο", 0.0
        data = dict(row)
        helper = globals().get("_creator_video_info_v249403")
        if callable(helper):
            is_video, caption = helper(data.get("text"), data.get("post_kind"))
            if is_video:
                return str(caption or "Βίντεο")[:260], float(data.get("created_at") or 0)
        raw = str(data.get("text") or "").strip()
        try:
            obj = _json_v249407.loads(raw)
            if isinstance(obj, dict):
                caption = str(
                    obj.get("caption")
                    or obj.get("body")
                    or obj.get("text")
                    or "Βίντεο"
                ).strip()
                return (caption or "Βίντεο")[:260], float(data.get("created_at") or 0)
        except Exception:
            pass
        return (raw[:260] or "Βίντεο"), float(data.get("created_at") or 0)
    except Exception:
        return "Βίντεο", 0.0

def _creator_quality_classify_viewer_v249407(sessions):
    windows = {}
    attentive_once_observed = False
    total_plays = 0
    total_replays = 0

    for item in sessions:
        started_at = float(item.get("started_at") or 0)
        window_key = int(started_at // 600)  # transparent ten-minute window

        play_count = max(1, int(item.get("play_count") or 1))
        replay_count = max(0, int(item.get("replay_count") or 0))
        completion_count = max(0, int(item.get("completion_count") or 0))
        milestone_mask = int(item.get("milestone_mask") or 0)
        active_watch_ms = max(0, int(item.get("active_watch_ms") or 0))
        duration_ms = max(0, int(item.get("duration_ms") or 0))
        max_position_ms = max(0, int(item.get("max_position_ms") or 0))

        total_plays += play_count
        total_replays += replay_count

        bucket = windows.setdefault(window_key, {
            "plays": 0,
            "replays": 0,
            "completions": 0,
        })
        bucket["plays"] += play_count
        bucket["replays"] += replay_count
        bucket["completions"] += completion_count

        reached_75 = bool(milestone_mask & 4)
        completed = completion_count > 0
        ratio_attention = bool(
            duration_ms > 0
            and (
                max_position_ms >= int(duration_ms * 0.75)
                or active_watch_ms >= int(duration_ms * 0.70)
            )
        )
        if play_count == 1 and replay_count == 0 and (reached_75 or completed or ratio_attention):
            attentive_once_observed = True

    max_plays_10m = max([w["plays"] for w in windows.values()] or [0])

    if max_plays_10m >= 10:
        category = "very_intense_repeat"
    elif max_plays_10m >= 4:
        category = "intense_repeat"
    elif max_plays_10m >= 2:
        category = "natural_repeat"
    elif attentive_once_observed:
        category = "attentive_once"
    else:
        category = "single_partial"

    return {
        "category": category,
        "max_plays_in_10_minutes": int(max_plays_10m),
        "total_plays": int(total_plays),
        "total_replays": int(total_replays),
    }

@app.get("/api/creator/views/quality_v249407")
def creator_viewing_quality_v249407(
    room_id: str = Query("ithaki-phone-test"),
    device_id: str = Query(...),
    days: int = Query(30, ge=1, le=365),
):
    _creator_views_init_v249405()

    room_id = str(room_id or "ithaki-phone-test").strip()[:120]
    device_id = str(device_id or "").strip()[:180]
    if len(device_id) < 3:
        raise HTTPException(400, "device_id_required")

    rules = {
        "window_minutes": 10,
        "attentive_once": "one play, no replay, and at least 75 percent reached or completed",
        "single_partial": "one play without reaching the attentive threshold",
        "natural_repeat": "two or three plays inside ten minutes",
        "intense_repeat": "four to nine plays inside ten minutes",
        "very_intense_repeat": "ten or more plays inside ten minutes",
        "privacy_minimum_unique_viewers_per_video": 3,
        "medical_diagnosis": False,
    }

    if not _creator_active_v249405(room_id, device_id):
        return {
            "ok": True,
            "version": "V249407-transparent-viewing-quality",
            "active_creator": False,
            "days": int(days),
            "rules": rules,
            "summary": {
                "privacy_ready_videos": 0,
                "suppressed_videos": 0,
                "viewer_video_patterns": 0,
                "attentive_once": 0,
                "single_partial": 0,
                "natural_repeat": 0,
                "intense_repeat": 0,
                "very_intense_repeat": 0,
            },
            "posts": [],
            "message": "creator_account_not_active",
        }

    since = float(_time_v249407.time() - int(days) * 86400)
    session_rows = rows(
        """
        SELECT
            post_id,viewer_hash,started_at,play_count,replay_count,
            completion_count,milestone_mask,active_watch_ms,
            duration_ms,max_position_ms
        FROM creator_video_sessions_v249405
        WHERE room_id=? AND creator_id=? AND is_owner=0
          AND valid_view=1 AND started_at>=?
        ORDER BY post_id,viewer_hash,started_at ASC
        """,
        (room_id, device_id, since),
    )

    grouped = _defaultdict_v249407(list)
    for row in session_rows:
        data = dict(row)
        post_id = str(data.get("post_id") or "")
        viewer_hash = str(data.get("viewer_hash") or "")
        if not post_id or not viewer_hash:
            continue
        grouped[(post_id, viewer_hash)].append(data)

    per_post = _defaultdict_v249407(lambda: {
        "attentive_once": 0,
        "single_partial": 0,
        "natural_repeat": 0,
        "intense_repeat": 0,
        "very_intense_repeat": 0,
        "max_observed_plays_10m": 0,
    })

    for (post_id, _viewer_hash), sessions in grouped.items():
        classified = _creator_quality_classify_viewer_v249407(sessions)
        category = classified["category"]
        per_post[post_id][category] += 1
        per_post[post_id]["max_observed_plays_10m"] = max(
            int(per_post[post_id]["max_observed_plays_10m"]),
            int(classified["max_plays_in_10_minutes"]),
        )

    privacy_minimum = int(rules["privacy_minimum_unique_viewers_per_video"])
    posts = []
    summary = {
        "privacy_ready_videos": 0,
        "suppressed_videos": 0,
        "viewer_video_patterns": 0,
        "attentive_once": 0,
        "single_partial": 0,
        "natural_repeat": 0,
        "intense_repeat": 0,
        "very_intense_repeat": 0,
    }

    for post_id, counts in per_post.items():
        total = (
            int(counts["attentive_once"])
            + int(counts["single_partial"])
            + int(counts["natural_repeat"])
            + int(counts["intense_repeat"])
            + int(counts["very_intense_repeat"])
        )
        caption, created_at = _creator_quality_caption_v249407(room_id, post_id)
        privacy_ready = total >= privacy_minimum

        if privacy_ready:
            summary["privacy_ready_videos"] += 1
            summary["viewer_video_patterns"] += total
            for key in (
                "attentive_once",
                "single_partial",
                "natural_repeat",
                "intense_repeat",
                "very_intense_repeat",
            ):
                summary[key] += int(counts[key])

            categories = {
                key: int(counts[key])
                for key in (
                    "attentive_once",
                    "single_partial",
                    "natural_repeat",
                    "intense_repeat",
                    "very_intense_repeat",
                )
            }
            percentages = {
                key: round(int(counts[key]) * 100.0 / total, 1) if total else 0.0
                for key in categories
            }
            unique_viewers = total
            max_observed = int(counts["max_observed_plays_10m"])
        else:
            summary["suppressed_videos"] += 1
            categories = None
            percentages = None
            unique_viewers = None
            max_observed = None

        posts.append({
            "post_id": post_id,
            "caption": caption,
            "created_at": float(created_at or 0),
            "privacy_ready": bool(privacy_ready),
            "unique_viewers": unique_viewers,
            "viewer_range": None if privacy_ready else "fewer_than_3",
            "categories": categories,
            "percentages": percentages,
            "max_observed_plays_10m": max_observed,
        })

    posts.sort(
        key=lambda item: (
            0 if item["privacy_ready"] else 1,
            -int(item["unique_viewers"] or 0),
            -float(item["created_at"] or 0),
        )
    )

    return {
        "ok": True,
        "version": "V249407-transparent-viewing-quality",
        "active_creator": True,
        "days": int(days),
        "rules": rules,
        "summary": summary,
        "posts": posts,
        "privacy": {
            "aggregated_only": True,
            "viewer_names_visible": False,
            "viewer_hashes_visible": False,
            "small_audiences_suppressed": True,
        },
        "interpretation": {
            "intense_repeat_is_observed_pattern_not_diagnosis": True,
            "no_secret_score": True,
        },
    }

@app.get("/api/creator/views/quality/diagnostics_v249407")
def creator_viewing_quality_diagnostics_v249407():
    try:
        sessions = one(
            "SELECT COUNT(*) n FROM creator_video_sessions_v249405 "
            "WHERE valid_view=1 AND is_owner=0"
        )
        valid_sessions = int(sessions["n"] if sessions else 0)
    except Exception:
        valid_sessions = 0

    return {
        "ok": True,
        "version": "V249407-transparent-viewing-quality",
        "source": "V249405 real video sessions",
        "valid_non_owner_sessions": valid_sessions,
        "window_minutes": 10,
        "categories": {
            "attentive_once": "one play and 75 percent or completion",
            "single_partial": "one play below attentive threshold",
            "natural_repeat": "2 to 3 plays in 10 minutes",
            "intense_repeat": "4 to 9 plays in 10 minutes",
            "very_intense_repeat": "10 or more plays in 10 minutes",
        },
        "privacy_minimum_unique_viewers_per_video": 3,
        "viewer_names_exposed": False,
        "viewer_hashes_exposed": False,
        "medical_diagnosis": False,
        "database_rows_modified": False,
    }

_CREATOR_VIEW_QUALITY_HTML_V249407 = r"""<!doctype html>
<html lang="el">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
  <meta name="theme-color" content="#07101a">
  <title>ΙΘΑΚΗ · Ποιότητα Θέασης</title>
  <style>
    :root{color-scheme:dark;font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
    *{box-sizing:border-box}
    body{margin:0;min-height:100vh;color:#eef7ff;background:
      radial-gradient(circle at 12% 0,rgba(231,184,81,.18),transparent 32%),
      radial-gradient(circle at 90% 12%,rgba(91,190,217,.12),transparent 30%),
      linear-gradient(150deg,#050912,#0d1522 62%,#04070c)}
    button,a{font:inherit}
    .shell{width:min(1120px,100%);margin:auto;padding:14px;display:grid;gap:12px}
    .top{position:sticky;top:0;z-index:8;display:grid;grid-template-columns:auto 1fr auto;
      gap:8px;align-items:center;padding:8px;border-radius:18px;background:rgba(4,9,16,.94);
      border:1px solid rgba(231,184,81,.23);backdrop-filter:blur(15px)}
    .back{width:44px;height:44px;display:grid;place-items:center;border-radius:50%;
      border:1px solid rgba(255,255,255,.13);background:rgba(255,255,255,.07);
      color:#fff;text-decoration:none}
    h1{margin:0;text-align:center;color:#f2d596;font-size:20px}
    .ethics{padding:13px 15px;border-radius:20px;border:1px solid rgba(85,215,255,.18);
      background:rgba(85,215,255,.055);color:#c4d4dd;font-size:12px;line-height:1.58}
    .ethics b{color:#eefaff}
    .filters{display:flex;gap:8px;flex-wrap:wrap}
    .filters button{min-height:42px;padding:9px 14px;border-radius:999px;
      border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.055);
      color:#dce8f0;cursor:pointer}
    .filters button.active{border-color:rgba(234,188,91,.72);color:#211508;
      background:linear-gradient(135deg,#a66f1f,#e0bd69);font-weight:900}
    .rules{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px}
    .rule{padding:12px;border-radius:18px;border:1px solid rgba(255,255,255,.10);
      background:rgba(255,255,255,.04);min-height:112px}
    .rule b{display:block;margin-bottom:7px;font-size:13px}.rule small{color:#aebdc8;line-height:1.45}
    .c1{color:#a8efcc}.c2{color:#b8c9d5}.c3{color:#93d8ef}.c4{color:#efc873}.c5{color:#ef9e8f}
    .summary{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px}
    .metric{padding:14px 9px;text-align:center;border-radius:19px;
      border:1px solid rgba(255,255,255,.11);background:rgba(255,255,255,.045)}
    .metric b{display:block;font-size:23px}.metric span{display:block;color:#b9c7d1;font-size:10px;margin-top:5px}
    .overview{padding:14px;border-radius:22px;border:1px solid rgba(255,255,255,.11);
      background:rgba(255,255,255,.045)}
    .overview h2{margin:0 0 6px;color:#f2d596;font-size:16px}.overview p{margin:0 0 12px;color:#9fb0bc;font-size:11px}
    .stack{height:28px;display:flex;border-radius:999px;overflow:hidden;background:rgba(255,255,255,.06)}
    .seg{height:100%;min-width:0}.seg1{background:#53b989}.seg2{background:#718393}.seg3{background:#4eaccb}.seg4{background:#c69238}.seg5{background:#c45d4b}
    .legend{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:7px;margin-top:10px}
    .leg{font-size:10px;color:#aebdc8}.leg i{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:5px}
    .list{display:grid;gap:10px}
    .empty{padding:25px;text-align:center;border-radius:22px;
      border:1px dashed rgba(255,255,255,.16);color:#b9c6cf;line-height:1.55}
    .item{display:grid;gap:11px;padding:14px;border-radius:22px;
      border:1px solid rgba(255,255,255,.11);background:rgba(255,255,255,.045)}
    .head{display:grid;grid-template-columns:48px 1fr auto;gap:10px;align-items:center}
    .rank{width:44px;height:44px;display:grid;place-items:center;border-radius:15px;
      background:rgba(234,188,91,.13);color:#f2d596;font-weight:900}
    .title{min-width:0}.title b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
    .title small{display:block;color:#aebdc8;margin-top:5px}
    .open{padding:9px 12px;border-radius:14px;text-decoration:none;
      border:1px solid rgba(54,240,160,.24);background:rgba(54,240,160,.08);color:#d8f7e8}
    .numbers{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:7px}
    .num{padding:9px 6px;text-align:center;border-radius:14px;background:rgba(255,255,255,.05)}
    .num b{display:block}.num span{font-size:9px;color:#aebdc8}
    .privacy{padding:14px;border-radius:18px;border:1px dashed rgba(255,255,255,.15);
      color:#b2c0c9;font-size:11px;line-height:1.5}
    .profileLink{display:inline-block;margin-top:12px;padding:10px 14px;border-radius:14px;
      color:#f3d99b;border:1px solid rgba(234,188,91,.25);background:rgba(234,188,91,.07);
      text-decoration:none}
    @media(max-width:900px){.rules,.summary,.legend{grid-template-columns:repeat(2,minmax(0,1fr))}
      .rules .rule:last-child,.summary .metric:last-child,.legend .leg:last-child{grid-column:1/-1}}
    @media(max-width:620px){.head{grid-template-columns:44px 1fr}.open{grid-column:1/-1;text-align:center}
      .numbers{grid-template-columns:repeat(2,minmax(0,1fr))}.numbers .num:last-child{grid-column:1/-1}}
  </style>
</head>
<body>
<main class="shell">
  <div class="top">
    <a class="back" href="/?v=249407_viewing_quality">‹</a>
    <h1>🌿 Ποιότητα θέασης</h1>
    <a class="back" href="/?v=249407_viewing_quality">×</a>
  </div>

  <div class="ethics">
    <b>Παρατήρηση, όχι διάγνωση.</b>
    Η ΙΘΑΚΗ δεν χαρακτηρίζει ανθρώπους και δεν χρησιμοποιεί κρυφό σκορ.
    Δείχνει μόνο μετρήσιμα μοτίβα: πόσες φορές παίχτηκε ένα βίντεο μέσα σε δέκα λεπτά
    και αν μία μοναδική θέαση έφτασε τουλάχιστον στο 75% ή ολοκληρώθηκε.
    Για προστασία ιδιωτικότητας, αν ένα βίντεο έχει λιγότερους από 3 μοναδικούς θεατές,
    η κατανομή δεν εμφανίζεται.
  </div>

  <div class="filters">
    <button data-days="7">7 ημέρες</button>
    <button data-days="30" class="active">30 ημέρες</button>
    <button data-days="90">90 ημέρες</button>
  </div>

  <section class="rules">
    <article class="rule"><b class="c1">Ήρεμη προσεκτική θέαση</b><small>Μία αναπαραγωγή, χωρίς επανάληψη, με τουλάχιστον 75% ή ολοκλήρωση.</small></article>
    <article class="rule"><b class="c2">Μία σύντομη θέαση</b><small>Μία αναπαραγωγή που δεν έφτασε το όριο προσεκτικής θέασης.</small></article>
    <article class="rule"><b class="c3">Φυσιολογική επανάληψη</b><small>2–3 αναπαραγωγές του ίδιου βίντεο μέσα σε 10 λεπτά.</small></article>
    <article class="rule"><b class="c4">Έντονη επανάληψη</b><small>4–9 αναπαραγωγές μέσα σε 10 λεπτά. Εμφανίζεται ως μοτίβο, όχι ως διάγνωση.</small></article>
    <article class="rule"><b class="c5">Πολύ έντονη επανάληψη</b><small>10 ή περισσότερες αναπαραγωγές μέσα σε 10 λεπτά.</small></article>
  </section>

  <section class="summary">
    <div class="metric"><b id="s1" class="c1">–</b><span>Ήρεμη προσεκτική</span></div>
    <div class="metric"><b id="s2" class="c2">–</b><span>Μία σύντομη</span></div>
    <div class="metric"><b id="s3" class="c3">–</b><span>Φυσιολογική επανάληψη</span></div>
    <div class="metric"><b id="s4" class="c4">–</b><span>Έντονη επανάληψη</span></div>
    <div class="metric"><b id="s5" class="c5">–</b><span>10+ σε 10 λεπτά</span></div>
  </section>

  <section class="overview">
    <h2>Συνολική εικόνα μοτίβων θεατή–βίντεο</h2>
    <p>Κάθε μοναδικός θεατής ταξινομείται ξεχωριστά για κάθε βίντεο, με βάση το εντονότερο δεκάλεπτο που παρατηρήθηκε.</p>
    <div id="overviewStack" class="stack"></div>
    <div class="legend">
      <div class="leg"><i class="seg1"></i>Ήρεμη προσεκτική</div>
      <div class="leg"><i class="seg2"></i>Μία σύντομη</div>
      <div class="leg"><i class="seg3"></i>2–3 επαναλήψεις</div>
      <div class="leg"><i class="seg4"></i>4–9 επαναλήψεις</div>
      <div class="leg"><i class="seg5"></i>10+ επαναλήψεις</div>
    </div>
  </section>

  <section id="list" class="list"><div class="empty">Φόρτωση πραγματικών μοτίβων…</div></section>
</main>

<script>
(function(){
  "use strict";
  function esc(v){return String(v==null?"":v).replace(/[&<>"']/g,function(c){return {"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]})}
  function dev(){try{return String(localStorage.getItem("ith_device")||localStorage.getItem("device_id")||"")}catch(e){return ""}}
  function room(){try{return String(localStorage.getItem("ith_room")||"ithaki-phone-test")}catch(e){return "ithaki-phone-test"}}
  function pct(v,t){var a=Number(v||0),b=Number(t||0);return b>0?Math.max(0,Math.min(100,Math.round(a*1000/b)/10)):0}
  function date(ts){try{var n=Number(ts||0);if(!n)return "";var d=new Date(n>1e12?n:n*1000);return d.toLocaleDateString("el-GR",{day:"2-digit",month:"2-digit",year:"numeric"})}catch(e){return ""}}
  function setSummary(s){
    s=s||{};
    document.getElementById("s1").textContent=String(s.attentive_once||0);
    document.getElementById("s2").textContent=String(s.single_partial||0);
    document.getElementById("s3").textContent=String(s.natural_repeat||0);
    document.getElementById("s4").textContent=String(s.intense_repeat||0);
    document.getElementById("s5").textContent=String(s.very_intense_repeat||0);
    var total=Number(s.viewer_video_patterns||0);
    var parts=[
      ["seg1",Number(s.attentive_once||0)],
      ["seg2",Number(s.single_partial||0)],
      ["seg3",Number(s.natural_repeat||0)],
      ["seg4",Number(s.intense_repeat||0)],
      ["seg5",Number(s.very_intense_repeat||0)]
    ];
    document.getElementById("overviewStack").innerHTML=parts.map(function(x){
      return '<div class="seg '+x[0]+'" style="width:'+pct(x[1],total)+'%" title="'+x[1]+'"></div>';
    }).join("");
  }
  async function load(days){
    document.querySelectorAll("[data-days]").forEach(function(b){b.classList.toggle("active",Number(b.dataset.days)===Number(days))});
    var list=document.getElementById("list"),device=dev();
    list.innerHTML='<div class="empty">Φόρτωση πραγματικών μοτίβων…</div>';
    if(device.length<3){
      setSummary({});
      list.innerHTML='<div class="empty">Δεν βρέθηκε η συσκευή του λογαριασμού. Άνοιξε πρώτα το Προφίλ.<br><a class="profileLink" href="/?v=249407_viewing_quality">Επιστροφή</a></div>';
      return;
    }
    try{
      var r=await fetch('/api/creator/views/quality_v249407?room_id='+encodeURIComponent(room())+'&device_id='+encodeURIComponent(device)+'&days='+days,{cache:'no-store',credentials:'same-origin'});
      var data=await r.json();
      if(!r.ok||!data||data.ok===false)throw new Error((data&&(data.detail||data.message))||('HTTP '+r.status));
      setSummary(data.summary||{});
      if(!data.active_creator){
        list.innerHTML='<div class="empty"><b>Ο λογαριασμός δεν έχει αποθηκευτεί ως Δημιουργός / Επαγγελματικός.</b><br><br>Επίλεξε τον επαγγελματικό λογαριασμό, βάλε ιδιωτικό κωδικό και πάτησε Αποθήκευση.<br><a class="profileLink" href="/?v=249407_viewing_quality">Επιστροφή στο Προφίλ</a></div>';
        return;
      }
      var posts=Array.isArray(data.posts)?data.posts:[];
      if(!posts.length){
        list.innerHTML='<div class="empty"><b>Η συλλογή είναι ενεργή.</b><br><br>Δεν υπάρχουν ακόμη πραγματικές προβολές για ανάλυση. Τα δεδομένα ξεκινούν από τη V405.</div>';
        return;
      }
      list.innerHTML=posts.map(function(p,i){
        if(!p.privacy_ready){
          return '<article class="item"><div class="head"><div class="rank">#'+(i+1)+'</div><div class="title"><b>'+esc(p.caption||'Βίντεο')+'</b><small>'+esc(date(p.created_at))+'</small></div><a class="open" href="/?v=249407_viewing_quality&open_post='+encodeURIComponent(String(p.post_id||''))+'">Άνοιγμα στη Ροή</a></div><div class="privacy">🔒 Η κατανομή δεν εμφανίζεται επειδή υπάρχουν λιγότεροι από 3 μοναδικοί θεατές. Αυτό προστατεύει τη συμπεριφορά μικρού αριθμού μελών.</div></article>';
        }
        var c=p.categories||{},total=Number(p.unique_viewers||0);
        var vals=[
          ["seg1",Number(c.attentive_once||0)],
          ["seg2",Number(c.single_partial||0)],
          ["seg3",Number(c.natural_repeat||0)],
          ["seg4",Number(c.intense_repeat||0)],
          ["seg5",Number(c.very_intense_repeat||0)]
        ];
        return '<article class="item"><div class="head"><div class="rank">#'+(i+1)+'</div><div class="title"><b>'+esc(p.caption||'Βίντεο')+'</b><small>'+total+' μοναδικοί θεατές · '+esc(date(p.created_at))+'</small></div><a class="open" href="/?v=249407_viewing_quality&open_post='+encodeURIComponent(String(p.post_id||''))+'">Άνοιγμα στη Ροή</a></div>'+
          '<div class="stack">'+vals.map(function(x){return '<div class="seg '+x[0]+'" style="width:'+pct(x[1],total)+'%" title="'+x[1]+'"></div>'}).join('')+'</div>'+
          '<div class="numbers"><div class="num"><b class="c1">'+Number(c.attentive_once||0)+'</b><span>Ήρεμη προσεκτική</span></div><div class="num"><b class="c2">'+Number(c.single_partial||0)+'</b><span>Μία σύντομη</span></div><div class="num"><b class="c3">'+Number(c.natural_repeat||0)+'</b><span>2–3 σε 10′</span></div><div class="num"><b class="c4">'+Number(c.intense_repeat||0)+'</b><span>4–9 σε 10′</span></div><div class="num"><b class="c5">'+Number(c.very_intense_repeat||0)+'</b><span>10+ σε 10′</span></div></div>'+
          '<div class="privacy">Μέγιστη παρατηρημένη συχνότητα στο εντονότερο δεκάλεπτο: '+Number(p.max_observed_plays_10m||0)+' αναπαραγωγές. Δεν εμφανίζονται ονόματα ή αναγνωριστικά θεατών.</div></article>';
      }).join('');
    }catch(e){
      setSummary({});
      list.innerHTML='<div class="empty">Η ποιότητα θέασης δεν φόρτωσε: '+esc(e&&e.message||e)+'</div>';
    }
  }
  document.querySelectorAll("[data-days]").forEach(function(b){b.onclick=function(){load(Number(b.dataset.days||30))}});
  load(30);
})();
</script>
</body>
</html>"""

@app.get("/creator-view-quality-v249407", response_class=_HTMLResponse_v249407)
def creator_view_quality_page_v249407():
    return _HTMLResponse_v249407(
        content=_CREATOR_VIEW_QUALITY_HTML_V249407,
        headers={"Cache-Control": "no-store, max-age=0"},
    )

# V249408_PRIVATE_MESSAGE_SHARE_ANALYTICS
from collections import defaultdict as _defaultdict_v249408
from datetime import datetime as _datetime_v249408, timedelta as _timedelta_v249408, timezone as _timezone_v249408
from fastapi.responses import HTMLResponse as _HTMLResponse_v249408
import hmac as _hmac_v249408
import hashlib as _hashlib_v249408
import re as _re_v249408
import time as _time_v249408

def _creator_shares_init_v249408():
    execute(
        """
        CREATE TABLE IF NOT EXISTS creator_video_shares_v249408(
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            event_id TEXT NOT NULL UNIQUE,
            room_id TEXT NOT NULL,
            post_id TEXT NOT NULL,
            creator_id TEXT NOT NULL,
            sender_hash TEXT NOT NULL,
            sender_is_creator INTEGER NOT NULL DEFAULT 0,
            share_channel TEXT NOT NULL DEFAULT 'messages',
            created_at REAL NOT NULL
        )
        """
    )
    execute(
        "CREATE INDEX IF NOT EXISTS idx_creator_shares_owner_time_v249408 "
        "ON creator_video_shares_v249408(room_id,creator_id,created_at DESC)"
    )
    execute(
        "CREATE INDEX IF NOT EXISTS idx_creator_shares_post_time_v249408 "
        "ON creator_video_shares_v249408(room_id,post_id,created_at DESC)"
    )
    execute(
        "CREATE INDEX IF NOT EXISTS idx_creator_shares_sender_time_v249408 "
        "ON creator_video_shares_v249408(room_id,sender_hash,created_at DESC)"
    )

def _creator_share_sender_hash_v249408(room_id, sender_device_id):
    helper = globals().get("_creator_viewer_hash_v249405")
    if callable(helper):
        try:
            return str(helper(room_id, sender_device_id))
        except Exception:
            pass

    # Fallback remains server-side pseudonymous and never leaves the API.
    secret_helper = globals().get("_creator_views_secret_v249405")
    if callable(secret_helper):
        try:
            secret = secret_helper()
        except Exception:
            secret = b"v249408-private-share-fallback"
    else:
        secret = b"v249408-private-share-fallback"

    message = (str(room_id) + "\x00" + str(sender_device_id)).encode("utf-8")
    return _hmac_v249408.new(secret, message, _hashlib_v249408.sha256).hexdigest()

def _creator_share_post_v249408(room_id, post_id):
    helper = globals().get("_creator_video_post_v249405")
    if callable(helper):
        try:
            data = helper(room_id, post_id)
            if data:
                return data
        except Exception:
            pass

    row = one(
        "SELECT post_id,room_id,sender_id,nickname,text,post_kind,created_at "
        "FROM flow_posts WHERE room_id=? AND post_id=? LIMIT 1",
        (room_id, post_id),
    )
    if not row:
        return None

    data = dict(row)
    token = (str(data.get("post_kind") or "") + " " + str(data.get("text") or "")).lower()
    data["is_video"] = bool(
        "video" in token
        or _re_v249408.search(r"\.(mp4|webm|mov|m4v|3gp)(?:\?|#|$)", token)
        or "data:video/" in token
    )
    data["caption"] = str(data.get("text") or "Βίντεο")[:260]
    return data

def _creator_share_caption_v249408(room_id, post_id):
    post = _creator_share_post_v249408(room_id, post_id)
    if not post:
        return "Βίντεο", 0.0
    caption = str(post.get("caption") or "Βίντεο").strip()[:260]
    return caption or "Βίντεο", float(post.get("created_at") or 0)

@app.post("/api/creator/share/event_v249408")
def creator_share_event_v249408(
    room_id: str = Query("ithaki-phone-test"),
    sender_device_id: str = Query(...),
    post_id: str = Query(...),
    event_id: str = Query(...),
    share_channel: str = Query("messages"),
):
    _creator_shares_init_v249408()

    room_id = str(room_id or "ithaki-phone-test").strip()[:120]
    sender_device_id = str(sender_device_id or "").strip()[:180]
    post_id = str(post_id or "").strip()[:220]
    event_id = str(event_id or "").strip()[:150]
    share_channel = str(share_channel or "messages").strip().lower()[:30]

    if len(sender_device_id) < 3:
        raise HTTPException(400, "sender_device_id_required")
    if not post_id:
        raise HTTPException(400, "post_id_required")
    if not _re_v249408.fullmatch(r"[A-Za-z0-9_-]{12,150}", event_id):
        raise HTTPException(400, "invalid_event_id")
    if share_channel != "messages":
        raise HTTPException(400, "only_message_shares_supported")

    post = _creator_share_post_v249408(room_id, post_id)
    if not post:
        raise HTTPException(404, "post_not_found")
    if not bool(post.get("is_video")):
        raise HTTPException(400, "post_is_not_video")

    creator_id = str(post.get("sender_id") or "")
    if not creator_id:
        raise HTTPException(400, "post_creator_missing")

    sender_hash = _creator_share_sender_hash_v249408(room_id, sender_device_id)
    sender_is_creator = 1 if creator_id == sender_device_id else 0
    now = float(_time_v249408.time())

    existing = one(
        "SELECT id,sender_is_creator FROM creator_video_shares_v249408 "
        "WHERE event_id=? LIMIT 1",
        (event_id,),
    )
    if existing:
        return {
            "ok": True,
            "version": "V249408-private-message-share-analytics",
            "duplicate": True,
            "counted_for_creator": not bool(existing["sender_is_creator"]),
            "recipient_stored": False,
            "sender_identity_exposed": False,
        }

    execute(
        """
        INSERT INTO creator_video_shares_v249408(
            event_id,room_id,post_id,creator_id,sender_hash,
            sender_is_creator,share_channel,created_at
        ) VALUES(?,?,?,?,?,?,?,?)
        """,
        (
            event_id, room_id, post_id, creator_id, sender_hash,
            sender_is_creator, "messages", now,
        ),
    )

    return {
        "ok": True,
        "version": "V249408-private-message-share-analytics",
        "duplicate": False,
        "counted_for_creator": not bool(sender_is_creator),
        "creator_self_share_excluded": bool(sender_is_creator),
        "recipient_stored": False,
        "sender_identity_exposed": False,
    }

@app.get("/api/creator/shares/summary_v249408")
def creator_shares_summary_v249408(
    room_id: str = Query("ithaki-phone-test"),
    device_id: str = Query(...),
    days: int = Query(30, ge=1, le=365),
    tz_offset_minutes: int = Query(0, ge=-840, le=840),
):
    _creator_shares_init_v249408()

    room_id = str(room_id or "ithaki-phone-test").strip()[:120]
    device_id = str(device_id or "").strip()[:180]
    if len(device_id) < 3:
        raise HTTPException(400, "device_id_required")

    rules = {
        "counted": "successful video share through messages",
        "creator_self_shares": "excluded",
        "recipient_identity": "never stored",
        "sender_identity": "server-side HMAC pseudonym only",
        "unique_sharer_privacy_minimum": 3,
        "historical_backfill": False,
    }

    if not _creator_active_v249405(room_id, device_id):
        return {
            "ok": True,
            "version": "V249408-private-message-share-analytics",
            "active_creator": False,
            "days": int(days),
            "rules": rules,
            "summary": {
                "message_shares": 0,
                "unique_sharers": None,
                "shared_videos": 0,
            },
            "timeline": [],
            "posts": [],
            "message": "creator_account_not_active",
        }

    now_utc = _datetime_v249408.now(_timezone_v249408.utc)
    local_now = now_utc + _timedelta_v249408(minutes=int(tz_offset_minutes))
    start_local_date = local_now.date() - _timedelta_v249408(days=int(days) - 1)
    start_utc = _datetime_v249408.combine(
        start_local_date,
        _datetime_v249408.min.time(),
        tzinfo=_timezone_v249408.utc,
    ) - _timedelta_v249408(minutes=int(tz_offset_minutes))
    since = start_utc.timestamp()

    aggregate = one(
        """
        SELECT
            COUNT(*) AS message_shares,
            COUNT(DISTINCT sender_hash) AS unique_sharers,
            COUNT(DISTINCT post_id) AS shared_videos
        FROM creator_video_shares_v249408
        WHERE room_id=? AND creator_id=? AND sender_is_creator=0
          AND created_at>=?
        """,
        (room_id, device_id, since),
    )
    agg = dict(aggregate) if aggregate else {}
    total_shares = int(agg.get("message_shares") or 0)
    unique_sharers_raw = int(agg.get("unique_sharers") or 0)
    privacy_minimum = int(rules["unique_sharer_privacy_minimum"])

    daily_rows = rows(
        """
        SELECT
            date(created_at + (? * 60), 'unixepoch') AS day_key,
            COUNT(*) AS message_shares
        FROM creator_video_shares_v249408
        WHERE room_id=? AND creator_id=? AND sender_is_creator=0
          AND created_at>=?
        GROUP BY day_key
        ORDER BY day_key ASC
        """,
        (int(tz_offset_minutes), room_id, device_id, since),
    )
    daily_map = {
        str(dict(row).get("day_key") or ""): int(dict(row).get("message_shares") or 0)
        for row in daily_rows
    }
    timeline = []
    for offset in range(int(days)):
        day = start_local_date + _timedelta_v249408(days=offset)
        key = day.isoformat()
        timeline.append({
            "day": key,
            "message_shares": int(daily_map.get(key, 0)),
        })

    post_rows = rows(
        """
        SELECT
            post_id,
            COUNT(*) AS message_shares,
            COUNT(DISTINCT sender_hash) AS unique_sharers,
            MIN(created_at) AS first_share_at,
            MAX(created_at) AS latest_share_at
        FROM creator_video_shares_v249408
        WHERE room_id=? AND creator_id=? AND sender_is_creator=0
          AND created_at>=?
        GROUP BY post_id
        ORDER BY message_shares DESC, latest_share_at DESC
        """,
        (room_id, device_id, since),
    )

    posts = []
    for row in post_rows:
        data = dict(row)
        post_id = str(data.get("post_id") or "")
        caption, created_at = _creator_share_caption_v249408(room_id, post_id)
        post_unique = int(data.get("unique_sharers") or 0)
        posts.append({
            "post_id": post_id,
            "caption": caption,
            "created_at": float(created_at or 0),
            "message_shares": int(data.get("message_shares") or 0),
            "unique_sharers": post_unique if post_unique >= privacy_minimum else None,
            "unique_sharer_range": None if post_unique >= privacy_minimum else "fewer_than_3",
            "first_share_at": float(data.get("first_share_at") or 0),
            "latest_share_at": float(data.get("latest_share_at") or 0),
        })

    return {
        "ok": True,
        "version": "V249408-private-message-share-analytics",
        "active_creator": True,
        "days": int(days),
        "timezone_offset_minutes": int(tz_offset_minutes),
        "rules": rules,
        "summary": {
            "message_shares": total_shares,
            "unique_sharers": unique_sharers_raw if unique_sharers_raw >= privacy_minimum else None,
            "unique_sharer_range": None if unique_sharers_raw >= privacy_minimum else "fewer_than_3",
            "shared_videos": int(agg.get("shared_videos") or 0),
        },
        "timeline": timeline,
        "posts": posts,
        "privacy": {
            "recipient_identity_stored": False,
            "sender_names_visible": False,
            "sender_hashes_visible": False,
            "small_unique_counts_suppressed": True,
        },
    }

@app.get("/api/creator/shares/diagnostics_v249408")
def creator_shares_diagnostics_v249408():
    _creator_shares_init_v249408()

    def _count(sql):
        try:
            row = one(sql)
            return int(row["n"] if row else 0)
        except Exception:
            return 0

    return {
        "ok": True,
        "version": "V249408-private-message-share-analytics",
        "stored_share_events": _count("SELECT COUNT(*) n FROM creator_video_shares_v249408"),
        "counted_non_creator_shares": _count(
            "SELECT COUNT(*) n FROM creator_video_shares_v249408 WHERE sender_is_creator=0"
        ),
        "creator_self_shares_excluded": True,
        "recipient_identity_stored": False,
        "sender_device_ids_stored": False,
        "sender_names_exposed": False,
        "sender_hashes_exposed": False,
        "historical_backfill": False,
        "database_content_rows_modified": False,
        "analytics_table_only": True,
    }

_CREATOR_SHARES_HTML_V249408 = r"""<!doctype html>
<html lang="el">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
  <meta name="theme-color" content="#07101a">
  <title>ΙΘΑΚΗ · Εξάπλωση μέσω μηνυμάτων</title>
  <style>
    :root{color-scheme:dark;font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
    *{box-sizing:border-box}
    body{margin:0;min-height:100vh;color:#eef7ff;background:
      radial-gradient(circle at 10% 0,rgba(231,184,81,.18),transparent 32%),
      radial-gradient(circle at 92% 14%,rgba(74,194,224,.12),transparent 30%),
      linear-gradient(150deg,#050912,#0d1522 62%,#04070c)}
    button,a{font:inherit}
    .shell{width:min(1120px,100%);margin:auto;padding:14px;display:grid;gap:12px}
    .top{position:sticky;top:0;z-index:8;display:grid;grid-template-columns:auto 1fr auto;
      gap:8px;align-items:center;padding:8px;border-radius:18px;background:rgba(4,9,16,.94);
      border:1px solid rgba(231,184,81,.23);backdrop-filter:blur(15px)}
    .back{width:44px;height:44px;display:grid;place-items:center;border-radius:50%;
      border:1px solid rgba(255,255,255,.13);background:rgba(255,255,255,.07);
      color:#fff;text-decoration:none}
    h1{margin:0;text-align:center;color:#f2d596;font-size:20px}
    .privacy{padding:13px 15px;border-radius:20px;border:1px solid rgba(85,215,255,.18);
      background:rgba(85,215,255,.055);color:#c4d4dd;font-size:12px;line-height:1.58}
    .privacy b{color:#eefaff}
    .filters{display:flex;gap:8px;flex-wrap:wrap}
    .filters button{min-height:42px;padding:9px 14px;border-radius:999px;
      border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.055);
      color:#dce8f0;cursor:pointer}
    .filters button.active{border-color:rgba(234,188,91,.72);color:#211508;
      background:linear-gradient(135deg,#a66f1f,#e0bd69);font-weight:900}
    .metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:9px}
    .metric{padding:15px 10px;text-align:center;border-radius:20px;
      border:1px solid rgba(255,255,255,.11);background:rgba(255,255,255,.045)}
    .metric b{display:block;color:#f1d28d;font-size:25px}
    .metric span{display:block;color:#b9c7d1;font-size:11px;margin-top:5px}
    .chart{padding:14px;border-radius:22px;border:1px solid rgba(255,255,255,.11);
      background:rgba(255,255,255,.045)}
    .chart h2{margin:0 0 5px;color:#f2d596;font-size:16px}
    .chart p{margin:0 0 12px;color:#9fb0bc;font-size:11px;line-height:1.5}
    .bars{height:230px;display:grid;grid-template-columns:repeat(14,minmax(0,1fr));gap:5px;align-items:end}
    .col{height:100%;display:flex;flex-direction:column;justify-content:flex-end;gap:5px;min-width:0}
    .value{text-align:center;color:#b7c6cf;font-size:9px;min-height:12px}
    .bar{width:100%;min-height:2px;border-radius:8px 8px 3px 3px;
      background:linear-gradient(180deg,#76d1e8,#ad7a28);box-shadow:0 4px 10px rgba(0,0,0,.22)}
    .label{text-align:center;color:#8497a3;font-size:8px;white-space:nowrap;overflow:hidden}
    .list{display:grid;gap:10px}
    .empty{padding:25px;text-align:center;border-radius:22px;
      border:1px dashed rgba(255,255,255,.16);color:#b9c6cf;line-height:1.55}
    .item{display:grid;gap:11px;padding:14px;border-radius:22px;
      border:1px solid rgba(255,255,255,.11);background:rgba(255,255,255,.045)}
    .head{display:grid;grid-template-columns:48px 1fr auto;gap:10px;align-items:center}
    .rank{width:44px;height:44px;display:grid;place-items:center;border-radius:15px;
      background:rgba(234,188,91,.13);color:#f2d596;font-weight:900}
    .title{min-width:0}.title b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
    .title small{display:block;color:#aebdc8;margin-top:5px}
    .open{padding:9px 12px;border-radius:14px;text-decoration:none;
      border:1px solid rgba(54,240,160,.24);background:rgba(54,240,160,.08);color:#d8f7e8}
    .numbers{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
    .num{padding:10px;text-align:center;border-radius:15px;background:rgba(255,255,255,.05)}
    .num b{display:block;color:#eef7ff}.num span{font-size:10px;color:#aebdc8}
    .track{height:8px;border-radius:999px;background:rgba(255,255,255,.07);overflow:hidden}
    .fill{height:100%;border-radius:999px;background:linear-gradient(90deg,#55bcd8,#dfbd67)}
    .note{color:#9fb0bc;font-size:11px;line-height:1.5}
    .profileLink{display:inline-block;margin-top:12px;padding:10px 14px;border-radius:14px;
      color:#f3d99b;border:1px solid rgba(234,188,91,.25);background:rgba(234,188,91,.07);
      text-decoration:none}
    @media(max-width:720px){.metrics{grid-template-columns:1fr}.head{grid-template-columns:44px 1fr}
      .open{grid-column:1/-1;text-align:center}.numbers{grid-template-columns:1fr}.bars{gap:3px}.label{font-size:7px}}
  </style>
</head>
<body>
<main class="shell">
  <div class="top">
    <a class="back" href="/?v=249408_private_message_shares">‹</a>
    <h1>🔗 Εξάπλωση μέσω μηνυμάτων</h1>
    <a class="back" href="/?v=249408_private_message_shares">×</a>
  </div>

  <div class="privacy">
    <b>Μετράμε την πράξη, όχι τους ανθρώπους.</b>
    Ο δημιουργός βλέπει πόσες φορές ένα δικό του βίντεο μοιράστηκε επιτυχώς μέσα στα Μηνύματα.
    Δεν αποθηκεύεται ο παραλήπτης, δεν εμφανίζεται ο αποστολέας και οι κοινοποιήσεις του ίδιου
    του δημιουργού εξαιρούνται. Οι μοναδικοί αποστολείς εμφανίζονται μόνο όταν είναι τουλάχιστον 3.
  </div>

  <div class="filters">
    <button data-days="7">7 ημέρες</button>
    <button data-days="30" class="active">30 ημέρες</button>
    <button data-days="90">90 ημέρες</button>
  </div>

  <section class="metrics">
    <div class="metric"><b id="shares">–</b><span>Επιτυχείς κοινοποιήσεις</span></div>
    <div class="metric"><b id="sharers">–</b><span>Μοναδικοί αποστολείς</span></div>
    <div class="metric"><b id="videos">–</b><span>Βίντεο που μοιράστηκαν</span></div>
  </section>

  <section class="chart">
    <h2>Πορεία κοινοποιήσεων</h2>
    <p>Πραγματικές επιτυχείς αποστολές βίντεο μέσα στα Μηνύματα, χωρίς στοιχεία αποστολέα ή παραλήπτη.</p>
    <div id="bars" class="bars"></div>
  </section>

  <section id="list" class="list"><div class="empty">Φόρτωση πραγματικών κοινοποιήσεων…</div></section>
</main>

<script>
(function(){
  "use strict";
  function esc(v){return String(v==null?"":v).replace(/[&<>"']/g,function(c){return {"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]})}
  function dev(){try{return String(localStorage.getItem("ith_device")||localStorage.getItem("device_id")||"")}catch(e){return ""}}
  function room(){try{return String(localStorage.getItem("ith_room")||"ithaki-phone-test")}catch(e){return "ithaki-phone-test"}}
  function date(ts){try{var n=Number(ts||0);if(!n)return "";var d=new Date(n>1e12?n:n*1000);return d.toLocaleDateString("el-GR",{day:"2-digit",month:"2-digit",year:"numeric"})+" · "+d.toLocaleTimeString("el-GR",{hour:"2-digit",minute:"2-digit",hour12:false})}catch(e){return ""}}
  function shortDay(v){try{return new Date(String(v)+"T00:00:00").toLocaleDateString("el-GR",{day:"2-digit",month:"2-digit"})}catch(e){return String(v||"")}}
  function renderBars(timeline){
    var sample=timeline||[];
    if(sample.length>14){
      var size=Math.ceil(sample.length/14),out=[];
      for(var i=0;i<sample.length;i+=size){
        var part=sample.slice(i,i+size);
        out.push({day:part[part.length-1].day,message_shares:part.reduce(function(a,x){return a+Number(x.message_shares||0)},0)});
      }
      sample=out;
    }
    var max=Math.max.apply(null,[1].concat(sample.map(function(x){return Number(x.message_shares||0)})));
    document.getElementById("bars").innerHTML=sample.map(function(x){
      var value=Number(x.message_shares||0),height=Math.max(2,Math.round(value*100/max));
      return '<div class="col"><div class="value">'+value+'</div><div class="bar" style="height:'+height+'%" title="'+esc(shortDay(x.day)+': '+value)+'"></div><div class="label">'+esc(shortDay(x.day))+'</div></div>';
    }).join("");
  }
  async function load(days){
    document.querySelectorAll("[data-days]").forEach(function(b){b.classList.toggle("active",Number(b.dataset.days)===Number(days))});
    var list=document.getElementById("list"),device=dev();
    list.innerHTML='<div class="empty">Φόρτωση πραγματικών κοινοποιήσεων…</div>';
    if(device.length<3){
      document.getElementById("shares").textContent="0";document.getElementById("sharers").textContent="–";document.getElementById("videos").textContent="0";
      renderBars([]);
      list.innerHTML='<div class="empty">Δεν βρέθηκε η συσκευή του λογαριασμού. Άνοιξε πρώτα το Προφίλ.<br><a class="profileLink" href="/?v=249408_private_message_shares">Επιστροφή</a></div>';
      return;
    }
    try{
      var url='/api/creator/shares/summary_v249408?room_id='+encodeURIComponent(room())+'&device_id='+encodeURIComponent(device)+'&days='+days+'&tz_offset_minutes='+encodeURIComponent(String(-new Date().getTimezoneOffset()));
      var r=await fetch(url,{cache:'no-store',credentials:'same-origin'}),data=await r.json();
      if(!r.ok||!data||data.ok===false)throw new Error((data&&(data.detail||data.message))||('HTTP '+r.status));
      var s=data.summary||{};
      document.getElementById("shares").textContent=String(s.message_shares||0);
      document.getElementById("sharers").textContent=s.unique_sharers==null?"<3":String(s.unique_sharers);
      document.getElementById("videos").textContent=String(s.shared_videos||0);
      renderBars(Array.isArray(data.timeline)?data.timeline:[]);
      if(!data.active_creator){
        list.innerHTML='<div class="empty"><b>Ο λογαριασμός δεν έχει αποθηκευτεί ως Δημιουργός / Επαγγελματικός.</b><br><br>Επίλεξε τον επαγγελματικό λογαριασμό, βάλε ιδιωτικό κωδικό και πάτησε Αποθήκευση.<br><a class="profileLink" href="/?v=249408_private_message_shares">Επιστροφή στο Προφίλ</a></div>';
        return;
      }
      var posts=Array.isArray(data.posts)?data.posts:[];
      if(!posts.length){
        list.innerHTML='<div class="empty"><b>Η καταγραφή είναι ενεργή.</b><br><br>Δεν υπάρχουν ακόμη επιτυχείς κοινοποιήσεις βίντεο μέσα στα Μηνύματα. Τα δεδομένα ξεκινούν από τη V408 και δεν κατασκευάζονται αναδρομικά.</div>';
        return;
      }
      var max=Math.max.apply(null,[1].concat(posts.map(function(p){return Number(p.message_shares||0)})));
      list.innerHTML=posts.map(function(p,i){
        var width=Math.max(3,Math.round(Number(p.message_shares||0)*100/max));
        var unique=p.unique_sharers==null?"<3":String(p.unique_sharers);
        return '<article class="item"><div class="head"><div class="rank">#'+(i+1)+'</div><div class="title"><b>'+esc(p.caption||"Βίντεο")+'</b><small>Τελευταία κοινοποίηση: '+esc(date(p.latest_share_at))+'</small></div><a class="open" href="/?v=249408_private_message_shares&open_post='+encodeURIComponent(String(p.post_id||''))+'">Άνοιγμα στη Ροή</a></div>'+
          '<div class="numbers"><div class="num"><b>'+Number(p.message_shares||0)+'</b><span>Κοινοποιήσεις</span></div><div class="num"><b>'+unique+'</b><span>Μοναδικοί αποστολείς</span></div><div class="num"><b>'+esc(date(p.first_share_at))+'</b><span>Πρώτη κοινοποίηση</span></div></div>'+
          '<div class="track"><div class="fill" style="width:'+width+'%"></div></div>'+
          '<div class="note">Δεν εμφανίζεται ποιος έστειλε το βίντεο ούτε σε ποιον. Η μέτρηση δείχνει μόνο την πραγματική εξάπλωση μέσω Μηνυμάτων.</div></article>';
      }).join("");
    }catch(e){
      renderBars([]);
      list.innerHTML='<div class="empty">Οι κοινοποιήσεις δεν φόρτωσαν: '+esc(e&&e.message||e)+'</div>';
    }
  }
  document.querySelectorAll("[data-days]").forEach(function(b){b.onclick=function(){load(Number(b.dataset.days||30))}});
  load(30);
})();
</script>
</body>
</html>"""

@app.get("/creator-message-shares-v249408", response_class=_HTMLResponse_v249408)
def creator_message_shares_page_v249408():
    return _HTMLResponse_v249408(
        content=_CREATOR_SHARES_HTML_V249408,
        headers={"Cache-Control": "no-store, max-age=0"},
    )

# V249409_TRANSPARENT_BEST_POSTING_TIME
from collections import defaultdict as _defaultdict_v249409
from datetime import datetime as _datetime_v249409, timedelta as _timedelta_v249409, timezone as _timezone_v249409
from fastapi.responses import HTMLResponse as _HTMLResponse_v249409
import time as _time_v249409

_WEEKDAYS_EL_V249409 = [
    "Δευτέρα", "Τρίτη", "Τετάρτη", "Πέμπτη",
    "Παρασκευή", "Σάββατο", "Κυριακή"
]

def _creator_best_time_table_exists_v249409(name):
    try:
        row = one(
            "SELECT name FROM sqlite_master WHERE type='table' AND name=? LIMIT 1",
            (str(name),),
        )
        return bool(row)
    except Exception:
        return False

def _creator_best_time_local_v249409(timestamp, offset_minutes):
    return (
        _datetime_v249409.fromtimestamp(float(timestamp), _timezone_v249409.utc)
        + _timedelta_v249409(minutes=int(offset_minutes))
    )

def _creator_best_time_attentive_v249409(item):
    milestone_mask = int(item.get("milestone_mask") or 0)
    completion_count = int(item.get("completion_count") or 0)
    active_watch_ms = max(0, int(item.get("active_watch_ms") or 0))
    duration_ms = max(0, int(item.get("duration_ms") or 0))
    max_position_ms = max(0, int(item.get("max_position_ms") or 0))

    if completion_count > 0 or (milestone_mask & 4):
        return True
    if duration_ms > 0 and (
        max_position_ms >= int(duration_ms * 0.75)
        or active_watch_ms >= int(duration_ms * 0.70)
    ):
        return True
    return False

def _creator_best_time_confidence_v249409(presence, unique_viewers, active_dates, weeks):
    presence = int(presence or 0)
    unique_viewers = int(unique_viewers or 0)
    active_dates = int(active_dates or 0)
    weeks = max(1, int(weeks or 1))

    if presence < 6 or unique_viewers < 3 or active_dates < 2:
        return "insufficient"
    if presence >= 80 and unique_viewers >= 20 and active_dates >= min(12, weeks * 2):
        return "strong"
    if presence >= 30 and unique_viewers >= 8 and active_dates >= min(6, weeks):
        return "medium"
    return "early"

def _creator_best_time_next_occurrence_v249409(weekday_index, start_hour, offset_minutes):
    now_local = (
        _datetime_v249409.now(_timezone_v249409.utc)
        + _timedelta_v249409(minutes=int(offset_minutes))
    )
    days_ahead = (int(weekday_index) - now_local.weekday()) % 7
    target_date = now_local.date() + _timedelta_v249409(days=days_ahead)
    target = _datetime_v249409.combine(
        target_date,
        _datetime_v249409.min.time(),
    ).replace(hour=int(start_hour), minute=0, second=0, microsecond=0)
    if target <= now_local.replace(tzinfo=None):
        target += _timedelta_v249409(days=7)
    return target.isoformat(timespec="minutes")

@app.get("/api/creator/best-time_v249409")
def creator_best_time_v249409(
    room_id: str = Query("ithaki-phone-test"),
    device_id: str = Query(...),
    weeks: int = Query(8, ge=2, le=16),
    tz_offset_minutes: int = Query(0, ge=-840, le=840),
):
    _creator_views_init_v249405()

    room_id = str(room_id or "ithaki-phone-test").strip()[:120]
    device_id = str(device_id or "").strip()[:180]
    weeks = int(weeks)
    tz_offset_minutes = int(tz_offset_minutes)

    if len(device_id) < 3:
        raise HTTPException(400, "device_id_required")

    formula = {
        "audience_presence": "1 point for one unique audience member in the same local date and two-hour window",
        "attentive_viewing": "plus 1 point when that audience presence reached at least 75 percent or completed a video",
        "message_share": "plus 2 points for one unique non-creator sharer in the same local date and two-hour window",
        "rewatch_inflation": "extra repeated plays do not add extra best-time points",
        "platform_comparison": "none; only this creator's own audience is used",
        "slot_size_hours": 2,
    }
    minimum_evidence = {
        "presence_events": 6,
        "unique_audience_members": 3,
        "active_local_dates": 2,
    }

    if not _creator_active_v249405(room_id, device_id):
        return {
            "ok": True,
            "version": "V249409-transparent-best-posting-time",
            "active_creator": False,
            "weeks": weeks,
            "timezone_offset_minutes": tz_offset_minutes,
            "formula": formula,
            "minimum_evidence": minimum_evidence,
            "summary": {
                "valid_view_sessions": 0,
                "audience_presence_events": 0,
                "attentive_events": 0,
                "message_share_events": 0,
                "unique_audience": None,
                "active_local_dates": 0,
            },
            "recommendation": {
                "available": False,
                "reason": "creator_account_not_active",
            },
            "top_slots": [],
            "heatmap": [],
            "message": "creator_account_not_active",
        }

    since = float(_time_v249409.time() - weeks * 7 * 86400)

    session_rows = rows(
        """
        SELECT
            viewer_hash,post_id,started_at,milestone_mask,completion_count,
            active_watch_ms,duration_ms,max_position_ms
        FROM creator_video_sessions_v249405
        WHERE room_id=? AND creator_id=? AND is_owner=0
          AND valid_view=1 AND started_at>=?
        ORDER BY started_at ASC
        """,
        (room_id, device_id, since),
    )

    slots = _defaultdict_v249409(lambda: {
        "presence_keys": set(),
        "attentive_keys": set(),
        "viewer_hashes": set(),
        "active_dates": set(),
        "share_keys": set(),
        "share_sender_hashes": set(),
    })
    global_viewers = set()
    global_dates = set()

    for row in session_rows:
        item = dict(row)
        timestamp = float(item.get("started_at") or 0)
        viewer_hash = str(item.get("viewer_hash") or "")
        if timestamp <= 0 or not viewer_hash:
            continue

        local_dt = _creator_best_time_local_v249409(timestamp, tz_offset_minutes)
        weekday = int(local_dt.weekday())
        start_hour = int((local_dt.hour // 2) * 2)
        date_key = local_dt.date().isoformat()
        slot_key = (weekday, start_hour)
        presence_key = (date_key, viewer_hash)

        bucket = slots[slot_key]
        bucket["presence_keys"].add(presence_key)
        bucket["viewer_hashes"].add(viewer_hash)
        bucket["active_dates"].add(date_key)
        global_viewers.add(viewer_hash)
        global_dates.add(date_key)

        if _creator_best_time_attentive_v249409(item):
            bucket["attentive_keys"].add(presence_key)

    shares_available = _creator_best_time_table_exists_v249409(
        "creator_video_shares_v249408"
    )
    share_rows = []
    if shares_available:
        try:
            share_rows = rows(
                """
                SELECT sender_hash,created_at
                FROM creator_video_shares_v249408
                WHERE room_id=? AND creator_id=? AND sender_is_creator=0
                  AND created_at>=?
                ORDER BY created_at ASC
                """,
                (room_id, device_id, since),
            )
        except Exception:
            share_rows = []

    for row in share_rows:
        item = dict(row)
        timestamp = float(item.get("created_at") or 0)
        sender_hash = str(item.get("sender_hash") or "")
        if timestamp <= 0 or not sender_hash:
            continue

        local_dt = _creator_best_time_local_v249409(timestamp, tz_offset_minutes)
        weekday = int(local_dt.weekday())
        start_hour = int((local_dt.hour // 2) * 2)
        date_key = local_dt.date().isoformat()
        slot_key = (weekday, start_hour)
        share_key = (date_key, sender_hash)

        bucket = slots[slot_key]
        bucket["share_keys"].add(share_key)
        bucket["share_sender_hashes"].add(sender_hash)
        bucket["active_dates"].add(date_key)
        global_dates.add(date_key)

    heatmap = []
    candidates = []

    for weekday in range(7):
        for start_hour in range(0, 24, 2):
            bucket = slots[(weekday, start_hour)]
            presence = len(bucket["presence_keys"])
            attentive = len(bucket["attentive_keys"])
            shares = len(bucket["share_keys"])
            unique_viewers = len(bucket["viewer_hashes"])
            active_dates = len(bucket["active_dates"])
            score = presence + attentive + (shares * 2)
            confidence = _creator_best_time_confidence_v249409(
                presence, unique_viewers, active_dates, weeks
            )
            cell = {
                "weekday_index": weekday,
                "weekday": _WEEKDAYS_EL_V249409[weekday],
                "start_hour": start_hour,
                "end_hour": start_hour + 2,
                "label": (
                    f"{_WEEKDAYS_EL_V249409[weekday]} "
                    f"{start_hour:02d}:00–{start_hour + 2:02d}:00"
                ),
                "score": int(score),
                "audience_presence": int(presence),
                "attentive_viewing": int(attentive),
                "message_shares": int(shares),
                "unique_audience": (
                    int(unique_viewers) if unique_viewers >= 3 else None
                ),
                "unique_audience_range": (
                    None if unique_viewers >= 3 else "fewer_than_3"
                ),
                "active_local_dates": int(active_dates),
                "confidence": confidence,
            }
            heatmap.append(cell)
            if confidence != "insufficient":
                candidates.append(cell)

    candidates.sort(
        key=lambda item: (
            -int(item["score"]),
            -int(item["active_local_dates"]),
            -int(item["audience_presence"]),
            -int(item["attentive_viewing"]),
            int(item["weekday_index"]),
            int(item["start_hour"]),
        )
    )

    total_presence = sum(
        len(bucket["presence_keys"]) for bucket in slots.values()
    )
    total_attentive = sum(
        len(bucket["attentive_keys"]) for bucket in slots.values()
    )
    total_shares = sum(
        len(bucket["share_keys"]) for bucket in slots.values()
    )

    if candidates:
        best = dict(candidates[0])
        best["available"] = True
        best["next_local_iso"] = _creator_best_time_next_occurrence_v249409(
            best["weekday_index"],
            best["start_hour"],
            tz_offset_minutes,
        )
        best["explanation"] = (
            f"{best['audience_presence']} παρουσίες κοινού + "
            f"{best['attentive_viewing']} προσεκτικές θεάσεις + "
            f"{best['message_shares']} κοινοποιήσεις ×2 = "
            f"{best['score']} βαθμοί."
        )
        recommendation = best
    else:
        recommendation = {
            "available": False,
            "reason": "not_enough_own_audience_data",
            "explanation": (
                "Χρειάζονται τουλάχιστον 6 παρουσίες κοινού, "
                "3 διαφορετικά μέλη και 2 διαφορετικές τοπικές ημερομηνίες "
                "στο ίδιο δίωρο."
            ),
        }

    return {
        "ok": True,
        "version": "V249409-transparent-best-posting-time",
        "active_creator": True,
        "weeks": weeks,
        "timezone_offset_minutes": tz_offset_minutes,
        "formula": formula,
        "minimum_evidence": minimum_evidence,
        "summary": {
            "valid_view_sessions": len(session_rows),
            "audience_presence_events": int(total_presence),
            "attentive_events": int(total_attentive),
            "message_share_events": int(total_shares),
            "unique_audience": (
                len(global_viewers) if len(global_viewers) >= 3 else None
            ),
            "unique_audience_range": (
                None if len(global_viewers) >= 3 else "fewer_than_3"
            ),
            "active_local_dates": len(global_dates),
            "shares_source_available": bool(shares_available),
        },
        "recommendation": recommendation,
        "top_slots": candidates[:5],
        "heatmap": heatmap,
        "limitations": [
            "The recommendation reflects when this creator's own audience actually viewed or shared the creator's videos.",
            "It does not prove that untested hours are bad.",
            "It does not compare this creator with other creators.",
            "Repeated rewatching does not inflate the best-time score.",
        ],
    }

@app.get("/api/creator/best-time/diagnostics_v249409")
def creator_best_time_diagnostics_v249409():
    sessions = 0
    try:
        row = one(
            "SELECT COUNT(*) n FROM creator_video_sessions_v249405 "
            "WHERE valid_view=1 AND is_owner=0"
        )
        sessions = int(row["n"] if row else 0)
    except Exception:
        sessions = 0

    shares = 0
    shares_available = _creator_best_time_table_exists_v249409(
        "creator_video_shares_v249408"
    )
    if shares_available:
        try:
            row = one(
                "SELECT COUNT(*) n FROM creator_video_shares_v249408 "
                "WHERE sender_is_creator=0"
            )
            shares = int(row["n"] if row else 0)
        except Exception:
            shares = 0

    return {
        "ok": True,
        "version": "V249409-transparent-best-posting-time",
        "valid_non_owner_view_sessions": sessions,
        "message_share_events_available": shares,
        "share_table_available": shares_available,
        "slot_hours": 2,
        "uses_only_creator_own_audience": True,
        "compulsive_rewatch_inflates_score": False,
        "compares_against_other_creators": False,
        "secret_ai_model": False,
        "database_rows_modified": False,
    }

_CREATOR_BEST_TIME_HTML_V249409 = r"""<!doctype html>
<html lang="el">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
  <meta name="theme-color" content="#07101a">
  <title>ΙΘΑΚΗ · Καλύτερη ώρα</title>
  <style>
    :root{color-scheme:dark;font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
    *{box-sizing:border-box}
    body{margin:0;min-height:100vh;color:#eef7ff;background:
      radial-gradient(circle at 10% 0,rgba(231,184,81,.18),transparent 32%),
      radial-gradient(circle at 92% 14%,rgba(79,194,225,.12),transparent 30%),
      linear-gradient(150deg,#050912,#0d1522 62%,#04070c)}
    button,a{font:inherit}
    .shell{width:min(1180px,100%);margin:auto;padding:14px;display:grid;gap:12px}
    .top{position:sticky;top:0;z-index:8;display:grid;grid-template-columns:auto 1fr auto;
      gap:8px;align-items:center;padding:8px;border-radius:18px;background:rgba(4,9,16,.94);
      border:1px solid rgba(231,184,81,.23);backdrop-filter:blur(15px)}
    .back{width:44px;height:44px;display:grid;place-items:center;border-radius:50%;
      border:1px solid rgba(255,255,255,.13);background:rgba(255,255,255,.07);
      color:#fff;text-decoration:none}
    h1{margin:0;text-align:center;color:#f2d596;font-size:20px}
    .logic{padding:14px 16px;border-radius:21px;border:1px solid rgba(85,215,255,.18);
      background:rgba(85,215,255,.055);color:#c4d4dd;font-size:12px;line-height:1.6}
    .logic b{color:#eefaff}
    .filters{display:flex;gap:8px;flex-wrap:wrap}
    .filters button{min-height:42px;padding:9px 14px;border-radius:999px;
      border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.055);
      color:#dce8f0;cursor:pointer}
    .filters button.active{border-color:rgba(234,188,91,.72);color:#211508;
      background:linear-gradient(135deg,#a66f1f,#e0bd69);font-weight:900}
    .hero{display:grid;grid-template-columns:1.25fr .75fr;gap:10px}
    .recommend{padding:19px;border-radius:24px;border:1px solid rgba(234,188,91,.25);
      background:linear-gradient(145deg,rgba(234,188,91,.105),rgba(81,192,221,.055))}
    .eyebrow{color:#b8c8d2;font-size:11px;text-transform:uppercase;letter-spacing:.08em}
    .recommend h2{margin:8px 0 4px;color:#f2d596;font-size:30px;line-height:1.2}
    .recommend p{margin:0;color:#c6d3dc;line-height:1.55;font-size:12px}
    .confidence{display:inline-block;margin-top:12px;padding:7px 10px;border-radius:999px;
      border:1px solid rgba(255,255,255,.13);background:rgba(255,255,255,.06);
      color:#dce8ef;font-size:11px;font-weight:800}
    .evidence{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
    .metric{padding:14px 9px;text-align:center;border-radius:19px;
      border:1px solid rgba(255,255,255,.11);background:rgba(255,255,255,.045)}
    .metric b{display:block;color:#f1d28d;font-size:22px}.metric span{font-size:10px;color:#aebdc8}
    .heatCard,.topCard{padding:14px;border-radius:22px;border:1px solid rgba(255,255,255,.11);
      background:rgba(255,255,255,.045)}
    .heatCard h2,.topCard h2{margin:0 0 5px;color:#f2d596;font-size:16px}
    .heatCard p,.topCard p{margin:0 0 12px;color:#9fb0bc;font-size:11px;line-height:1.5}
    .heatWrap{overflow:auto}
    .heat{display:grid;grid-template-columns:92px repeat(12,minmax(48px,1fr));gap:4px;min-width:760px}
    .headCell,.dayCell,.cell{min-height:42px;border-radius:10px;display:grid;place-items:center;font-size:9px}
    .headCell{color:#8fa2ae}.dayCell{justify-items:start;padding-left:8px;color:#d5e0e7;font-size:10px}
    .cell{border:1px solid rgba(255,255,255,.06);color:#dce7ed;position:relative;cursor:default}
    .cell small{display:block;font-size:8px;color:#d5e2e8}.cell span{font-size:9px;font-weight:900}
    .topList{display:grid;gap:8px}
    .topRow{display:grid;grid-template-columns:34px minmax(0,1fr) 62px;gap:8px;align-items:center;
      padding:10px;border-radius:15px;background:rgba(255,255,255,.04)}
    .rank{width:32px;height:32px;border-radius:11px;display:grid;place-items:center;
      background:rgba(234,188,91,.12);color:#f2d596;font-weight:900}
    .topMain{min-width:0}.topMain b{display:block;font-size:12px}.topMain small{color:#9fb0bc;font-size:10px}
    .score{text-align:right;color:#f2d596;font-weight:900}
    .warning{padding:14px;border-radius:18px;border:1px dashed rgba(255,255,255,.15);
      color:#b5c4cd;font-size:11px;line-height:1.55}
    .profileLink{display:inline-block;margin-top:12px;padding:10px 14px;border-radius:14px;
      color:#f3d99b;border:1px solid rgba(234,188,91,.25);background:rgba(234,188,91,.07);
      text-decoration:none}
    @media(max-width:860px){.hero{grid-template-columns:1fr}.recommend h2{font-size:25px}}
  </style>
</head>
<body>
<main class="shell">
  <div class="top">
    <a class="back" href="/?v=249409_transparent_best_time">‹</a>
    <h1>🕰️ Καλύτερη ώρα — χωρίς μαύρο κουτί</h1>
    <a class="back" href="/?v=249409_transparent_best_time">×</a>
  </div>

  <div class="logic">
    <b>Απλή και ορατή λογική:</b>
    1 βαθμός για μία μοναδική παρουσία μέλους στο ίδιο τοπικό δίωρο και ημερομηνία,
    +1 όταν η θέαση έφτασε τουλάχιστον στο 75% ή ολοκληρώθηκε,
    +2 για μία μοναδική κοινοποίηση μέσω Μηνυμάτων.
    Οι συνεχόμενες επαναλήψεις δεν αυξάνουν τη σύσταση.
    Δεν χρησιμοποιούνται δεδομένα άλλων δημιουργών και δεν υπάρχει κρυφό μοντέλο AI.
  </div>

  <div class="filters">
    <button data-weeks="4">4 εβδομάδες</button>
    <button data-weeks="8" class="active">8 εβδομάδες</button>
    <button data-weeks="12">12 εβδομάδες</button>
  </div>

  <section class="hero">
    <article class="recommend">
      <div class="eyebrow">Πρόταση από το δικό σου κοινό</div>
      <h2 id="bestLabel">Φόρτωση…</h2>
      <p id="bestExplanation">Ανάλυση πραγματικών θεάσεων και κοινοποιήσεων.</p>
      <div id="confidence" class="confidence">Έλεγχος αξιοπιστίας</div>
    </article>
    <section class="evidence">
      <div class="metric"><b id="presence">–</b><span>Παρουσίες κοινού</span></div>
      <div class="metric"><b id="attentive">–</b><span>Προσεκτικές θεάσεις</span></div>
      <div class="metric"><b id="shares">–</b><span>Κοινοποιήσεις</span></div>
      <div class="metric"><b id="dates">–</b><span>Ενεργές ημερομηνίες</span></div>
    </section>
  </section>

  <section class="heatCard">
    <h2>Χάρτης δραστηριότητας του δικού σου κοινού</h2>
    <p>Κάθε τετράγωνο είναι δίωρο. Η ένταση βασίζεται στον ίδιο ακριβώς, εμφανή τύπο βαθμολογίας.</p>
    <div class="heatWrap"><div id="heat" class="heat"></div></div>
  </section>

  <section class="topCard">
    <h2>Οι ισχυρότερες ώρες</h2>
    <p>Εμφανίζονται μόνο δίωρα με τουλάχιστον 6 παρουσίες, 3 διαφορετικά μέλη και 2 διαφορετικές ημερομηνίες.</p>
    <div id="topList" class="topList"></div>
  </section>

  <div class="warning">
    Η πρόταση δείχνει πότε το κοινό σου <b>πραγματικά</b> παρακολούθησε ή μοιράστηκε τα δικά σου βίντεο.
    Δεν αποδεικνύει ότι ώρες στις οποίες δεν έχεις ακόμη δοκιμάσει δημοσίευση είναι κακές.
  </div>
</main>

<script>
(function(){
  "use strict";

  function esc(v){
    return String(v==null?"":v).replace(/[&<>"']/g,function(c){
      return {"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c];
    });
  }
  function dev(){
    try{return String(localStorage.getItem("ith_device")||localStorage.getItem("device_id")||"")}
    catch(e){return ""}
  }
  function room(){
    try{return String(localStorage.getItem("ith_room")||"ithaki-phone-test")}
    catch(e){return "ithaki-phone-test"}
  }
  function confidenceText(value){
    return {
      strong:"Ισχυρή ένδειξη",
      medium:"Μέτρια ένδειξη",
      early:"Πρώιμη ένδειξη",
      insufficient:"Δεν υπάρχουν αρκετά στοιχεία"
    }[value]||"Έλεγχος αξιοπιστίας";
  }
  function color(score,max){
    var ratio=max>0?Math.max(0,Math.min(1,Number(score||0)/max)):0;
    var alpha=(0.04+ratio*0.54).toFixed(3);
    return "rgba(226,190,104,"+alpha+")";
  }
  function renderHeatmap(cells){
    var host=document.getElementById("heat");
    var max=Math.max.apply(null,[1].concat(cells.map(function(x){return Number(x.score||0)})));
    var html='<div class="headCell"></div>';
    for(var h=0;h<24;h+=2){
      html+='<div class="headCell">'+String(h).padStart(2,"0")+'–'+String(h+2).padStart(2,"0")+'</div>';
    }
    for(var d=0;d<7;d++){
      var dayCells=cells.filter(function(x){return Number(x.weekday_index)===d}).sort(function(a,b){return Number(a.start_hour)-Number(b.start_hour)});
      html+='<div class="dayCell">'+esc(dayCells[0]?dayCells[0].weekday:"")+'</div>';
      dayCells.forEach(function(x){
        var title=x.label+" · "+x.score+" βαθμοί · "+x.audience_presence+" παρουσίες · "+x.attentive_viewing+" προσεκτικές · "+x.message_shares+" κοινοποιήσεις";
        html+='<div class="cell" style="background:'+color(x.score,max)+'" title="'+esc(title)+'"><span>'+Number(x.score||0)+'</span><small>'+Number(x.active_local_dates||0)+' ημ.</small></div>';
      });
    }
    host.innerHTML=html;
  }
  function renderTop(slots){
    var host=document.getElementById("topList");
    if(!slots.length){
      host.innerHTML='<div class="warning">Δεν υπάρχει ακόμη δίωρο με αρκετά στοιχεία. Η ΙΘΑΚΗ δεν θα κατασκευάσει σύσταση χωρίς πραγματική βάση.</div>';
      return;
    }
    host.innerHTML=slots.map(function(x,i){
      return '<div class="topRow"><div class="rank">'+(i+1)+'</div><div class="topMain"><b>'+esc(x.label)+'</b><small>'+Number(x.audience_presence||0)+' παρουσίες · '+Number(x.attentive_viewing||0)+' προσεκτικές · '+Number(x.message_shares||0)+' κοινοποιήσεις · '+confidenceText(x.confidence)+'</small></div><div class="score">'+Number(x.score||0)+'</div></div>';
    }).join("");
  }
  async function load(weeks){
    document.querySelectorAll("[data-weeks]").forEach(function(b){
      b.classList.toggle("active",Number(b.dataset.weeks)===Number(weeks));
    });

    var device=dev();
    if(device.length<3){
      document.getElementById("bestLabel").textContent="Δεν βρέθηκε ο λογαριασμός";
      document.getElementById("bestExplanation").innerHTML='Άνοιξε πρώτα το Προφίλ.<br><a class="profileLink" href="/?v=249409_transparent_best_time">Επιστροφή</a>';
      document.getElementById("confidence").textContent="Δεν υπάρχουν στοιχεία";
      renderHeatmap([]);
      renderTop([]);
      return;
    }

    try{
      var url='/api/creator/best-time_v249409?room_id='+encodeURIComponent(room())
        +'&device_id='+encodeURIComponent(device)
        +'&weeks='+encodeURIComponent(weeks)
        +'&tz_offset_minutes='+encodeURIComponent(String(-new Date().getTimezoneOffset()));
      var response=await fetch(url,{cache:'no-store',credentials:'same-origin'});
      var data=await response.json();
      if(!response.ok||!data||data.ok===false){
        throw new Error((data&&(data.detail||data.message))||('HTTP '+response.status));
      }

      var summary=data.summary||{};
      document.getElementById("presence").textContent=String(summary.audience_presence_events||0);
      document.getElementById("attentive").textContent=String(summary.attentive_events||0);
      document.getElementById("shares").textContent=String(summary.message_share_events||0);
      document.getElementById("dates").textContent=String(summary.active_local_dates||0);

      if(!data.active_creator){
        document.getElementById("bestLabel").textContent="Ο λογαριασμός δεν είναι ακόμη Δημιουργός";
        document.getElementById("bestExplanation").innerHTML='Αποθήκευσε πρώτα τον τύπο λογαριασμού ως Δημιουργός / Επαγγελματικός.';
        document.getElementById("confidence").textContent="Απαιτείται ενεργός λογαριασμός";
        renderHeatmap([]);
        renderTop([]);
        return;
      }

      var rec=data.recommendation||{};
      if(rec.available){
        document.getElementById("bestLabel").textContent=rec.label;
        document.getElementById("bestExplanation").textContent=
          rec.explanation+" Επόμενη αντίστοιχη ώρα: "+String(rec.next_local_iso||"").replace("T"," ")+".";
        document.getElementById("confidence").textContent=confidenceText(rec.confidence);
      }else{
        document.getElementById("bestLabel").textContent="Δεν υπάρχουν ακόμη αρκετά στοιχεία";
        document.getElementById("bestExplanation").textContent=rec.explanation||"Η ΙΘΑΚΗ περιμένει πραγματικά δεδομένα από το δικό σου κοινό.";
        document.getElementById("confidence").textContent="Χωρίς αυθαίρετη σύσταση";
      }

      renderHeatmap(Array.isArray(data.heatmap)?data.heatmap:[]);
      renderTop(Array.isArray(data.top_slots)?data.top_slots:[]);
    }catch(e){
      document.getElementById("bestLabel").textContent="Η ανάλυση δεν φόρτωσε";
      document.getElementById("bestExplanation").textContent=String(e&&e.message||e);
      document.getElementById("confidence").textContent="Σφάλμα φόρτωσης";
      renderHeatmap([]);
      renderTop([]);
    }
  }

  document.querySelectorAll("[data-weeks]").forEach(function(b){
    b.onclick=function(){load(Number(b.dataset.weeks||8))};
  });
  load(8);
})();
</script>
</body>
</html>"""

@app.get("/creator-best-time-v249409", response_class=_HTMLResponse_v249409)
def creator_best_time_page_v249409():
    return _HTMLResponse_v249409(
        content=_CREATOR_BEST_TIME_HTML_V249409,
        headers={"Cache-Control": "no-store, max-age=0"},
    )

# V249410_REAL_SCHEDULED_POSTS
from fastapi.responses import HTMLResponse as _HTMLResponse_v249410
from pydantic import BaseModel as _BaseModel_v249410
import json as _json_v249410
import threading as _threading_v249410
import time as _time_v249410

_V249410_WORKER_STARTED = False
_V249410_WORKER_LOCK = _threading_v249410.Lock()

class ScheduledPostInV249410(_BaseModel_v249410):
    room_id: str = "ithaki-phone-test"
    device_id: str
    sender_id: str
    nickname: str = ""
    language: str = "el"
    text: str = ""
    is_encrypted: bool = False
    iv: str = ""
    cipher_text: str = ""
    post_kind: str = "text"
    anonymous: bool = False
    visibility: str = "public"
    scheduled_for: float

class ScheduleChangeV249410(_BaseModel_v249410):
    room_id: str = "ithaki-phone-test"
    device_id: str
    schedule_id: str
    scheduled_for: float = 0

def _sched_init_v249410():
    execute('''
    CREATE TABLE IF NOT EXISTS creator_scheduled_posts_v249410(
      schedule_id TEXT PRIMARY KEY,
      room_id TEXT NOT NULL,
      device_id TEXT NOT NULL,
      sender_id TEXT NOT NULL,
      nickname TEXT NOT NULL DEFAULT '',
      language TEXT NOT NULL DEFAULT 'el',
      text TEXT NOT NULL DEFAULT '',
      is_encrypted INTEGER NOT NULL DEFAULT 0,
      iv TEXT NOT NULL DEFAULT '',
      cipher_text TEXT NOT NULL DEFAULT '',
      post_kind TEXT NOT NULL DEFAULT 'text',
      anonymous INTEGER NOT NULL DEFAULT 0,
      visibility TEXT NOT NULL DEFAULT 'public',
      scheduled_for REAL NOT NULL,
      status TEXT NOT NULL DEFAULT 'scheduled',
      published_post_id TEXT NOT NULL DEFAULT '',
      attempt_count INTEGER NOT NULL DEFAULT 0,
      next_attempt_at REAL NOT NULL DEFAULT 0,
      last_error TEXT NOT NULL DEFAULT '',
      created_at REAL NOT NULL,
      updated_at REAL NOT NULL,
      published_at REAL
    )''')
    execute("CREATE INDEX IF NOT EXISTS idx_sched_due_v249410 ON creator_scheduled_posts_v249410(status,scheduled_for,next_attempt_at)")
    execute("CREATE INDEX IF NOT EXISTS idx_sched_owner_v249410 ON creator_scheduled_posts_v249410(room_id,device_id,created_at DESC)")

def _sched_creator_v249410(room_id, device_id):
    helper = globals().get("_creator_active_v249405")
    if callable(helper):
        try:
            return bool(helper(room_id, device_id))
        except Exception:
            pass
    try:
        row = one("SELECT account_type FROM creator_accounts_v249401 WHERE room_id=? AND device_id=? LIMIT 1",(room_id,device_id))
        return bool(row and str(row["account_type"] or "") == "creator")
    except Exception:
        return False

def _sched_time_v249410(value):
    try:
        ts = float(value)
    except Exception:
        raise HTTPException(400, "invalid_scheduled_for")
    now_ts = _time_v249410.time()
    if ts < now_ts + 60:
        raise HTTPException(400, "scheduled_time_must_be_in_future")
    if ts > now_ts + 180 * 86400:
        raise HTTPException(400, "scheduled_time_too_far")
    return ts

def _sched_preview_v249410(text, kind):
    raw = str(text or "")
    try:
        obj = _json_v249410.loads(raw)
        if isinstance(obj, dict):
            value = obj.get("title") or obj.get("caption") or obj.get("text") or obj.get("body")
            if value:
                return str(value).strip().replace("\n"," ")[:220]
    except Exception:
        pass
    return raw.strip().replace("\n"," ")[:220] or str(kind or "Δημοσίευση")

def _sched_public_v249410(row):
    d = dict(row)
    return {
      "schedule_id": str(d.get("schedule_id") or ""),
      "post_kind": str(d.get("post_kind") or "text"),
      "scheduled_for": float(d.get("scheduled_for") or 0),
      "status": str(d.get("status") or ""),
      "published_post_id": str(d.get("published_post_id") or ""),
      "attempt_count": int(d.get("attempt_count") or 0),
      "last_error": str(d.get("last_error") or "")[:240],
      "published_at": float(d["published_at"]) if d.get("published_at") is not None else None,
      "preview": _sched_preview_v249410(d.get("text"), d.get("post_kind")),
    }

def _sched_claim_v249410():
    _sched_init_v249410()
    current = _time_v249410.time()
    try:
        _conn.execute("BEGIN IMMEDIATE")
        row = _conn.execute('''
          SELECT * FROM creator_scheduled_posts_v249410
          WHERE status='scheduled' AND scheduled_for<=?
            AND (next_attempt_at=0 OR next_attempt_at<=?)
          ORDER BY scheduled_for ASC LIMIT 1
        ''',(current,current)).fetchone()
        if not row:
            _conn.rollback()
            return None
        sid = str(row["schedule_id"])
        changed = _conn.execute('''
          UPDATE creator_scheduled_posts_v249410
          SET status='publishing',attempt_count=attempt_count+1,updated_at=?
          WHERE schedule_id=? AND status='scheduled'
        ''',(current,sid)).rowcount
        if changed != 1:
            _conn.rollback()
            return None
        _conn.commit()
        return dict(row)
    except Exception:
        try:
            _conn.rollback()
        except Exception:
            pass
        return None

def _sched_publish_v249410(d):
    current = _time_v249410.time()
    sid = str(d.get("schedule_id") or "")
    try:
        room_id = str(d.get("room_id") or "")
        sender_id = str(d.get("sender_id") or "")
        banned = globals().get("is_banned")
        if callable(banned) and banned(room_id, sender_id):
            raise RuntimeError("member_blocked")
        encrypted = bool(d.get("is_encrypted"))
        text = str(d.get("text") or "")
        iv = str(d.get("iv") or "")
        cipher = str(d.get("cipher_text") or "")
        if encrypted:
            if not iv or not cipher:
                raise RuntimeError("encrypted_payload_missing")
            safe_text = ""
        else:
            safe_text = text.strip()
            if not safe_text:
                raise RuntimeError("empty_post")
        post_id = nid("flow")
        execute('''
          INSERT INTO flow_posts(
            post_id,room_id,sender_id,nickname,language,text,is_encrypted,
            iv,cipher_text,post_kind,anonymous,visibility,created_at
          ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
        ''',(
          post_id,room_id,sender_id,str(d.get("nickname") or ""),
          str(d.get("language") or "el"),safe_text,1 if encrypted else 0,
          iv,cipher,str(d.get("post_kind") or "text"),
          1 if bool(d.get("anonymous")) else 0,
          str(d.get("visibility") or "public"),current
        ))
        execute('''
          UPDATE creator_scheduled_posts_v249410
          SET status='published',published_post_id=?,published_at=?,
              updated_at=?,last_error=''
          WHERE schedule_id=?
        ''',(post_id,current,current,sid))
    except Exception as exc:
        attempts = int(d.get("attempt_count") or 0) + 1
        status = "failed" if attempts >= 5 else "scheduled"
        retry = 0 if status == "failed" else current + min(3600,60*(2**max(0,attempts-1)))
        execute('''
          UPDATE creator_scheduled_posts_v249410
          SET status=?,next_attempt_at=?,updated_at=?,last_error=?
          WHERE schedule_id=?
        ''',(status,retry,current,str(exc)[:500],sid))

def _sched_worker_v249410():
    while True:
        try:
            worked = False
            for _ in range(10):
                row = _sched_claim_v249410()
                if not row:
                    break
                worked = True
                _sched_publish_v249410(row)
            _time_v249410.sleep(5 if worked else 20)
        except Exception:
            _time_v249410.sleep(30)

def _sched_start_v249410():
    global _V249410_WORKER_STARTED
    with _V249410_WORKER_LOCK:
        if _V249410_WORKER_STARTED:
            return
        _sched_init_v249410()
        thread = _threading_v249410.Thread(target=_sched_worker_v249410,name="ithaki-scheduler-v249410",daemon=True)
        thread.start()
        _V249410_WORKER_STARTED = True

@app.post("/api/creator/schedule_v249410")
def create_schedule_v249410(p: ScheduledPostInV249410):
    _sched_init_v249410()
    room_id = str(p.room_id or "ithaki-phone-test")[:120]
    device_id = str(p.device_id or "")[:180]
    sender_id = str(p.sender_id or "")[:180]
    if sender_id != device_id:
        raise HTTPException(403, "sender_must_match_creator_device")
    if not _sched_creator_v249410(room_id, device_id):
        raise HTTPException(403, "creator_account_not_active")
    scheduled_for = _sched_time_v249410(p.scheduled_for)
    if p.is_encrypted:
        if not p.iv or not p.cipher_text:
            raise HTTPException(400, "encrypted_payload_missing")
    elif not str(p.text or "").strip():
        raise HTTPException(400, "empty_post")
    current = _time_v249410.time()
    schedule_id = nid("sched")
    execute('''
      INSERT INTO creator_scheduled_posts_v249410(
        schedule_id,room_id,device_id,sender_id,nickname,language,text,
        is_encrypted,iv,cipher_text,post_kind,anonymous,visibility,
        scheduled_for,status,published_post_id,attempt_count,next_attempt_at,
        last_error,created_at,updated_at,published_at
      ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
    ''',(
      schedule_id,room_id,device_id,sender_id,str(p.nickname or "")[:180],
      str(p.language or "el")[:20],str(p.text or ""),1 if p.is_encrypted else 0,
      str(p.iv or ""),str(p.cipher_text or ""),str(p.post_kind or "text")[:40],
      1 if p.anonymous else 0,str(p.visibility or "public")[:40],
      scheduled_for,"scheduled","",0,0,"",current,current,None
    ))
    row = one("SELECT * FROM creator_scheduled_posts_v249410 WHERE schedule_id=?",(schedule_id,))
    return {"ok":True,"version":"V249410-real-scheduled-posts","scheduled":True,
            "post_id":schedule_id,"schedule":_sched_public_v249410(row),
            "automatic_worker":True,"published_now":False}

@app.get("/api/creator/schedules_v249410")
def list_schedules_v249410(
    room_id: str = Query("ithaki-phone-test"),
    device_id: str = Query(...),
    limit: int = Query(100,ge=1,le=300),
):
    _sched_init_v249410()
    if not _sched_creator_v249410(room_id,device_id):
        return {"ok":True,"version":"V249410-real-scheduled-posts","active_creator":False,"schedules":[],"message":"creator_account_not_active"}
    result = rows('''
      SELECT * FROM creator_scheduled_posts_v249410
      WHERE room_id=? AND device_id=?
      ORDER BY CASE status WHEN 'scheduled' THEN 0 WHEN 'publishing' THEN 1
        WHEN 'failed' THEN 2 WHEN 'published' THEN 3 ELSE 4 END,
        scheduled_for ASC,created_at DESC LIMIT ?
    ''',(room_id,device_id,int(limit)))
    return {"ok":True,"version":"V249410-real-scheduled-posts","active_creator":True,
            "automatic_worker":True,"schedules":[_sched_public_v249410(x) for x in result]}

@app.post("/api/creator/schedule/cancel_v249410")
def cancel_schedule_v249410(p: ScheduleChangeV249410):
    _sched_init_v249410()
    if not _sched_creator_v249410(p.room_id,p.device_id):
        raise HTTPException(403,"creator_account_not_active")
    row = one("SELECT status FROM creator_scheduled_posts_v249410 WHERE schedule_id=? AND room_id=? AND device_id=?",(p.schedule_id,p.room_id,p.device_id))
    if not row:
        raise HTTPException(404,"schedule_not_found")
    if str(row["status"]) not in ("scheduled","failed"):
        raise HTTPException(409,"schedule_cannot_be_cancelled")
    execute("UPDATE creator_scheduled_posts_v249410 SET status='cancelled',updated_at=?,last_error='' WHERE schedule_id=? AND room_id=? AND device_id=?",
            (_time_v249410.time(),p.schedule_id,p.room_id,p.device_id))
    return {"ok":True,"cancelled":True,"schedule_id":p.schedule_id}

@app.post("/api/creator/schedule/reschedule_v249410")
def reschedule_v249410(p: ScheduleChangeV249410):
    _sched_init_v249410()
    if not _sched_creator_v249410(p.room_id,p.device_id):
        raise HTTPException(403,"creator_account_not_active")
    new_time = _sched_time_v249410(p.scheduled_for)
    row = one("SELECT status FROM creator_scheduled_posts_v249410 WHERE schedule_id=? AND room_id=? AND device_id=?",(p.schedule_id,p.room_id,p.device_id))
    if not row:
        raise HTTPException(404,"schedule_not_found")
    if str(row["status"]) not in ("scheduled","failed"):
        raise HTTPException(409,"schedule_cannot_be_rescheduled")
    execute("UPDATE creator_scheduled_posts_v249410 SET status='scheduled',scheduled_for=?,next_attempt_at=0,last_error='',updated_at=? WHERE schedule_id=? AND room_id=? AND device_id=?",
            (new_time,_time_v249410.time(),p.schedule_id,p.room_id,p.device_id))
    return {"ok":True,"rescheduled":True,"schedule_id":p.schedule_id,"scheduled_for":new_time}

@app.get("/api/creator/schedule/diagnostics_v249410")
def schedule_diagnostics_v249410():
    _sched_init_v249410()
    def count(status):
        row = one("SELECT COUNT(*) n FROM creator_scheduled_posts_v249410 WHERE status=?",(status,))
        return int(row["n"] if row else 0)
    return {"ok":True,"version":"V249410-real-scheduled-posts",
      "worker_started":bool(_V249410_WORKER_STARTED),
      "scheduled":count("scheduled"),"publishing":count("publishing"),
      "published":count("published"),"failed":count("failed"),"cancelled":count("cancelled"),
      "publishes_automatically":True,"creator_only":True,
      "database_content_rows_deleted":False,"existing_posts_modified":False}

_CREATOR_SCHEDULE_HTML_V249410 = r'''<!doctype html>
<html lang="el"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>ΙΘΑΚΗ · Προγραμματισμένες δημοσιεύσεις</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#07101a;color:#eef7ff;font-family:system-ui}
main{width:min(1000px,100%);margin:auto;padding:14px;display:grid;gap:12px}
.top,.box,.item{border:1px solid #ffffff1d;background:#ffffff0b;border-radius:20px;padding:14px}
.top{display:grid;grid-template-columns:auto 1fr auto;align-items:center}.top h1{text-align:center;color:#f2d596;font-size:20px}
a,button,input{font:inherit}.back{color:white;text-decoration:none;font-size:28px}
.plan{display:grid;grid-template-columns:1fr auto auto;gap:8px;align-items:end}
label{display:grid;gap:5px}input,button{min-height:44px;border-radius:13px;padding:9px 12px}
input{background:#07101a;color:white;border:1px solid #ffffff25}
button{cursor:pointer;border:1px solid #e2bd6577;background:#e2bd65;color:#211508;font-weight:800}
button.alt{background:#66c7e811;color:#d9f6ff;border-color:#66c7e844}
.notice{color:#c3d3dc;line-height:1.55;font-size:12px}.list{display:grid;gap:9px}
.item{display:grid;gap:8px}.head{display:flex;justify-content:space-between;gap:10px}
.small{font-size:11px;color:#9fb0bc}.actions{display:flex;gap:7px;flex-wrap:wrap}
.actions button,.actions a{min-height:36px;padding:7px 10px;background:#ffffff0a;color:#eaf4fa;border:1px solid #ffffff22;border-radius:11px;text-decoration:none}
.status{color:#f2d596}.empty{text-align:center;color:#aebdc8;padding:24px}
@media(max-width:700px){.plan{grid-template-columns:1fr}.plan button{width:100%}}
</style></head><body><main>
<div class="top"><a class="back" href="/?v=249410_scheduled_posts">‹</a><h1>🗓️ Προγραμματισμένες δημοσιεύσεις</h1><a class="back" href="/?v=249410_scheduled_posts">×</a></div>
<div class="box notice"><b>Πραγματικός προγραμματισμός:</b> διάλεξε ώρα, όρισε την επόμενη δημοσίευση και επέστρεψε στην κανονική οθόνη δημοσίευσης. Όταν πατήσεις το υπάρχον κουμπί Δημοσίευση, η ΙΘΑΚΗ θα την κρατήσει στην ουρά. Ο server τη δημοσιεύει αυτόματα ακόμη κι αν ο browser είναι κλειστός.</div>
<div class="box plan"><label>Τοπική ημερομηνία και ώρα<input id="when" type="datetime-local"></label><button id="best" class="alt">🕰️ Καλύτερη ώρα</button><button id="arm">Ορισμός επόμενης δημοσίευσης</button></div>
<div id="pending" class="box notice" style="display:none"></div>
<section id="list" class="list"><div class="empty">Φόρτωση…</div></section>
</main><script>
(function(){
var KEY="ithaki_schedule_next_v249410";
function dev(){return localStorage.getItem("ith_device")||localStorage.getItem("device_id")||""}
function room(){return localStorage.getItem("ith_room")||"ithaki-phone-test"}
function fmt(ts){return new Date(Number(ts)*1000).toLocaleString("el-GR",{day:"2-digit",month:"2-digit",year:"numeric",hour:"2-digit",minute:"2-digit"})}
function localValue(ms){var d=new Date(ms),x=new Date(d.getTime()-d.getTimezoneOffset()*60000);return x.toISOString().slice(0,16)}
async function jf(url,opt){var r=await fetch(url,opt||{}),d=await r.json();if(!r.ok||d.ok===false)throw Error(d.detail||d.message||("HTTP "+r.status));return d}
function showPending(){var box=document.getElementById("pending"),p=null;try{p=JSON.parse(localStorage.getItem(KEY)||"null")}catch(e){}
if(!p){box.style.display="none";return}box.style.display="block";box.innerHTML="<b>Η επόμενη δημοσίευση: "+fmt(p.scheduled_for)+"</b><br>Επέστρεψε στην κανονική δημοσίευση και πάτησε Δημοσίευση. <button id='clear' class='alt'>Ακύρωση επιλογής</button>";document.getElementById("clear").onclick=function(){localStorage.removeItem(KEY);showPending()}}
async function load(){var host=document.getElementById("list"),device=dev();if(!device){host.innerHTML="<div class='empty'>Άνοιξε πρώτα το Προφίλ.</div>";return}
try{var d=await jf("/api/creator/schedules_v249410?room_id="+encodeURIComponent(room())+"&device_id="+encodeURIComponent(device));if(!d.active_creator){host.innerHTML="<div class='empty'>Ο λογαριασμός δεν είναι αποθηκευμένος ως Δημιουργός / Επαγγελματικός.</div>";return}
var a=d.schedules||[];if(!a.length){host.innerHTML="<div class='empty'>Δεν υπάρχουν προγραμματισμένες δημοσιεύσεις.</div>";return}
host.innerHTML=a.map(function(x){var can=x.status==="scheduled"||x.status==="failed";return "<article class='item'><div class='head'><b>"+String(x.preview||"Δημοσίευση").replace(/[<>]/g,"")+"</b><span class='status'>"+x.status+"</span></div><div class='small'>"+fmt(x.scheduled_for)+" · "+x.post_kind+"</div><div class='actions'>"+(can?"<button data-cancel='"+x.schedule_id+"'>Ακύρωση</button><button data-move='"+x.schedule_id+"' data-time='"+x.scheduled_for+"'>Αλλαγή ώρας</button>":"")+(x.published_post_id?"<a href='/?v=249410_scheduled_posts&open_post="+encodeURIComponent(x.published_post_id)+"'>Άνοιγμα στη Ροή</a>":"")+"</div>"+(x.last_error?"<div class='small'>"+x.last_error+"</div>":"")+"</article>"}).join("");
host.querySelectorAll("[data-cancel]").forEach(function(b){b.onclick=async function(){if(!confirm("Να ακυρωθεί;"))return;await jf("/api/creator/schedule/cancel_v249410",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({room_id:room(),device_id:dev(),schedule_id:b.dataset.cancel})});load()}});
host.querySelectorAll("[data-move]").forEach(function(b){b.onclick=async function(){var v=prompt("Νέα ώρα YYYY-MM-DDTHH:MM",localValue(Number(b.dataset.time)*1000));if(!v)return;await jf("/api/creator/schedule/reschedule_v249410",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({room_id:room(),device_id:dev(),schedule_id:b.dataset.move,scheduled_for:new Date(v).getTime()/1000})});load()}});
}catch(e){host.innerHTML="<div class='empty'>"+e.message+"</div>"}}
var input=document.getElementById("when");input.min=localValue(Date.now()+120000);input.value=localValue(Date.now()+3600000);
document.getElementById("arm").onclick=function(){var ts=new Date(input.value).getTime()/1000;if(!isFinite(ts)||ts<Date.now()/1000+60){alert("Διάλεξε μελλοντική ώρα.");return}localStorage.setItem(KEY,JSON.stringify({scheduled_for:ts}));location.href="/?v=249410_scheduled_posts"};
document.getElementById("best").onclick=async function(){try{var d=await jf("/api/creator/best-time_v249409?room_id="+encodeURIComponent(room())+"&device_id="+encodeURIComponent(dev())+"&weeks=8&tz_offset_minutes="+(-new Date().getTimezoneOffset()));var r=d.recommendation||{};if(!r.available)throw Error(r.explanation||"Δεν υπάρχουν αρκετά στοιχεία.");input.value=r.next_local_iso;alert(r.label+"\n"+r.explanation)}catch(e){alert(e.message)}};
showPending();load();setInterval(load,30000);
})();
</script></body></html>'''

@app.get("/creator-scheduled-posts-v249410",response_class=_HTMLResponse_v249410)
def schedule_page_v249410():
    return _HTMLResponse_v249410(content=_CREATOR_SCHEDULE_HTML_V249410,headers={"Cache-Control":"no-store"})

_sched_start_v249410()


# ===== OK ITHAKI V249363: serve creator_account_v249401.js + build verification =====
@app.get("/creator_account_v249401.js")
def frontend_creator_account_v249401_js():
    p = FRONTEND / "creator_account_v249401.js"
    if p.exists():
        return Response(p.read_text(encoding="utf-8"), media_type="application/javascript; charset=utf-8", headers={"Cache-Control": "no-store"})
    raise HTTPException(status_code=404, detail="creator_account_v249401.js missing")


@app.get("/api/frontend/build_v249363")
def frontend_build_v249363():
    html_txt = (FRONTEND / "index.html").read_text(encoding="utf-8", errors="replace") if (FRONTEND / "index.html").exists() else ""
    return JSONResponse({
        "ok": True,
        "version": VERSION,
        "creator_account_hardened": "_creator_verify_code_v249401" in Path(__file__).read_text(encoding="utf-8", errors="replace"),
        "creator_script_tag_present": 'src="creator_account_v249401.js' in html_txt,
        "boot_crash_fix_present": "Promise.resolve(boot())" in (FRONTEND / "index.js").read_text(encoding="utf-8", errors="replace"),
    }, headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"})
# ===== END OK ITHAKI V249363 =====


# ===== OK ITHAKI V249364: serve clean_rebuild_v249364.js + build verification =====
@app.get("/clean_rebuild_v249364.js")
def frontend_clean_rebuild_v249364_js():
    p = FRONTEND / "clean_rebuild_v249364.js"
    if p.exists():
        return Response(p.read_text(encoding="utf-8"), media_type="application/javascript; charset=utf-8", headers={"Cache-Control": "no-store"})
    raise HTTPException(status_code=404, detail="clean_rebuild_v249364.js missing")


@app.get("/api/frontend/build_v249364")
def frontend_build_v249364():
    html_txt = (FRONTEND / "index.html").read_text(encoding="utf-8", errors="replace") if (FRONTEND / "index.html").exists() else ""
    feed_txt = (FRONTEND / "clean_rebuild_v249361.js").read_text(encoding="utf-8", errors="replace") if (FRONTEND / "clean_rebuild_v249361.js").exists() else ""
    return JSONResponse({
        "ok": True,
        "version": VERSION,
        "avatar_friend_guard_script_tag_present": 'src="clean_rebuild_v249364.js' in html_txt,
        "feed_object_fit_contain": "object-fit:contain;display:block;background:#000" in feed_txt,
        "feed_has_avatar_thumb": "avatarThumb361" in feed_txt,
    }, headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"})
# ===== END OK ITHAKI V249364 =====


# ===== OK ITHAKI V249365: serve clean_rebuild_v249365_views.js + build verification =====
@app.get("/clean_rebuild_v249365_views.js")
def frontend_clean_rebuild_v249365_views_js():
    p = FRONTEND / "clean_rebuild_v249365_views.js"
    if p.exists():
        return Response(p.read_text(encoding="utf-8"), media_type="application/javascript; charset=utf-8", headers={"Cache-Control": "no-store"})
    raise HTTPException(status_code=404, detail="clean_rebuild_v249365_views.js missing")


@app.get("/api/frontend/build_v249365")
def frontend_build_v249365():
    html_txt = (FRONTEND / "index.html").read_text(encoding="utf-8", errors="replace") if (FRONTEND / "index.html").exists() else ""
    server_txt = Path(__file__).read_text(encoding="utf-8", errors="replace")
    return JSONResponse({
        "ok": True,
        "version": VERSION,
        "view_tracker_script_tag_present": 'src="clean_rebuild_v249365_views.js' in html_txt,
        "v405_real_view_foundation_present": "creator_video_sessions_v249405" in server_txt,
        "v406d_charts_present": "V249406D_CREATOR_ANALYTICS_CHARTS" in server_txt,
        "v407_viewing_quality_present": "V249407_TRANSPARENT_VIEWING_QUALITY" in server_txt,
        "v408_message_shares_present": "creator_video_shares_v249408" in server_txt,
        "v409_best_time_present": "creator_best_time_v249409" in server_txt,
        "v410_scheduling_present": "creator_scheduled_posts_v249410" in server_txt,
        "no_duplicate_old_v405_block": "V249405 REAL VIEWS -------------------------" not in server_txt,
    }, headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"})
# ===== END OK ITHAKI V249365 =====

# BEGIN V249375_FULL_EXPERIENCE_ROUTES
@app.get("/api/lexicon/v249374/status")
def api_lexicon_v249374_status():
    try:
        from lexicon_service_v249374 import status
        return status()
    except Exception as exc:
        return {"ok": False, "note": str(exc)}

@app.get("/api/lexicon/v249374/entries")
def api_lexicon_v249374_entries(status: str = "pending", q: str = "", limit: int = 100, offset: int = 0):
    try:
        from lexicon_service_v249374 import list_entries
        return list_entries(status, q, limit, offset)
    except Exception as exc:
        return {"ok": False, "note": str(exc), "items": []}

@app.post("/api/lexicon/v249374/save")
def api_lexicon_v249374_save(payload: dict):
    try:
        from lexicon_service_v249374 import save_translation
        return save_translation(payload or {})
    except Exception as exc:
        return {"ok": False, "note": str(exc)}

@app.post("/api/lexicon/v249374/reopen")
def api_lexicon_v249374_reopen(payload: dict):
    try:
        from lexicon_service_v249374 import reopen_entry
        return reopen_entry(payload or {})
    except Exception as exc:
        return {"ok": False, "note": str(exc)}

@app.get("/api/lexicon/v249374/revisions")
def api_lexicon_v249374_revisions(entry_key: str = "", limit: int = 50):
    try:
        from lexicon_service_v249374 import revisions
        return revisions(entry_key, limit)
    except Exception as exc:
        return {"ok": False, "note": str(exc), "items": []}

@app.get("/api/lexicon/v249374/contributors")
def api_lexicon_v249374_contributors(limit: int = 100):
    try:
        from lexicon_service_v249374 import contributors
        return contributors(limit)
    except Exception as exc:
        return {"ok": False, "note": str(exc), "items": []}

@app.get("/api/lexicon/v249374/export.csv")
def api_lexicon_v249374_export_csv():
    try:
        from lexicon_service_v249374 import export_helper_csv
        from fastapi.responses import FileResponse as _LexiconFileResponse
        out = export_helper_csv()
        return _LexiconFileResponse(out["path"], media_type="text/csv; charset=utf-8", filename="community_translation_helper_v249374.csv")
    except Exception as exc:
        raise HTTPException(status_code=500, detail=str(exc))

@app.get("/api/community/v249375/status")
def api_community_v249375_status():
    try:
        from community_suite_v249375 import wall_status
        out = wall_status()
        out["middleware_installed"] = any(
            getattr(getattr(item, "cls", None), "__name__", "") == "PublicModerationMiddlewareV249375"
            for item in getattr(app, "user_middleware", [])
        )
        return out
    except Exception as exc:
        return {"ok": False, "note": str(exc)}

@app.post("/api/moderation/v249375/check")
def api_moderation_v249375_check(payload: dict):
    try:
        from community_suite_v249375 import moderation_check
        return moderation_check(payload or {})
    except Exception as exc:
        return {"ok": False, "note": str(exc)}

@app.get("/api/wall/v249375/list")
def api_wall_v249375_list(room_id: str = "ithaki-phone-test", owner_token: str = "", limit: int = 100):
    try:
        from community_suite_v249375 import wall_list
        return wall_list(room_id, owner_token, limit)
    except Exception as exc:
        return {"ok": False, "note": str(exc), "items": []}

@app.post("/api/wall/v249375/post")
def api_wall_v249375_post(payload: dict):
    try:
        from community_suite_v249375 import wall_post
        return wall_post(payload or {})
    except Exception as exc:
        return {"ok": False, "note": str(exc)}

@app.post("/api/wall/v249375/react")
def api_wall_v249375_react(payload: dict):
    try:
        from community_suite_v249375 import wall_react
        return wall_react(payload or {})
    except Exception as exc:
        return {"ok": False, "note": str(exc)}

@app.get("/api/wall/v249375/comments")
def api_wall_v249375_comments(post_id: str = "", owner_token: str = "", limit: int = 200):
    try:
        from community_suite_v249375 import wall_comments
        return wall_comments(post_id, owner_token, limit)
    except Exception as exc:
        return {"ok": False, "note": str(exc), "items": []}

@app.post("/api/wall/v249375/comment")
def api_wall_v249375_comment(payload: dict):
    try:
        from community_suite_v249375 import wall_comment
        return wall_comment(payload or {})
    except Exception as exc:
        return {"ok": False, "note": str(exc)}

@app.post("/api/wall/v249375/delete")
def api_wall_v249375_delete(payload: dict):
    try:
        from community_suite_v249375 import wall_delete
        return wall_delete(payload or {})
    except Exception as exc:
        return {"ok": False, "note": str(exc)}

@app.post("/api/wall/v249375/report")
def api_wall_v249375_report(payload: dict):
    try:
        from community_suite_v249375 import wall_report
        return wall_report(payload or {})
    except Exception as exc:
        return {"ok": False, "note": str(exc)}

try:
    from community_suite_v249375 import install_public_moderation
    install_public_moderation(app)
except Exception:
    pass
# END V249375_FULL_EXPERIENCE_ROUTES

# BEGIN V249380_MEMBER_COMMUNICATION_GUARD_ROUTES
@app.get("/api/social/friend/state_v249380")
def api_social_friend_state_v249380(room_id: str = "ithaki-phone-test", device_id: str = "", peer_id: str = ""):
    try:
        init_social_v249223_db()
    except Exception:
        try:
            init_social_v166_db()
        except Exception:
            pass
    room_id = str(room_id or "ithaki-phone-test")[:120]
    device_id = str(device_id or "")[:120]
    peer_id = str(peer_id or "")[:120]
    if not device_id or not peer_id:
        return {"ok": True, "state": "profile_required", "accepted": False, "communication_allowed": False}
    if device_id == peer_id:
        return {"ok": True, "state": "self", "accepted": False, "communication_allowed": False}
    blocked = False
    try:
        blocked = bool(_is_user_blocked_between(room_id, device_id, peer_id))
    except Exception:
        blocked = False
    accepted = one("""
        SELECT id,from_id,to_id,status,updated_at FROM social_events
        WHERE room_id=? AND type='contact' AND status='accepted'
          AND ((from_id=? AND to_id=?) OR (from_id=? AND to_id=?))
        ORDER BY updated_at DESC LIMIT 1
    """, (room_id, device_id, peer_id, peer_id, device_id))
    outgoing = one("""
        SELECT id,status,updated_at FROM social_events
        WHERE room_id=? AND type='contact' AND status='pending' AND from_id=? AND to_id=?
        ORDER BY updated_at DESC LIMIT 1
    """, (room_id, device_id, peer_id))
    incoming = one("""
        SELECT id,status,updated_at FROM social_events
        WHERE room_id=? AND type='contact' AND status='pending' AND from_id=? AND to_id=?
        ORDER BY updated_at DESC LIMIT 1
    """, (room_id, peer_id, device_id))
    if blocked:
        state = "blocked"
    elif accepted:
        state = "accepted"
    elif incoming:
        state = "pending_incoming"
    elif outgoing:
        state = "pending_outgoing"
    else:
        state = "none"
    return {
        "ok": True,
        "version": "V249380-member-communication-friend-guard",
        "state": state,
        "accepted": bool(accepted) and not blocked,
        "blocked": blocked,
        "pending_outgoing": bool(outgoing),
        "pending_incoming": bool(incoming),
        "communication_allowed": bool(accepted) and not blocked,
        "event_id": str((accepted or outgoing or incoming or {}).get("id") or ""),
        "policy": "Personal message, voice call and video call open only after accepted friendship."
    }

@app.get("/api/diagnostics/v249380")
def api_diagnostics_v249380(room_id: str = "ithaki-phone-test", device_id: str = ""):
    from pathlib import Path as _PathV249380
    try:
        init_fcm_db()
    except Exception:
        pass
    try:
        init_video_call_db()
    except Exception:
        pass
    def _cnt_v249380(query, args=()):
        try:
            r = one(query, args)
            return int((r or {}).get("n") or 0)
        except Exception:
            return -1
    cfg = _fcm_cfg()
    key_value = str(cfg.get("service_account_json") or "firebase_service_account.json")
    key_path = _PathV249380(key_value)
    if not key_path.is_absolute():
        key_path = ROOT / key_path
    try:
        import firebase_admin as _firebase_admin_v249380  # noqa
        firebase_importable = True
    except Exception:
        firebase_importable = False
    route_paths = {str(getattr(route, "path", "")) for route in getattr(app, "routes", [])}
    active_tokens = _cnt_v249380("SELECT COUNT(*) n FROM fcm_tokens WHERE room_id=? AND enabled=1", (room_id,))
    device_tokens = _cnt_v249380("SELECT COUNT(*) n FROM fcm_tokens WHERE room_id=? AND device_id=? AND enabled=1", (room_id, device_id)) if device_id else 0
    accepted = _cnt_v249380("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='accepted'", (room_id,))
    pending = _cnt_v249380("SELECT COUNT(*) n FROM social_events WHERE room_id=? AND type='contact' AND status='pending'", (room_id,))
    active_calls = _cnt_v249380("SELECT COUNT(*) n FROM video_call_sessions WHERE room_id=? AND status IN ('ringing','accepted','active') AND expires_at>?", (room_id, now()))
    server_ready = bool(cfg.get("enabled")) and key_path.exists() and firebase_importable
    apk_registered = active_tokens > 0
    call_routes_ready = "/api/video/session/create" in route_paths and "/api/video/session/incoming" in route_paths
    return {
        "ok": True,
        "version": "V249380-member-mobile-card-friend-guard-fcm-diagnostic",
        "friend_guard": {
            "endpoint": "/api/social/friend/state_v249380",
            "messages_require_accepted_friendship": True,
            "voice_video_require_accepted_friendship": True,
            "accepted_friend_rows": accepted,
            "pending_friend_rows": pending
        },
        "fcm": {
            "server_enabled": bool(cfg.get("enabled")),
            "service_account_exists": key_path.exists(),
            "firebase_admin_importable": firebase_importable,
            "active_tokens": active_tokens,
            "enabled_tokens_for_device": device_tokens,
            "server_ready": server_ready,
            "apk_registered": apk_registered,
            "closed_app_ready": server_ready and apk_registered,
            "message_channel": "ok_ithaki_fcm_messages_v249336",
            "call_channel": "ok_ithaki_fcm_calls_v249336"
        },
        "calls": {
            "create_route": "/api/video/session/create" in route_paths,
            "incoming_route": "/api/video/session/incoming" in route_paths,
            "routes_ready": call_routes_ready,
            "active_sessions": active_calls,
            "friend_guard_is_also_enforced_by_server": True,
            "one_live_call_push_per_session": True,
            "missed_message_only_when_unanswered": True
        },
        "closed_app_voice_video_ready": server_ready and apk_registered and call_routes_ready,
        "apk_requirements": [
            "FCM-enabled APK built with google-services.json",
            "notification permission granted",
            "app opened once for token registration",
            "phone powered on and connected to internet"
        ],
        "test_order": [
            "Open the FCM-enabled APK once and wait 20 seconds.",
            "Close the app completely without clearing its data.",
            "From a second accepted-friend device send a DM.",
            "From a second accepted-friend device start one voice call and one video call.",
            "Confirm the phone rings once and the call screen opens from the notification."
        ]
    }
# END V249380_MEMBER_COMMUNICATION_GUARD_ROUTES

# BEGIN V249382_UNIFIED_ACCOUNT_SECURITY_ROUTES
@app.get("/api/account/v249382/status")
def api_account_v249382_status(room_id: str = "ithaki-phone-test", device_id: str = ""):
    try:
        from account_security_v249382 import status
        return status(room_id, device_id)
    except Exception as exc:
        return {"ok": False, "version": "V249382", "note": str(exc)}

@app.post("/api/account/v249382/profile/sync")
def api_account_v249382_profile_sync(payload: dict):
    try:
        from account_security_v249382 import sync_profile
        return sync_profile(payload or {})
    except Exception as exc:
        return {"ok": False, "version": "V249382", "note": str(exc)}

@app.post("/api/account/v249382/professional/setup")
def api_account_v249382_professional_setup(payload: dict):
    try:
        from account_security_v249382 import setup_professional
        return setup_professional(payload or {})
    except Exception as exc:
        return {"ok": False, "version": "V249382", "note": str(exc)}

@app.post("/api/account/v249382/professional/unlock")
def api_account_v249382_professional_unlock(payload: dict):
    try:
        from account_security_v249382 import unlock_professional
        return unlock_professional(payload or {})
    except Exception as exc:
        return {"ok": False, "version": "V249382", "note": str(exc)}

@app.post("/api/account/v249382/professional/profile/update")
def api_account_v249382_professional_profile_update(payload: dict):
    try:
        from account_security_v249382 import update_professional_profile
        return update_professional_profile(payload or {})
    except Exception as exc:
        return {"ok": False, "version": "V249382", "note": str(exc)}

@app.post("/api/account/v249382/mode")
def api_account_v249382_mode(payload: dict):
    try:
        from account_security_v249382 import set_mode
        return set_mode(payload or {})
    except Exception as exc:
        return {"ok": False, "version": "V249382", "note": str(exc)}

@app.post("/api/account/v249382/professional/password/change")
def api_account_v249382_professional_password_change(payload: dict):
    try:
        from account_security_v249382 import change_password
        return change_password(payload or {})
    except Exception as exc:
        return {"ok": False, "version": "V249382", "note": str(exc)}

@app.post("/api/account/v249382/professional/close")
def api_account_v249382_professional_close(payload: dict):
    try:
        from account_security_v249382 import close_professional
        return close_professional(payload or {})
    except Exception as exc:
        return {"ok": False, "version": "V249382", "note": str(exc)}

@app.get("/api/account/v249382/diagnostics")
def api_account_v249382_diagnostics():
    try:
        from account_security_v249382 import diagnostics
        return diagnostics()
    except Exception as exc:
        return {"ok": False, "version": "V249382", "note": str(exc)}
# END V249382_UNIFIED_ACCOUNT_SECURITY_ROUTES

# BEGIN V249383_PROFESSIONAL_CENTER_ANALYTICS_LIVE_WALL_ROUTES
@app.get("/api/account/v249383/status")
def api_account_v249383_status(room_id: str = "ithaki-phone-test", device_id: str = ""):
    try:
        from account_security_v249382 import status
        return status(room_id, device_id)
    except Exception as exc:
        return {"ok": False, "version": "V249383", "note": str(exc)}

@app.post("/api/account/v249383/profile/sync")
def api_account_v249383_profile_sync(payload: dict):
    try:
        from account_security_v249382 import sync_profile
        return sync_profile(payload or {})
    except Exception as exc:
        return {"ok": False, "version": "V249383", "note": str(exc)}

@app.post("/api/account/v249383/professional/setup")
def api_account_v249383_professional_setup(payload: dict):
    try:
        from account_security_v249382 import setup_professional
        return setup_professional(payload or {})
    except Exception as exc:
        return {"ok": False, "version": "V249383", "note": str(exc)}

@app.post("/api/account/v249383/professional/unlock")
def api_account_v249383_professional_unlock(payload: dict):
    try:
        from account_security_v249382 import unlock_professional
        return unlock_professional(payload or {})
    except Exception as exc:
        return {"ok": False, "version": "V249383", "note": str(exc)}

@app.post("/api/account/v249383/professional/profile/update")
def api_account_v249383_professional_profile_update(payload: dict):
    try:
        from account_security_v249382 import update_professional_profile
        return update_professional_profile(payload or {})
    except Exception as exc:
        return {"ok": False, "version": "V249383", "note": str(exc)}

@app.post("/api/account/v249383/mode")
def api_account_v249383_mode(payload: dict):
    try:
        from account_security_v249382 import set_mode
        return set_mode(payload or {})
    except Exception as exc:
        return {"ok": False, "version": "V249383", "note": str(exc)}

@app.post("/api/account/v249383/professional/password/change")
def api_account_v249383_professional_password_change(payload: dict):
    try:
        from account_security_v249382 import change_password
        return change_password(payload or {})
    except Exception as exc:
        return {"ok": False, "version": "V249383", "note": str(exc)}

@app.post("/api/account/v249383/professional/close")
def api_account_v249383_professional_close(payload: dict):
    try:
        from account_security_v249382 import close_professional
        return close_professional(payload or {})
    except Exception as exc:
        return {"ok": False, "version": "V249383", "note": str(exc)}

@app.post("/api/account/v249383/professional/session/validate")
def api_account_v249383_professional_session_validate(payload: dict):
    try:
        from account_security_v249382 import validate_session
        return validate_session(payload or {})
    except Exception as exc:
        return {"ok": False, "version": "V249383", "note": str(exc)}

@app.get("/api/professional/v249383/diagnostics")
def api_professional_v249383_diagnostics():
    try:
        from account_security_v249382 import diagnostics
        details = diagnostics()
        return {
            "ok": True,
            "version": "V249383-professional-center-analytics-live-wall",
            "visible_profile_button": True,
            "password_required_each_entry": True,
            "tools": ["V405", "V406", "V407", "V408", "V409", "V410", "V411"],
            "account_security": details,
            "database_deleted": False,
            "media_deleted": False,
        }
    except Exception as exc:
        return {"ok": False, "version": "V249383", "note": str(exc)}
# END V249383_PROFESSIONAL_CENTER_ANALYTICS_LIVE_WALL_ROUTES

# BEGIN V249384_PROFESSIONAL_ACTIVATION_BACKUP_FEED_CLEAN_ROUTES
@app.get("/api/professional/v249384/diagnostics")
def api_professional_v249384_diagnostics():
    return {
        "ok": True,
        "version": "V249384-professional-activation-backup-feed-clean",
        "persistent_profile_entry": True,
        "same_password_for_entry": True,
        "setup_closes_after_activation": True,
        "activation_status_visible": True,
        "no_server_password_recovery_warning": True,
        "ask_private_file_save": True,
        "android_private_file_export": True,
        "left_feed_message_video_voice_rail_removed": True,
        "right_special_action_rail_preserved": True,
        "database_deleted": False,
        "media_deleted": False,
    }
# END V249384_PROFESSIONAL_ACTIVATION_BACKUP_FEED_CLEAN_ROUTES

# BEGIN V249385_STABLE_PROFESSIONAL_GLOBAL_FRIEND_GATE_ROUTES
@app.get("/api/professional/v249385/diagnostics")
def api_professional_v249385_diagnostics():
    from pathlib import Path as _V249385Path
    account_db = _V249385Path(ROOT) / "data" / "account_security_v249382.sqlite3"
    enabled = 0
    try:
        import sqlite3 as _v249385_sqlite3
        if account_db.exists():
            con = _v249385_sqlite3.connect(str(account_db), timeout=5)
            try:
                row = con.execute("SELECT COUNT(*) FROM accounts_v249382 WHERE professional_enabled=1").fetchone()
                enabled = int((row or [0])[0] or 0)
            finally:
                con.close()
    except Exception:
        enabled = -1
    route_paths = {str(getattr(route, "path", "")) for route in getattr(app, "routes", [])}
    return {
        "ok": True,
        "version": "V249385-stable-professional-global-friend-gate",
        "professional_center": {
            "persistent_activation_memory": True,
            "stale_status_responses_ignored": True,
            "fixed_device_identity_per_runtime": True,
            "legacy_v249384_device_identity_reconciled": True,
            "activation_does_not_return_to_setup": True,
            "enabled_accounts": enabled,
            "account_database_exists": account_db.exists(),
        },
        "friendship_gate": {
            "global_frontend_gate": True,
            "message": True,
            "voice_call": True,
            "video_call": True,
            "dm_photo_video_file_audio": True,
            "friend_state_route": "/api/social/friend/state_v249380" in route_paths,
            "server_dm_guard": True,
            "server_call_guard": True,
            "auto_open_after_acceptance": True,
        },
        "feed": {
            "right_avatar_member_menu_preserved": True,
            "left_message_video_voice_rail_removed": True,
            "right_special_action_rail_preserved": True,
        },
    }
# END V249385_STABLE_PROFESSIONAL_GLOBAL_FRIEND_GATE_ROUTES

# BEGIN V249386_PRO_CODE_CONFIRMATION_GLOBAL_FRIEND_CONTINUATION_ROUTES
@app.get("/api/professional/v249386/diagnostics")
def api_professional_v249386_diagnostics():
    from pathlib import Path as _V249386Path
    account_db = _V249386Path(ROOT) / "data" / "account_security_v249382.sqlite3"
    enabled = 0
    try:
        import sqlite3 as _v249386_sqlite3
        if account_db.exists():
            con = _v249386_sqlite3.connect(str(account_db), timeout=5)
            try:
                row = con.execute("SELECT COUNT(*) FROM accounts_v249382 WHERE professional_enabled=1").fetchone()
                enabled = int((row or [0])[0] or 0)
            finally:
                con.close()
    except Exception:
        enabled = -1
    route_paths = {str(getattr(route, "path", "")) for route in getattr(app, "routes", [])}
    return {
        "ok": True,
        "version": "V249386-professional-code-confirmation-global-friend-continuation",
        "professional_code": {
            "minimum_characters": 8,
            "maximum_characters": 128,
            "requires_letter": True,
            "requires_number": True,
            "double_entry_required": True,
            "server_compares_both_entries": True,
            "same_code_used_for_each_entry": True,
            "persistent_activation_memory": True,
            "enabled_accounts": enabled,
            "account_database_exists": account_db.exists(),
        },
        "friendship_gate": {
            "global_frontend_gate": True,
            "message": True,
            "voice_call": True,
            "video_call": True,
            "feed_avatar_menu": True,
            "dm_photo_video_file_audio": True,
            "friend_state_route": "/api/social/friend/state_v249380" in route_paths,
            "server_dm_guard": True,
            "server_call_guard": True,
            "pending_action_minutes": 15,
            "auto_open_after_acceptance": True,
            "recheck_on_accept_focus_online_visibility": True,
        },
        "preserved": {
            "v405_to_v410": True,
            "v411_live_wall": True,
            "lexicon": True,
            "right_feed_action_rail": True,
            "left_feed_communication_rail_removed": True,
        },
    }
# END V249386_PRO_CODE_CONFIRMATION_GLOBAL_FRIEND_CONTINUATION_ROUTES

# BEGIN V249387_CREATOR_PUBLIC_LINK_PUBLICATION_WORKSPACE_ROUTES
@app.post("/api/creator/public/v249387/status")
def api_creator_public_v249387_status(payload: dict):
    try:
        from creator_public_v249387 import status
        return status(payload or {})
    except Exception as exc:
        return {"ok": False, "version": "V249387", "note": str(exc)}

@app.post("/api/creator/public/v249387/save")
def api_creator_public_v249387_save(payload: dict):
    try:
        from creator_public_v249387 import save_profile
        return save_profile(payload or {})
    except Exception as exc:
        return {"ok": False, "version": "V249387", "note": str(exc)}

@app.post("/api/creator/public/v249387/analytics")
def api_creator_public_v249387_analytics(payload: dict):
    try:
        from creator_public_v249387 import analytics
        return analytics(payload or {})
    except Exception as exc:
        return {"ok": False, "version": "V249387", "note": str(exc)}

@app.get("/api/creator/public/v249387/profile/{creator_slug}")
def api_creator_public_v249387_profile(creator_slug: str):
    try:
        from creator_public_v249387 import public_data
        data = public_data(creator_slug)
        if not data:
            raise HTTPException(404, "creator_public_profile_not_found")
        return {"ok": True, "version": "V249387", **data}
    except HTTPException:
        raise
    except Exception as exc:
        raise HTTPException(500, str(exc))

@app.get("/api/creator/public/v249387/diagnostics")
def api_creator_public_v249387_diagnostics():
    try:
        from creator_public_v249387 import diagnostics
        return diagnostics()
    except Exception as exc:
        return {"ok": False, "version": "V249387", "note": str(exc)}

@app.get("/@{creator_slug}", response_class=HTMLResponse)
def creator_public_page_v249387(creator_slug: str, request: Request, src: str = Query("direct")):
    from creator_public_v249387 import public_page
    forwarded = str(request.headers.get("x-forwarded-for") or "").split(",", 1)[0].strip()
    client_ip = forwarded or (request.client.host if request.client else "")
    page = public_page(
        creator_slug,
        source=src,
        ip=client_ip,
        user_agent=str(request.headers.get("user-agent") or ""),
    )
    if not page:
        raise HTTPException(404, "creator_public_profile_not_found")
    return HTMLResponse(
        content=page,
        headers={
            "Cache-Control": "no-store, max-age=0",
            "X-Content-Type-Options": "nosniff",
            "Referrer-Policy": "strict-origin-when-cross-origin",
            "X-Frame-Options": "SAMEORIGIN",
        },
    )
# END V249387_CREATOR_PUBLIC_LINK_PUBLICATION_WORKSPACE_ROUTES

# BEGIN V249389_SECURE_DM_ANDROID_FOUNDATION_ROUTES
try:
    import secure_messaging_v249389 as _secure_v249389
except Exception:
    _secure_v249389 = None
    LOG.exception("V249389 secure messaging module import failed")


def _secure_require_module_v249389():
    if _secure_v249389 is None:
        raise HTTPException(503, "secure_messaging_v249389_unavailable")
    return _secure_v249389


def _secure_dm_peer_v249389(channel: str, device_id: str) -> str:
    parts = [str(x or "").strip() for x in str(channel or "").split(":")]
    if len(parts) != 3 or parts[0] != "dm":
        raise HTTPException(400, "secure_messages_require_direct_channel")
    members = [x for x in parts[1:] if x]
    if len(members) != 2 or str(device_id) not in members:
        raise HTTPException(403, "device_not_part_of_direct_channel")
    return members[0] if members[1] == str(device_id) else members[1]


def _secure_cleanup_v249389() -> int:
    module = _secure_require_module_v249389()
    ids = module.expired_message_ids()
    if not ids:
        return 0
    deleted = 0
    for message_id in ids:
        try:
            cur = execute(
                "DELETE FROM messages WHERE message_id=? AND is_encrypted=1 AND iv='v249389:rsa-oaep-aes-gcm'",
                (message_id,),
            )
            deleted += max(0, int(getattr(cur, "rowcount", 0) or 0))
        except Exception:
            LOG.exception("V249389 secure message cleanup failed for %s", message_id)
    module.delete_meta(ids)
    return deleted


def _secure_person_public_v249389(room_id: str, device_id: str) -> dict:
    try:
        row = one(
            "SELECT nickname,avatar,last_seen FROM people WHERE room_id=? AND device_id=? LIMIT 1",
            (room_id, device_id),
        )
        if row:
            return {
                "device_id": device_id,
                "nickname": str(row["nickname"] or "Μέλος"),
                "avatar": str(row["avatar"] or ""),
                "last_seen": float(row["last_seen"] or 0),
            }
    except Exception:
        pass
    return {"device_id": device_id, "nickname": "Μέλος", "avatar": "", "last_seen": 0}


def secure_call_push_v249389(room_id: str, callee_id: str, caller_id: str, session_id: str, call_type: str) -> dict:
    module = _secure_require_module_v249389()
    event_id, nonce = module.create_call_wake(room_id, callee_id, caller_id, session_id, call_type)
    return push_notify_devices(
        room_id,
        [callee_id],
        "OK ITHAKI",
        "",
        {
            "kind": "secure_call_wake",
            "event_id": event_id,
            "nonce": nonce,
            "room_id": room_id,
        },
    )


@app.post("/api/secure/device/register_v249389")
def api_secure_device_register_v249389(payload: dict):
    module = _secure_require_module_v249389()
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    device_id = str(payload.get("device_id") or "")[:180]
    security_member_guard(room_id, device_id, "secure_key_register", max_per_minute=8, max_per_hour=40)
    if is_banned(room_id, device_id):
        raise HTTPException(403, "member_blocked")
    try:
        return module.register_device_key(payload or {})
    except module.SecureMessagingError as exc:
        raise HTTPException(400, str(exc))


@app.get("/api/secure/device/key_v249389")
def api_secure_device_key_v249389(
    room_id: str = Query("ithaki-phone-test"),
    requester_id: str = Query(...),
    peer_id: str = Query(...),
):
    module = _secure_require_module_v249389()
    requester_id = str(requester_id or "")[:180]
    peer_id = str(peer_id or "")[:180]
    if not requester_id or not peer_id or requester_id == peer_id:
        raise HTTPException(400, "invalid_secure_key_request")
    if _is_user_blocked_between(room_id, requester_id, peer_id):
        raise HTTPException(403, "blocked_by_member")
    if not _are_friends_v249227(room_id, requester_id, peer_id):
        raise HTTPException(status_code=403, detail={"ok": False, "reason": "friend_required", "message": "Απαιτείται αποδεκμένη φιλία."})
    item = module.get_device_key(room_id, peer_id)
    if not item:
        raise HTTPException(status_code=409, detail={"ok": False, "reason": "peer_secure_key_missing", "message": "Ο φίλος πρέπει να ανοίξει πρώτα την έκδοση V249389 στο κινητό του."})
    return {"ok": True, "version": module.VERSION, "peer_id": peer_id, **item}


@app.get("/api/secure/settings_v249389")
def api_secure_settings_get_v249389(
    room_id: str = Query("ithaki-phone-test"),
    device_id: str = Query(...),
):
    module = _secure_require_module_v249389()
    return module.get_settings(room_id, device_id)


@app.post("/api/secure/settings_v249389")
def api_secure_settings_save_v249389(payload: dict):
    module = _secure_require_module_v249389()
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    device_id = str(payload.get("device_id") or "")[:180]
    security_member_guard(room_id, device_id, "secure_settings", max_per_minute=12, max_per_hour=80)
    try:
        return module.save_settings(payload or {})
    except module.SecureMessagingError as exc:
        raise HTTPException(400, str(exc))


@app.post("/api/secure/messages/send_v249389")
def api_secure_messages_send_v249389(payload: dict):
    module = _secure_require_module_v249389()
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    channel = str(payload.get("channel") or "")[:400]
    sender_id = str(payload.get("sender_id") or "")[:180]
    recipient_id = str(payload.get("recipient_id") or "")[:180]
    nickname = str(payload.get("nickname") or "Μέλος")[:80]
    envelope = str(payload.get("envelope") or "")
    message_type = str(payload.get("message_type") or "message")[:40]
    settings = module.normalize_settings(payload or {})
    if message_type not in ("message", "photo", "video", "file", "audio"):
        message_type = "message"
    peer_id = _secure_dm_peer_v249389(channel, sender_id)
    if peer_id != recipient_id:
        raise HTTPException(400, "recipient_channel_mismatch")
    security_member_guard(room_id, sender_id, "secure_message", max_per_minute=12, max_per_hour=80)
    if is_banned(room_id, sender_id):
        raise HTTPException(403, "member_blocked")
    if _is_user_blocked_between(room_id, sender_id, recipient_id):
        raise HTTPException(403, "blocked_by_member")
    if not _are_friends_v249227(room_id, sender_id, recipient_id):
        raise HTTPException(status_code=403, detail={"ok": False, "reason": "friend_required", "message": "Πρώτα χρειάζεται αποδοχή φιλίας για ασφαλές μήνυμα."})
    if not module.get_device_key(room_id, sender_id):
        raise HTTPException(409, "sender_secure_key_missing")
    if not module.get_device_key(room_id, recipient_id):
        raise HTTPException(status_code=409, detail={"ok": False, "reason": "peer_secure_key_missing", "message": "Ο φίλος πρέπει να ανοίξει πρώτα την έκδοση V249389 στο κινητό του."})
    try:
        module.validate_envelope(envelope, room_id, channel, sender_id, recipient_id)
    except module.SecureMessagingError as exc:
        raise HTTPException(400, str(exc))

    _secure_cleanup_v249389()
    message_id = nid("smsg")
    created_at = now()
    storage_mode = settings["storage_mode"]
    if storage_mode == "zero_online_only":
        person = _secure_person_public_v249389(room_id, recipient_id)
        if float(person.get("last_seen") or 0) < created_at - 45:
            raise HTTPException(status_code=409, detail={"ok": False, "reason": "recipient_offline_zero_storage", "message": "Η μηδενική αποθήκευση λειτουργεί μόνο όταν ο φίλος είναι online."})
        module.queue_zero_message({
            "message_id": message_id,
            "room_id": room_id,
            "channel": channel,
            "sender_id": sender_id,
            "nickname": nickname,
            "language": "el",
            "text": "",
            "is_encrypted": True,
            "iv": "v249389:rsa-oaep-aes-gcm",
            "cipher_text": envelope,
            "created_at": created_at,
            "recipient_id": recipient_id,
            "secure_meta": {
                "message_type": message_type,
                "auto_delete_seconds": settings["auto_delete_seconds"],
                "storage_mode": storage_mode,
                "read_at": 0,
                "expires_at": 0,
            },
        })
        try:
            ws_notify(room_id)
        except Exception:
            pass
        return {
            "ok": True,
            "version": module.VERSION,
            "message_id": message_id,
            "storage_mode": storage_mode,
            "server_persisted": False,
            "fcm_sent": False,
        }

    execute(
        """
        INSERT INTO messages(message_id,room_id,channel,sender_id,nickname,language,text,is_encrypted,iv,cipher_text,created_at)
        VALUES(?,?,?,?,?,?,?,?,?,?,?)
        """,
        (message_id, room_id, channel, sender_id, nickname, "el", "", 1, "v249389:rsa-oaep-aes-gcm", envelope, created_at),
    )
    nonce, nonce_hash = module.new_wake_token()
    module.record_persistent_message(
        message_id,
        room_id,
        channel,
        sender_id,
        recipient_id,
        message_type,
        settings["auto_delete_seconds"],
        storage_mode,
        nonce_hash,
    )
    push = push_notify_devices(
        room_id,
        [recipient_id],
        "OK ITHAKI",
        "",
        {
            "kind": "secure_message_wake",
            "event_id": message_id,
            "nonce": nonce,
            "room_id": room_id,
        },
    )
    try:
        ws_notify(room_id)
    except Exception:
        pass
    return {
        "ok": True,
        "version": module.VERSION,
        "message_id": message_id,
        "storage_mode": storage_mode,
        "server_persisted": True,
        "push": push,
    }


@app.get("/api/secure/messages_v249389")
def api_secure_messages_get_v249389(
    room_id: str = Query("ithaki-phone-test"),
    channel: str = Query(...),
    viewer_id: str = Query(...),
    since: float = Query(0),
    limit: int = Query(300, ge=1, le=500),
):
    module = _secure_require_module_v249389()
    peer_id = _secure_dm_peer_v249389(channel, viewer_id)
    if _is_user_blocked_between(room_id, viewer_id, peer_id):
        raise HTTPException(403, "blocked_by_member")
    if not _are_friends_v249227(room_id, viewer_id, peer_id):
        raise HTTPException(status_code=403, detail={"ok": False, "reason": "friend_required"})
    _secure_cleanup_v249389()
    result = []
    for row in rows(
        """
        SELECT * FROM messages WHERE room_id=? AND channel=? AND created_at>?
        ORDER BY created_at ASC LIMIT ?
        """,
        (room_id, channel, since, limit),
    ):
        item = dict(row)
        item["is_encrypted"] = bool(item.get("is_encrypted"))
        person = _secure_person_public_v249389(room_id, str(item.get("sender_id") or ""))
        item["avatar"] = person.get("avatar") or ""
        item["photo"] = item["avatar"]
        result.append(item)
    zero = module.pop_zero_messages(room_id, channel, viewer_id)
    result.extend(zero)
    result.sort(key=lambda item: float(item.get("created_at") or 0))
    meta = module.meta_for_messages([str(item.get("message_id") or "") for item in result])
    for item in result:
        message_id = str(item.get("message_id") or "")
        if "secure_meta" not in item and message_id in meta:
            item["secure_meta"] = meta[message_id]
    return {
        "ok": True,
        "version": module.VERSION,
        "messages": result[-limit:],
        "zero_storage_delivered": len(zero),
        "server_plaintext_content": False,
    }


@app.post("/api/secure/messages/read_v249389")
def api_secure_messages_read_v249389(payload: dict):
    module = _secure_require_module_v249389()
    room_id = str(payload.get("room_id") or "ithaki-phone-test")[:120]
    channel = str(payload.get("channel") or "")[:400]
    message_id = str(payload.get("message_id") or "")[:180]
    reader_id = str(payload.get("reader_id") or "")[:180]
    _secure_dm_peer_v249389(channel, reader_id)
    try:
        result = module.mark_read(message_id, reader_id)
    except module.SecureMessagingError as exc:
        raise HTTPException(403, str(exc))
    _secure_cleanup_v249389()
    try:
        ws_notify(room_id)
    except Exception:
        pass
    return {"version": module.VERSION, **result}


@app.get("/api/secure/wake_metadata_v249389")
def api_secure_wake_metadata_v249389(
    room_id: str = Query("ithaki-phone-test"),
    recipient_id: str = Query(...),
    event_id: str = Query(...),
    nonce: str = Query(...),
):
    module = _secure_require_module_v249389()
    item = module.verify_wake_token(event_id, recipient_id, nonce)
    if item:
        sender_id = str(item.get("sender_id") or "")
        if not _are_friends_v249227(room_id, sender_id, recipient_id):
            raise HTTPException(403, "friendship_no_longer_accepted")
        person = _secure_person_public_v249389(room_id, sender_id)
        return {
            "ok": True,
            "version": module.VERSION,
            "event_kind": "message",
            "sender_id": sender_id,
            "sender_name": person.get("nickname") or "Μέλος",
            "sender_avatar": person.get("avatar") or "",
            "channel": str(item.get("channel") or ""),
            "message_type": str(item.get("message_type") or "message"),
            "friend_accepted": True,
        }
    call = module.verify_call_wake(event_id, recipient_id, nonce)
    if call:
        caller_id = str(call.get("caller_id") or "")
        if not _are_friends_v249227(room_id, caller_id, recipient_id):
            raise HTTPException(403, "friendship_no_longer_accepted")
        person = _secure_person_public_v249389(room_id, caller_id)
        call_type = "voice" if str(call.get("call_type")) == "voice" else "video"
        return {
            "ok": True,
            "version": module.VERSION,
            "event_kind": "call",
            "sender_id": caller_id,
            "sender_name": person.get("nickname") or "Μέλος",
            "sender_avatar": person.get("avatar") or "",
            "session_id": str(call.get("session_id") or ""),
            "call_type": call_type,
            "friend_accepted": True,
        }
    raise HTTPException(404, "secure_wake_event_not_found_or_expired")


@app.get("/api/secure/diagnostics_v249389")
def api_secure_diagnostics_v249389(
    room_id: str = Query("ithaki-phone-test"),
    device_id: str = Query(""),
):
    module = _secure_require_module_v249389()
    data = module.diagnostics()
    data.update({
        "room_id": room_id,
        "device_key_registered": bool(module.get_device_key(room_id, device_id)) if device_id else False,
        "android_keystore_required": True,
        "anti_screenshot_requires_apk_v249389": True,
        "fcm_secure_wake_data_only": True,
        "voice_mask_recorded_audio": "not_enabled_until_device_test",
        "voice_mask_live_calls": False,
        "deploy_deleted_existing_messages": False,
    })
    return data
# END V249389_SECURE_DM_ANDROID_FOUNDATION_ROUTES

# BEGIN V249383_V411_INSTALL
try:
    from v411_live_wall_v249383 import install_v411 as _install_v411_v249383
    _install_v411_v249383(app, ROOT)
except Exception:
    LOG.exception("V249383 V411 live wall reactions failed to install")
# END V249383_V411_INSTALL

# BEGIN V249390_FRIEND_IDENTITY_ACCEPTANCE
try:
    from friend_identity_v249390 import install_friend_identity_v249390 as _install_friend_identity_v249390
    _install_friend_identity_v249390(
        app,
        one=one,
        rows=rows,
        execute=execute,
        now=now,
        push_notify_devices=push_notify_devices,
        person_lookup=_person_v249223,
        friend_request_handler=api_social_friend_request,
        ws_notify=ws_notify,
    )
except Exception:
    LOG.exception("V249390 friend identity/acceptance failed to install")
# END V249390_FRIEND_IDENTITY_ACCEPTANCE

# BEGIN V249391_UNIFIED_FRIEND_COMMUNICATION_SECURE
# Frontend controller owns proposal/accept/reject/message/voice/video/secure-lock routing.
# END V249391_UNIFIED_FRIEND_COMMUNICATION_SECURE


# BEGIN V249396_PROFESSIONAL_SECURITY_MODES_TOR_READY_ROUTES
try:
    import communication_security_v249396 as _commsec_v249396
except Exception:
    _commsec_v249396 = None
    LOG.exception("V249396 communication security module import failed")

def _comm396_module():
    if _commsec_v249396 is None:
        raise HTTPException(503,"communication_security_v249396_unavailable")
    return _commsec_v249396

def _comm396_peer(channel: str, device_id: str) -> str:
    parts=[str(x or '').strip() for x in str(channel or '').split(':')]
    if len(parts)!=3 or parts[0]!='dm':raise HTTPException(400,'direct_channel_required')
    if str(device_id) not in parts[1:]:raise HTTPException(403,'device_not_part_of_direct_channel')
    return parts[2] if parts[1]==str(device_id) else parts[1]

def _comm396_person(room_id: str, device_id: str) -> dict:
    try:
        r=one("SELECT nickname,avatar,last_seen FROM people WHERE room_id=? AND device_id=? LIMIT 1",(room_id,device_id))
        if r:return {'device_id':device_id,'nickname':str(r['nickname'] or 'Μέλος'),'avatar':str(r['avatar'] or ''),'last_seen':float(r['last_seen'] or 0)}
    except Exception:pass
    return {'device_id':device_id,'nickname':'Μέλος','avatar':'','last_seen':0}

def _comm396_cleanup() -> int:
    m=_comm396_module();ids=m.expired_message_ids();deleted=0
    for mid in ids:
        try:
            cur=execute("DELETE FROM messages WHERE message_id=?",(mid,));deleted+=max(0,int(getattr(cur,'rowcount',0) or 0))
        except Exception:LOG.exception('V249396 cleanup failed for %s',mid)
    m.delete_meta(ids);return deleted

@app.get('/api/communication/security_mode_v249396')
def api_communication_security_mode_get_v249396(room_id: str=Query('ithaki-phone-test'),channel: str=Query(...),viewer_id: str=Query(...)):
    m=_comm396_module();peer=_comm396_peer(channel,viewer_id)
    if _is_user_blocked_between(room_id,viewer_id,peer):raise HTTPException(403,'blocked_by_member')
    if not _are_friends_v249227(room_id,viewer_id,peer):raise HTTPException(status_code=403,detail={'ok':False,'reason':'friend_required'})
    return m.get_mode(room_id,channel)

@app.post('/api/communication/security_mode_v249396')
def api_communication_security_mode_save_v249396(payload: dict):
    m=_comm396_module();room_id=str(payload.get('room_id') or 'ithaki-phone-test')[:120];channel=str(payload.get('channel') or '')[:400];device_id=str(payload.get('device_id') or '')[:180];peer=_comm396_peer(channel,device_id)
    security_member_guard(room_id,device_id,'conversation_security_mode',max_per_minute=10,max_per_hour=60)
    if _is_user_blocked_between(room_id,device_id,peer):raise HTTPException(403,'blocked_by_member')
    if not _are_friends_v249227(room_id,device_id,peer):raise HTTPException(status_code=403,detail={'ok':False,'reason':'friend_required','message':'Πρώτα χρειάζεται αποδοχή φιλίας.'})
    try:out=m.save_mode(room_id,channel,device_id,str(payload.get('mode') or ''),payload.get('plaintext_ack') is True)
    except m.CommunicationSecurityError as exc:raise HTTPException(400,str(exc))
    try:ws_notify(room_id)
    except Exception:pass
    return out

@app.post('/api/communication/messages/send_v249396')
def api_communication_messages_send_v249396(payload: dict):
    m=_comm396_module();room_id=str(payload.get('room_id') or 'ithaki-phone-test')[:120];channel=str(payload.get('channel') or '')[:400];sender_id=str(payload.get('sender_id') or '')[:180];recipient_id=str(payload.get('recipient_id') or '')[:180];nickname=str(payload.get('nickname') or 'Μέλος')[:80];peer=_comm396_peer(channel,sender_id)
    if peer!=recipient_id:raise HTTPException(400,'recipient_channel_mismatch')
    security_member_guard(room_id,sender_id,'communication_message_v249396',max_per_minute=12,max_per_hour=80)
    if is_banned(room_id,sender_id):raise HTTPException(403,'member_blocked')
    if _is_user_blocked_between(room_id,sender_id,recipient_id):raise HTTPException(403,'blocked_by_member')
    if not _are_friends_v249227(room_id,sender_id,recipient_id):raise HTTPException(status_code=403,detail={'ok':False,'reason':'friend_required','message':'Πρώτα χρειάζεται αποδοχή φιλίας.'})
    stored=m.get_mode(room_id,channel);mode=m.normalize_mode(payload.get('security_mode'))
    if stored.get('updated_at',0)>0 and mode!=stored.get('mode'):raise HTTPException(status_code=409,detail={'ok':False,'reason':'security_mode_changed','message':'Ο τρόπος προστασίας άλλαξε. Άνοιξε ξανά την κλειδαριά.'})
    if mode=='full_e2ee':raise HTTPException(400,'full_e2ee_uses_secure_v249389_endpoint')
    is_encrypted=bool(payload.get('is_encrypted'));text=str(payload.get('text') or '');iv=str(payload.get('iv') or '');cipher=str(payload.get('cipher_text') or '')
    if mode=='standard_aes':
        if not is_encrypted or not iv or not cipher:raise HTTPException(400,'standard_aes_requires_ciphertext')
        text=''
    else:
        if payload.get('plaintext_ack') is not True:raise HTTPException(400,'plaintext_ack_required')
        if is_encrypted:raise HTTPException(400,'no_encryption_must_be_plaintext')
        text=text.strip()
        if not text:raise HTTPException(400,'empty_message')
        if len(text)>2500000:raise HTTPException(413,'message_too_large')
    ttl=m.normalize_ttl(payload.get('auto_delete_seconds'));storage=m.normalize_storage(payload.get('storage_mode'));message_type=str(payload.get('message_type') or 'message')[:40]
    if message_type not in ('message','photo','video','audio','file'):message_type='message'
    mid=nid('msg');created=now();item={'message_id':mid,'room_id':room_id,'channel':channel,'sender_id':sender_id,'nickname':nickname,'language':'el','text':text,'is_encrypted':is_encrypted,'iv':iv,'cipher_text':cipher,'created_at':created,'recipient_id':recipient_id,'security_mode':mode,'security_meta':{'mode':mode,'message_type':message_type,'auto_delete_seconds':ttl,'storage_mode':storage,'read_at':0,'expires_at':0}}
    if storage=='zero_online_only':
        person=_comm396_person(room_id,recipient_id)
        if float(person.get('last_seen') or 0)<created-45:raise HTTPException(status_code=409,detail={'ok':False,'reason':'recipient_offline_zero_storage','message':'Η μηδενική αποθήκευση λειτουργεί μόνο όταν ο φίλος είναι online.'})
        m.queue_zero(item)
        try:ws_notify(room_id)
        except Exception:pass
        return {'ok':True,'version':m.VERSION,'message_id':mid,'security_mode':mode,'server_persisted':False,'fcm_content_included':False}
    execute("INSERT INTO messages(message_id,room_id,channel,sender_id,nickname,language,text,is_encrypted,iv,cipher_text,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",(mid,room_id,channel,sender_id,nickname,'el',text,1 if is_encrypted else 0,iv,cipher,created))
    event_id,nonce=m.new_wake(room_id,channel,sender_id,recipient_id,mode,message_type);m.record_message(mid,room_id,channel,sender_id,recipient_id,mode,message_type,ttl,storage,'')
    push=push_notify_devices(room_id,[recipient_id],'OK ITHAKI','',{'kind':'communication_message_wake','event_id':event_id,'nonce':nonce,'room_id':room_id})
    try:ws_notify(room_id)
    except Exception:pass
    return {'ok':True,'version':m.VERSION,'message_id':mid,'security_mode':mode,'server_persisted':True,'push':push,'fcm_content_included':False}

@app.get('/api/communication/messages_v249396')
def api_communication_messages_get_v249396(room_id: str=Query('ithaki-phone-test'),channel: str=Query(...),viewer_id: str=Query(...),since: float=Query(0),limit: int=Query(300,ge=1,le=500)):
    m=_comm396_module();peer=_comm396_peer(channel,viewer_id)
    if _is_user_blocked_between(room_id,viewer_id,peer):raise HTTPException(403,'blocked_by_member')
    if not _are_friends_v249227(room_id,viewer_id,peer):raise HTTPException(status_code=403,detail={'ok':False,'reason':'friend_required'})
    _comm396_cleanup()
    try:_secure_cleanup_v249389()
    except Exception:pass
    result=[]
    for r in rows("SELECT * FROM messages WHERE room_id=? AND channel=? AND created_at>? ORDER BY created_at ASC LIMIT ?",(room_id,channel,since,limit)):
        d=dict(r);d['is_encrypted']=bool(d.get('is_encrypted'));p=_comm396_person(room_id,str(d.get('sender_id') or ''));d['avatar']=p.get('avatar') or '';d['photo']=d['avatar'];result.append(d)
    zero=[]
    try:zero.extend(_secure_v249389.pop_zero_messages(room_id,channel,viewer_id))
    except Exception:pass
    zero.extend(m.pop_zero(room_id,channel,viewer_id));result.extend(zero);result.sort(key=lambda x:float(x.get('created_at') or 0))
    ids=[str(x.get('message_id') or '') for x in result];commmeta=m.meta_for_messages(ids)
    try:securemeta=_secure_v249389.meta_for_messages(ids)
    except Exception:securemeta={}
    plaintext=False
    for item in result:
        mid=str(item.get('message_id') or '')
        if str(item.get('iv') or '')=='v249389:rsa-oaep-aes-gcm':mode='full_e2ee'
        elif bool(item.get('is_encrypted')):mode='standard_aes'
        else:mode='no_encryption';plaintext=True
        item['security_mode']=mode
        if mid in commmeta:item['security_meta']=commmeta[mid]
        elif mid in securemeta:item['secure_meta']=securemeta[mid]
    return {'ok':True,'version':m.VERSION,'messages':result[-limit:],'zero_storage_delivered':len(zero),'server_plaintext_content_present':plaintext}

@app.post('/api/communication/messages/read_v249396')
def api_communication_messages_read_v249396(payload: dict):
    m=_comm396_module();room_id=str(payload.get('room_id') or 'ithaki-phone-test')[:120];channel=str(payload.get('channel') or '')[:400];mid=str(payload.get('message_id') or '')[:180];reader=str(payload.get('reader_id') or '')[:180];_comm396_peer(channel,reader)
    row=one("SELECT iv FROM messages WHERE room_id=? AND channel=? AND message_id=? LIMIT 1",(room_id,channel,mid))
    try:
        if row and str(row['iv'] or '')=='v249389:rsa-oaep-aes-gcm':out=_secure_v249389.mark_read(mid,reader)
        else:out=m.mark_read(mid,reader)
    except Exception as exc:raise HTTPException(403,str(exc))
    _comm396_cleanup()
    try:_secure_cleanup_v249389()
    except Exception:pass
    try:ws_notify(room_id)
    except Exception:pass
    return {'version':m.VERSION,**out}

@app.get('/api/communication/wake_metadata_v249396')
def api_communication_wake_metadata_v249396(room_id: str=Query('ithaki-phone-test'),recipient_id: str=Query(...),event_id: str=Query(...),nonce: str=Query(...)):
    m=_comm396_module();item=m.verify_wake(event_id,recipient_id,nonce)
    if not item:raise HTTPException(404,'wake_not_found')
    sender=str(item.get('sender_id') or '')
    if not _are_friends_v249227(room_id,sender,recipient_id):raise HTTPException(403,'friendship_no_longer_accepted')
    p=_comm396_person(room_id,sender)
    return {'ok':True,'version':m.VERSION,'event_kind':'message','sender_id':sender,'sender_name':p.get('nickname') or 'Μέλος','sender_avatar':p.get('avatar') or '','channel':str(item.get('channel') or ''),'message_type':str(item.get('message_type') or 'message'),'security_mode':str(item.get('mode') or 'full_e2ee'),'friend_accepted':True}

@app.get('/api/diagnostics/communication_security_v249396')
def api_diagnostics_communication_security_v249396():
    return _comm396_module().diagnostics()
# END V249396_PROFESSIONAL_SECURITY_MODES_TOR_READY_ROUTES



# BEGIN V249397_PROFESSIONAL_PREVIEW_SECURITY_UNIFIED_WALL_DIAGNOSTIC
@app.get("/api/diagnostics/professional_flow_v249397")
async def diagnostics_professional_flow_v249397():
    return {
        "ok": True,
        "version": "V249397-professional-preview-security-unified-wall",
        "professional_preview_local_open": True,
        "studio_from_preview": True,
        "send_from_preview": True,
        "dm_security_selector_visible": True,
        "security_modes": ["full_e2ee", "standard_aes", "no_encryption"],
        "fcm_message_content_included": False,
        "one_unified_home_wall": True,
        "v411_reactions_in_home_wall": True,
        "professional_wall_duplicate_removed": True,
        "database_deleted": False,
        "media_deleted": False,
    }
# END V249397_PROFESSIONAL_PREVIEW_SECURITY_UNIFIED_WALL_DIAGNOSTIC


# BEGIN V249507_NORMAL_POST_STALE_CREATOR_SCHEDULE_FIX
@app.get("/api/build/v249507")
def api_build_v249507():
    return {
        "ok": True,
        "version": VERSION,
        "normal_flow_send_not_creator_gated": True,
        "stale_creator_schedule_cleared": True,
        "creator_inactive_schedule_falls_back_to_normal_feed": True,
        "explicit_schedule_only": True,
        "post_id_confirmation_preserved": True,
        "database_media_messages_calls_friends_preserved": True,
    }
# END V249507_NORMAL_POST_STALE_CREATOR_SCHEDULE_FIX
