from __future__ import annotations

import asyncio
import json
import base64
import hashlib
import mimetypes
import re
import secrets
import time
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from urllib.parse import urlencode
from zoneinfo import ZoneInfo

import aiohttp
from fastapi import FastAPI, File, Form, Request, UploadFile, status
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from loguru import logger

from .config import settings
from .constants import PLATFORM_VK
from .db import fetch_int_setting, fetch_setting, get_pool
from .security import hash_password, new_token, token_hash, verify_password
from .text_utils import build_publication_text, normalize_hash_tag, parse_categories
from .vk_api import VKAPIClient, normalize_vk_source
from .workers.ai_qualifier import AIQualifierWorker, normalize_model, response_usage
from .workers.ai_writer import AIWriterWorker
from .workers.daily_report import DailyReportWorker
from .workers.max_poster import MAXPoster
from .workers.parser import VKParserWorker
from .workers.site_poster import SitePoster
from .workers.tg_poster import TelegramPoster
from .workers.tg_reactor import TelegramReactor
from .workers.vk_poster import VKPoster
from .workers.vk_storage_uploader import TelegramStorageUploader

COOKIE_NAME = "vk_parser_admin"
VK_OAUTH_VERIFIER_COOKIE = "vk_oauth_verifier"
VK_OAUTH_STATE_COOKIE = "vk_oauth_state"
BASE_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = BASE_DIR.parents[1]
FAVICON_PATH = PROJECT_ROOT / "favi.png"
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
app = FastAPI(title="VK Parser Admin")
LOCAL_TZ = ZoneInfo("Asia/Yekaterinburg")
MODEL_CACHE: dict[str, Any] = {"key": "", "at": 0.0, "models": []}
UPLOAD_ROOT = Path("uploads").resolve()
EDITOR_MEDIA_DIR = UPLOAD_ROOT / "editor_media"
EDITOR_MEDIA_DIR.mkdir(parents=True, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_ROOT)), name="uploads")


@app.get("/favi.png")
async def favicon_png():
    return FileResponse(FAVICON_PATH)


@app.get("/favicon.ico")
async def favicon_ico():
    return FileResponse(FAVICON_PATH, media_type="image/png")

PROVIDER_OPTIONS = [
    {"value": "openrouter", "label": "OpenRouter"},
    {"value": "openai", "label": "OpenAI"},
    {"value": "anthropic", "label": "Anthropic"},
    {"value": "gemini", "label": "Google Gemini"},
    {"value": "openai_compatible", "label": "OpenAI-compatible API"},
]

PROMPT_HINTS = {
    "ai_qualifier_prompt": {
        "title": "Свободная инструкция квалификатора",
        "body": (
            "Здесь можно спокойно менять смысл: роль, тон, критерии пригодности, примеры хороших и плохих постов, "
            "логику оценки по шкале 1-10."
        ),
        "safe": (
            "Можно менять: критерии, примеры, тон причины, пороговые объяснения. "
            "Не добавляй сюда JSON-схему: для неё есть отдельный технический контракт ниже."
        ),
        "input": "На вход уходит JSON-массив постов: id, source, original_url, media_count, media_types, text.",
    },
    "ai_qualifier_contract": {
        "title": "Технический контракт квалификатора",
        "body": (
            "Эта часть защищает парсер от сломанного ответа. Меняй её только если осознанно меняешь формат ответа "
            "и одновременно готов править валидатор в коде."
        ),
        "safe": "Обычно не трогаем. Здесь живут JSON-схема, допустимые decision/reject_tag и правило вернуть результат на каждый id.",
        "input": "Контракт склеивается после свободной инструкции и отправляется как system prompt.",
        "contract": '{"results":[{"id":123,"score":8,"decision":"accepted","reason":"до 10 слов на русском","reject_tag":null}]}',
    },
    "ai_writer_prompt": {
        "title": "Свободная инструкция райтера",
        "body": (
            "Здесь можно менять редакторскую часть: стиль, длину, голос канала, запреты на выдумки, примеры хорошего текста."
        ),
        "safe": (
            "Можно менять: tone of voice, правила фактов, длину, примеры фраз. "
            "Хэштеги руками писать не надо: модель выбирает category, а код сам соберёт #category и #producer_tag."
        ),
        "input": "На вход уходит JSON-объект: categories и posts. В каждом post есть id, producer_name, producer_tag, original_url, qualification_score, media_count, media_types, text.",
    },
    "ai_writer_contract": {
        "title": "Технический контракт райтера",
        "body": (
            "Эта часть фиксирует формат JSON, запрет хэштегов в тексте и выбор категории. "
            "Менять её стоит только вместе с валидатором райтера."
        ),
        "safe": "Обычно не трогаем. Если промпт райтера переписывается, свободную часть меняем выше, контракт оставляем стабильным.",
        "input": "Контракт склеивается после свободной инструкции и отправляется как system prompt.",
        "contract": '{"rewrites":[{"id":123,"category_id":2,"text":"готовый текст до 900 символов без хэштегов; явно упоминает producer_name","notes":"короткая заметка для редактора"}]}',
    },
}

MODEL_FALLBACKS = {
    "openrouter": [
        "openrouter/openai/gpt-4.1-mini",
        "openrouter/anthropic/claude-3.5-sonnet",
        "openrouter/google/gemini-2.5-flash",
    ],
    "openai": ["gpt-4.1-mini", "gpt-4o-mini", "o4-mini"],
    "anthropic": ["anthropic/claude-haiku-4-5-20251001", "anthropic/claude-sonnet-4-20250514"],
    "gemini": ["gemini/gemini-2.5-flash", "gemini/gemini-2.5-pro"],
    "openai_compatible": [],
}

PREFERRED_MODELS = {
    "openrouter": [
        "openrouter/anthropic/claude-sonnet-4",
        "openrouter/anthropic/claude-3.5-sonnet",
        "openrouter/openai/gpt-4.1-mini",
        "openrouter/google/gemini-2.5-flash",
    ],
    "anthropic": [
        "anthropic/claude-haiku-4-5-20251001",
        "anthropic/claude-4-sonnet-20250514",
        "anthropic/claude-sonnet-4-20250514",
        "anthropic/claude-3-7-sonnet-20250219",
        "anthropic/claude-3-5-sonnet-20241022",
        "anthropic/claude-3-5-haiku-20241022",
    ],
    "openai": ["gpt-4.1-mini", "gpt-4o-mini", "o4-mini"],
    "gemini": ["gemini/gemini-2.5-flash", "gemini/gemini-2.5-pro"],
}

CATEGORY_TITLES = {
    "AI Qualifier": "AI-квалификатор",
    "AI Writer": "AI-райтер",
    "TG Poster": "TG-постер",
    "TG Reactor": "TG-реактор",
    "VK Poster": "VK-постер",
    "MAX Poster": "MAX-постер",
    "Daily Report": "Ежедневный отчет",
    "Parser": "Парсер",
    "Uploader": "Аплоадер",
    "VK": "VK API",
    "General": "Общие",
}

CATEGORY_ORDER = {
    "AI Qualifier": 10,
    "AI Writer": 20,
    "TG Poster": 30,
    "TG Reactor": 40,
    "VK Poster": 50,
    "MAX Poster": 51,
    "Site Poster": 52,
    "Daily Report": 55,
    "Parser": 60,
    "VK": 70,
    "Uploader": 80,
    "General": 100,
}

AI_WORKER_ENABLED_SETTINGS = {
    "ai-qualifier": "ai_qualifier_enabled",
    "ai-writer": "ai_writer_enabled",
    "tg-poster": "tg_poster_enabled",
    "tg-reactor": "tg_reactor_enabled",
    "vk-poster": "vk_poster_enabled",
    "max-poster": "max_poster_enabled",
    "site-poster": "site_poster_enabled",
    "daily-report": "daily_report_enabled",
}

SETTING_ORDER = {
    "AI Qualifier": [
        "ai_qualifier_provider",
        "ai_qualifier_model",
        "ai_qualifier_api_key",
        "ai_qualifier_api_base",
        "ai_qualifier_prompt",
        "ai_qualifier_contract",
        "ai_qualifier_batch_size",
        "ai_qualifier_min_score",
        "ai_qualifier_max_text_chars",
        "ai_qualifier_temperature",
        "ai_qualifier_timeout_sec",
        "ai_qualifier_interval_sec",
    ],
    "AI Writer": [
        "ai_writer_provider",
        "ai_writer_model",
        "ai_writer_api_key",
        "ai_writer_api_base",
        "ai_writer_prompt",
        "ai_writer_contract",
        "ai_writer_categories",
        "ai_writer_batch_size",
        "ai_writer_max_text_chars",
        "ai_writer_temperature",
        "ai_writer_timeout_sec",
        "ai_writer_interval_sec",
    ],
    "TG Poster": [
        "tg_poster_bot_token",
        "tg_poster_chat_id",
        "tg_poster_interval_sec",
        "tg_poster_media_group_max_items",
        "tg_poster_caption_limit",
        "tg_poster_message_limit",
        "tg_poster_max_attempts",
        "tg_poster_retry_backoff_max_sec",
        "tg_poster_send_delay_sec",
        "tg_poster_text_overflow_caption",
        "tg_poster_recent_window",
        "tg_poster_category_repeat_penalty",
        "tg_poster_source_repeat_penalty",
    ],
    "TG Reactor": [
        "tg_reactor_bot_tokens",
        "tg_reactor_reactions_json",
        "tg_reactor_since",
        "tg_reactor_delay_sec",
        "tg_reactor_interval_sec",
        "tg_reactor_reaction_pause_sec",
        "tg_reactor_react_all_messages",
        "tg_reactor_max_attempts",
        "tg_reactor_retry_backoff_max_sec",
    ],
    "VK Poster": [
        "vk_poster_app_id",
        "vk_poster_client_secret",
        "vk_poster_access_token",
        "vk_poster_refresh_token",
        "vk_poster_token_device_id",
        "vk_poster_token_expires_at",
        "vk_poster_owner_id",
        "vk_poster_from_group",
        "vk_poster_interval_sec",
        "vk_poster_timeout_sec",
        "vk_poster_max_attempts",
        "vk_poster_retry_backoff_max_sec",
        "vk_poster_send_delay_sec",
        "vk_poster_recent_window",
        "vk_poster_category_repeat_penalty",
        "vk_poster_source_repeat_penalty",
        "vk_poster_dry_run",
    ],
    "MAX Poster": [
        "max_poster_bot_token",
        "max_poster_chat_id",
        "max_poster_api_base_url",
        "max_poster_interval_sec",
        "max_poster_message_limit",
        "max_poster_media_max_items",
        "max_poster_media_process_delay_sec",
        "max_poster_video_ready_attempts",
        "max_poster_video_ready_delay_sec",
        "max_poster_max_attempts",
        "max_poster_retry_backoff_max_sec",
        "max_poster_send_delay_sec",
        "max_poster_recent_window",
        "max_poster_category_repeat_penalty",
        "max_poster_source_repeat_penalty",
        "max_poster_auto_reaction_enabled",
        "max_poster_auto_reaction",
        "max_poster_reaction_path_template",
        "max_poster_dry_run",
    ],
    "Site Poster": [
        "site_poster_interval_sec",
        "site_poster_photo_batch_size",
        "site_poster_video_batch_size",
        "site_poster_ghost_url",
        "site_poster_key_file",
    ],
    "Daily Report": [
        "daily_report_bot_token",
        "daily_report_recipient_ids",
        "daily_report_time",
        "daily_report_interval_sec",
        "daily_report_last_sent_date",
    ],
    "Parser": [
        "parser_interval_sec",
        "parser_new_source_lookback_days",
        "parser_reparse_overlap_minutes",
        "parser_min_text_length",
        "parser_skip_empty_text",
        "parser_skip_no_media",
        "parser_skip_text_too_short",
        "parser_skip_reposts",
        "parser_store_skipped_posts",
        "parser_dedupe_content_hash",
        "parser_source_pause_sec",
    ],
    "VK": [
        "vk_requests_per_second",
        "vk_wall_page_size",
        "vk_api_timeout_total_sec",
        "vk_api_timeout_connect_sec",
        "vk_rate_limit_sleep_sec",
        "vk_api_retry_attempts",
        "vk_api_retry_min_delay_sec",
        "vk_api_retry_max_delay_sec",
    ],
    "Uploader": [
        "tg_media_channel_id",
        "local_bot_api_url",
        "uploader_interval_sec",
        "uploader_download_timeout_sec",
        "media_group_max_items",
        "media_upload_delay_sec",
        "media_post_job_pause_sec",
        "max_media_attempts",
        "tg_retry_attempts",
        "tg_retry_backoff_max_sec",
        "video_max_size_mb",
        "video_max_height",
        "video_max_duration_sec",
        "uploader_yt_dlp_timeout_sec",
    ],
}


def now_utc() -> datetime:
    return datetime.now(timezone.utc)


def redirect(path: str) -> RedirectResponse:
    return RedirectResponse(path, status_code=status.HTTP_303_SEE_OTHER)


@app.middleware("http")
async def add_security_headers(request: Request, call_next):
    response = await call_next(request)
    response.headers.setdefault("X-Frame-Options", "DENY")
    response.headers.setdefault("X-Content-Type-Options", "nosniff")
    response.headers.setdefault("Referrer-Policy", "same-origin")
    response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
    return response


def should_audit_request(request: Request) -> bool:
    path = request.url.path
    if path in {"/health", "/favi.png", "/favicon.ico"}:
        return False
    if path.startswith("/uploads/"):
        return False
    if path == "/login" and request.method == "GET":
        return False
    return request.method in {"GET", "POST"}


@app.middleware("http")
async def add_request_audit(request: Request, call_next):
    user = await get_current_user(request) if should_audit_request(request) else None
    response = await call_next(request)
    if user:
        try:
            await audit(
                user["id"],
                f"request.{request.method.lower()}",
                "http_request",
                None,
                {"status_code": response.status_code},
                request,
            )
        except Exception as exc:
            logger.warning("Could not write request audit log: {}", exc)
    return response


def pkce_challenge(verifier: str) -> str:
    digest = hashlib.sha256(verifier.encode("ascii")).digest()
    return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")


async def bootstrap_admin() -> None:
    """Create or sync the bootstrap admin user from env secrets on every startup."""
    if not settings.admin_bootstrap_login or not settings.admin_bootstrap_password:
        return
    pool = await get_pool()
    login = settings.admin_bootstrap_login.strip().lower()
    exists = await pool.fetchval("SELECT 1 FROM admin_users WHERE login=$1", login)
    if exists:
        # Always sync password from env so rotating the secret takes effect immediately.
        await pool.execute(
            "UPDATE admin_users SET password_hash=$1, is_active=TRUE, updated_at=NOW() WHERE login=$2",
            hash_password(settings.admin_bootstrap_password),
            login,
        )
        logger.info("Bootstrap admin password synced from env: {}", login)
        return
    await pool.execute(
        """
        INSERT INTO admin_users(login, password_hash, role, is_active)
        VALUES($1, $2, 'admin', TRUE)
        """,
        login,
        hash_password(settings.admin_bootstrap_password),
    )
    logger.warning("Bootstrap admin user created: {}", login)


async def get_current_user(request: Request) -> dict | None:
    token = request.cookies.get(COOKIE_NAME)
    if not token:
        return None
    pool = await get_pool()
    row = await pool.fetchrow(
        """
        SELECT s.csrf_token, u.id, u.login, u.role, u.is_active
        FROM admin_sessions s
        JOIN admin_users u ON u.id=s.user_id
        WHERE s.token_hash=$1
          AND s.expires_at > NOW()
          AND u.is_active=TRUE
        """,
        token_hash(token, settings.app_secret_key),
    )
    return dict(row) if row else None


def require_csrf(user: dict, csrf_token: str) -> None:
    if not user or csrf_token != user["csrf_token"]:
        raise PermissionError("bad csrf")


def base_context(request: Request, user: dict | None, **extra: Any) -> dict[str, Any]:
    ctx = {"request": request, "user": user, "app_env": settings.app_env}
    ctx.update(extra)
    return ctx


EDITOR_ALLOWED_PATHS = (
    "/",
    "/raw",
    "/editor",
    "/sources",
    "/uploads",
    "/logout",
    "/login",
    "/favi.png",
    "/favicon.ico",
    "/health",
)


def is_editor_allowed_path(path: str) -> bool:
    return any(path == allowed or path.startswith(f"{allowed}/") for allowed in EDITOR_ALLOWED_PATHS if allowed != "/") or path == "/"


@app.middleware("http")
async def restrict_editor_access(request: Request, call_next):
    user = await get_current_user(request)
    if user and user.get("role") == "editor" and not is_editor_allowed_path(request.url.path):
        if request.method == "GET" and not request.headers.get("hx-request"):
            return redirect("/raw")
        return JSONResponse({"detail": "Forbidden"}, status_code=status.HTTP_403_FORBIDDEN)
    return await call_next(request)


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


def preserved_query(request: Request, exclude: set[str] | None = None) -> list[dict[str, str]]:
    exclude = exclude or {"page"}
    items = []
    for key, value in request.query_params.multi_items():
        if key not in exclude:
            items.append({"key": key, "value": value})
    return items


def query_path(request: Request, **replace: str) -> str:
    values: list[tuple[str, str]] = []
    excluded = set(replace) | {"page"}
    for key, value in request.query_params.multi_items():
        if key not in excluded:
            values.append((key, value))
    for key, value in replace.items():
        if value != "":
            values.append((key, value))
    encoded = urlencode(values)
    return f"{request.url.path}?{encoded}" if encoded else request.url.path


def raw_sort_headers(request: Request, current_sort: str) -> dict[str, dict[str, str | bool]]:
    columns = {
        "id": ("id_asc", "id_desc"),
        "source": ("source_asc", "source_desc"),
        "stage": ("stage_asc", "stage_desc"),
        "post": ("text_asc", "text_desc"),
        "ai": ("score_asc", "score_desc"),
    }
    headers: dict[str, dict[str, str | bool]] = {}
    for key, (asc, desc) in columns.items():
        next_sort = desc if current_sort == asc else asc
        headers[key] = {
            "url": query_path(request, sort=next_sort),
            "active": current_sort in {asc, desc},
            "direction": "asc" if current_sort == asc else "desc" if current_sort == desc else "",
        }
    return headers


def parse_date_filter(value: str) -> date | None:
    value = (value or "").strip()
    if not value:
        return None
    try:
        return datetime.strptime(value, "%Y-%m-%d").date()
    except ValueError:
        return None


def format_dt(value: datetime | None) -> str:
    if not value:
        return ""
    if value.tzinfo is None:
        value = value.replace(tzinfo=timezone.utc)
    return value.astimezone(LOCAL_TZ).strftime("%d.%m.%y %H:%M")


def clamp_int(value: int, min_value: int, max_value: int) -> int:
    return max(min_value, min(max_value, int(value)))


def pagination(page: int, per_page: int, total: int) -> dict[str, int | bool]:
    page = clamp_int(page, 1, 1_000_000)
    per_page = clamp_int(per_page, 10, 200)
    pages = max(1, (int(total) + per_page - 1) // per_page)
    page = min(page, pages)
    return {
        "page": page,
        "per_page": per_page,
        "total": int(total),
        "pages": pages,
        "offset": (page - 1) * per_page,
        "has_prev": page > 1,
        "has_next": page < pages,
    }


FILTER_OPS = {
    "text": [
        {"value": "contains", "label": "содержит"},
        {"value": "eq", "label": "равно"},
        {"value": "neq", "label": "не равно"},
    ],
    "enum": [
        {"value": "eq", "label": "равно"},
        {"value": "neq", "label": "не равно"},
    ],
    "number": [
        {"value": "eq", "label": "="},
        {"value": "gte", "label": ">="},
        {"value": "lte", "label": "<="},
    ],
    "date": [
        {"value": "gte", "label": "от"},
        {"value": "lte", "label": "до"},
        {"value": "eq", "label": "день"},
    ],
}
FILTER_OP_OPTIONS = [
    {"value": "contains", "label": "содержит"},
    {"value": "eq", "label": "равно / день"},
    {"value": "neq", "label": "не равно"},
    {"value": "gte", "label": ">= / от"},
    {"value": "lte", "label": "<= / до"},
]

SOURCE_FIELD_SPECS = {
    "id": {"label": "ID", "expr": "s.id", "type": "number"},
    "platform": {"label": "Площадка", "expr": "s.platform", "type": "enum"},
    "name": {"label": "Название", "expr": "s.name", "type": "text"},
    "tag": {"label": "Тэг", "expr": "COALESCE(s.tag,'')", "type": "text"},
    "url": {"label": "Ссылка", "expr": "s.url", "type": "text"},
    "external_id": {"label": "VK id", "expr": "COALESCE(s.external_id,'')", "type": "text"},
    "active": {"label": "Включён", "expr": "s.active", "type": "enum"},
    "status": {"label": "Статус", "expr": "s.status", "type": "enum"},
    "posts_count": {
        "label": "Постов",
        "expr": "(SELECT COUNT(*) FROM raw_posts rp2 WHERE rp2.source_id=s.id)",
        "type": "number",
    },
    "posts_24h": {
        "label": "Постов за 24ч",
        "expr": "(SELECT COUNT(*) FROM raw_posts rp3 WHERE rp3.source_id=s.id AND rp3.created_at > NOW() - INTERVAL '24 hours')",
        "type": "number",
    },
    "last_parsed_at": {"label": "Последний парсинг", "expr": "timezone('Asia/Yekaterinburg', s.last_parsed_at)::date", "sort_expr": "s.last_parsed_at", "type": "date"},
    "created_at": {"label": "Создан", "expr": "timezone('Asia/Yekaterinburg', s.created_at)::date", "sort_expr": "s.created_at", "type": "date"},
}

RAW_FIELD_SPECS = {
    "id": {"label": "ID", "expr": "rp.id", "type": "number"},
    "source_name": {"label": "Источник", "expr": "s.name", "type": "text"},
    "source_tag": {"label": "Тэг источника", "expr": "COALESCE(s.tag,'')", "type": "text"},
    "platform": {"label": "Площадка", "expr": "rp.platform", "type": "enum"},
    "status": {"label": "Raw статус", "expr": "rp.status", "type": "enum"},
    "original_url": {"label": "Оригинал", "expr": "rp.original_url", "type": "text"},
    "text": {"label": "Текст", "expr": "rp.raw_text", "type": "text"},
    "media_count": {
        "label": "Медиа",
        "expr": "(SELECT COUNT(*) FROM raw_post_media rmf WHERE rmf.raw_post_id=rp.id)",
        "type": "number",
    },
    "posted_at": {"label": "Дата VK", "expr": "timezone('Asia/Yekaterinburg', rp.posted_at)::date", "sort_expr": "rp.posted_at", "type": "date"},
    "created_at": {"label": "Дата загрузки", "expr": "timezone('Asia/Yekaterinburg', rp.created_at)::date", "sort_expr": "rp.created_at", "type": "date"},
    "qualification_status": {"label": "Итог AI", "expr": "COALESCE(rp.qualification_status,'pending')", "type": "enum"},
    "qualification_model_decision": {"label": "Решение модели", "expr": "COALESCE(rp.qualification_model_decision, rp.qualification_decision, '')", "type": "enum"},
    "qualification_score": {"label": "Оценка", "expr": "rp.qualification_score", "type": "number"},
    "qualification_reject_tag": {"label": "Reject tag", "expr": "COALESCE(rp.qualification_reject_tag,'')", "type": "enum"},
    "rewrite_status": {"label": "Статус рерайта", "expr": "COALESCE(rp.rewrite_status,'pending')", "type": "enum"},
    "rewrite_category": {"label": "Категория", "expr": "COALESCE(rp.rewrite_category,'')", "type": "enum"},
    "rewrite_source_tag": {"label": "Хэштег источника", "expr": "COALESCE(rp.rewrite_source_tag,'')", "type": "text"},
}

EDITOR_STATUS_OPTIONS = [
    {"value": "review", "label": "На проверке"},
    {"value": "edited", "label": "Отредактировано"},
    {"value": "accepted", "label": "Принято"},
    {"value": "published", "label": "Опубликовано"},
    {"value": "publish_failed", "label": "Ошибка публикации"},
    {"value": "rejected", "label": "Отклонено"},
]


def field_options(specs: dict[str, dict[str, str]]) -> list[dict[str, str]]:
    return [{"value": key, "label": spec["label"], "type": spec["type"]} for key, spec in specs.items()]


def parse_table_filters(request: Request, specs: dict[str, dict[str, str]], slots: int = 4) -> list[dict[str, str]]:
    qp = request.query_params
    fields = qp.getlist("f_field")
    ops = qp.getlist("f_op")
    values = qp.getlist("f_value")
    rows: list[dict[str, str]] = []
    for i in range(max(slots, len(fields))):
        field = fields[i] if i < len(fields) else ""
        op = ops[i] if i < len(ops) else ""
        value = values[i] if i < len(values) else ""
        if field not in specs:
            field = ""
        field_type = specs[field]["type"] if field else "text"
        allowed_ops = {item["value"] for item in FILTER_OPS[field_type]}
        if op not in allowed_ops:
            op = "contains" if field_type == "text" else "eq"
        rows.append({"field": field, "op": op, "value": value})
    return rows


def parse_table_sorts(request: Request, specs: dict[str, dict[str, str]], slots: int = 3) -> list[dict[str, str]]:
    qp = request.query_params
    fields = qp.getlist("sort_field")
    dirs = qp.getlist("sort_dir")
    rows: list[dict[str, str]] = []
    for i in range(max(slots, len(fields))):
        field = fields[i] if i < len(fields) else ""
        direction = dirs[i] if i < len(dirs) else "desc"
        if field not in specs:
            field = ""
        if direction not in {"asc", "desc"}:
            direction = "desc"
        rows.append({"field": field, "dir": direction})
    return rows


def build_filter_sql(filters: list[dict[str, str]], specs: dict[str, dict[str, str]], start_index: int = 1) -> tuple[list[str], list[Any]]:
    clauses: list[str] = []
    args: list[Any] = []
    index = start_index
    for item in filters:
        field = item.get("field") or ""
        value = str(item.get("value") or "").strip()
        if not field or field not in specs or value == "":
            continue
        spec = specs[field]
        expr = spec["expr"]
        field_type = spec["type"]
        op = item.get("op") or ("contains" if field_type == "text" else "eq")
        if field_type == "text":
            if op == "eq":
                clauses.append(f"{expr} = ${index}")
                args.append(value)
            elif op == "neq":
                clauses.append(f"{expr} <> ${index}")
                args.append(value)
            else:
                clauses.append(f"{expr} ILIKE '%' || ${index} || '%'")
                args.append(value)
        elif field_type == "enum":
            if op == "neq":
                clauses.append(f"{expr} <> ${index}")
            else:
                clauses.append(f"{expr} = ${index}")
            if value.lower() in {"true", "false"}:
                args.append(value.lower() == "true")
            else:
                args.append(value)
        elif field_type == "number":
            try:
                number_value = int(value)
            except ValueError:
                continue
            sign = {"gte": ">=", "lte": "<=", "neq": "<>", "eq": "="}.get(op, "=")
            clauses.append(f"{expr} {sign} ${index}")
            args.append(number_value)
        elif field_type == "date":
            sign = {"gte": ">=", "lte": "<=", "eq": "="}.get(op, "=")
            clauses.append(f"{expr} {sign} ${index}::date")
            args.append(value)
        index += 1
    return clauses, args


def build_sort_sql(sorts: list[dict[str, str]], specs: dict[str, dict[str, str]], default_sql: str) -> str:
    parts: list[str] = []
    seen: set[str] = set()
    for item in sorts:
        field = item.get("field") or ""
        if not field or field not in specs or field in seen:
            continue
        spec = specs[field]
        expr = spec.get("sort_expr") or spec["expr"]
        direction = "ASC" if item.get("dir") == "asc" else "DESC"
        nulls = " NULLS LAST" if direction == "DESC" else " NULLS FIRST"
        parts.append(f"{expr} {direction}{nulls}")
        seen.add(field)
    if parts:
        if "id" not in seen and "id" in specs:
            parts.append(f"{specs['id'].get('sort_expr') or specs['id']['expr']} DESC")
        return ", ".join(part for part in parts if part)
    return default_sql


def selected_values(request: Request, name: str) -> list[str]:
    return [str(value).strip() for value in request.query_params.getlist(name) if str(value).strip()]


def selected_int_values(request: Request, name: str) -> list[int]:
    values: list[int] = []
    for value in selected_values(request, name):
        try:
            values.append(int(value))
        except ValueError:
            continue
    return values


def add_where(clauses: list[str], args: list[Any], sql_template: str, value: Any) -> None:
    args.append(value)
    clauses.append(sql_template.format(i=len(args)))


async def raw_filter_facets(pool) -> dict[str, Any]:
    source_rows = await pool.fetch(
        """
        SELECT s.id, s.name, COALESCE(s.tag, '') AS tag, COUNT(rp.id) AS count
        FROM raw_posts rp
        JOIN sources s ON s.id=rp.source_id
        GROUP BY s.id, s.name, s.tag
        ORDER BY COUNT(rp.id) DESC, s.name ASC
        """
    )
    category_rows = await pool.fetch(
        """
        SELECT COALESCE(final_category, rewrite_category, '') AS value, COUNT(*) AS count
        FROM raw_posts
        WHERE COALESCE(final_category, rewrite_category, '') <> ''
        GROUP BY COALESCE(final_category, rewrite_category, '')
        ORDER BY COUNT(*) DESC, value ASC
        """
    )
    status_rows = await pool.fetch("SELECT status AS value, COUNT(*) AS count FROM raw_posts GROUP BY status ORDER BY count DESC, value")
    qualification_rows = await pool.fetch(
        """
        SELECT COALESCE(qualification_status, 'pending') AS value, COUNT(*) AS count
        FROM raw_posts
        GROUP BY COALESCE(qualification_status, 'pending')
        ORDER BY count DESC, value
        """
    )
    rewrite_rows = await pool.fetch(
        """
        SELECT COALESCE(rewrite_status, 'pending') AS value, COUNT(*) AS count
        FROM raw_posts
        GROUP BY COALESCE(rewrite_status, 'pending')
        ORDER BY count DESC, value
        """
    )
    return {
        "sources": [dict(row) for row in source_rows],
        "categories": [dict(row) for row in category_rows],
        "statuses": [dict(row) for row in status_rows],
        "qualification_statuses": [dict(row) for row in qualification_rows],
        "rewrite_statuses": [dict(row) for row in rewrite_rows],
    }


async def editor_filter_facets(pool) -> dict[str, Any]:
    source_rows = await pool.fetch(
        """
        SELECT s.id, s.name, COALESCE(s.tag, '') AS tag, COUNT(rp.id) AS count
        FROM raw_posts rp
        JOIN sources s ON s.id=rp.source_id
        WHERE rp.rewrite_status='ready'
        GROUP BY s.id, s.name, s.tag
        ORDER BY COUNT(rp.id) DESC, s.name ASC
        """
    )
    category_rows = await pool.fetch(
        """
        SELECT COALESCE(final_category, rewrite_category, '') AS value, COUNT(*) AS count
        FROM raw_posts
        WHERE rewrite_status='ready'
          AND COALESCE(final_category, rewrite_category, '') <> ''
        GROUP BY COALESCE(final_category, rewrite_category, '')
        ORDER BY COUNT(*) DESC, value ASC
        """
    )
    status_rows = await pool.fetch(
        """
        SELECT COALESCE(editorial_status, 'review') AS value, COUNT(*) AS count
        FROM raw_posts
        WHERE rewrite_status='ready'
        GROUP BY COALESCE(editorial_status, 'review')
        ORDER BY count DESC, value
        """
    )
    return {
        "sources": [dict(row) for row in source_rows],
        "categories": [dict(row) for row in category_rows],
        "statuses": [dict(row) for row in status_rows],
    }


def parse_source_line(line: str) -> dict[str, str]:
    raw = (line or "").strip()
    if not raw:
        return {"name": "", "tag": "", "url": "", "error": "Пустая строка"}
    parts = re.split(r"\s+", raw)
    url_index = next((i for i, part in enumerate(parts) if "vk.com/" in part or part.startswith("club")), -1)
    if url_index < 0:
        if len(parts) == 1:
            value = parts[0].strip()
            url = value if value.startswith("http") else f"https://vk.com/{value}"
            return {"name": "", "tag": "", "url": url, "error": ""}
        return {"name": "", "tag": "", "url": "", "error": "Не нашёл ссылку VK"}
    url = parts[url_index].strip()
    before_url = parts[:url_index]
    if len(before_url) >= 2:
        tag = before_url[-1].strip()
        name = " ".join(before_url[:-1]).strip()
    elif len(before_url) == 1:
        name = before_url[0].strip()
        tag = before_url[0].strip()
    else:
        name = ""
        tag = ""
    if url.startswith("vk.com/"):
        url = f"https://{url}"
    return {"name": name, "tag": normalize_hash_tag(tag, ""), "url": url, "error": ""}


async def build_sources_preview(lines_text: str, default_active: bool = True) -> list[dict[str, Any]]:
    lines = [line for line in (lines_text or "").splitlines() if line.strip()]
    parsed = [parse_source_line(line) for line in lines]
    normalized_values = [normalize_vk_source(item["url"]) for item in parsed if item.get("url")]
    pool = await get_pool()
    existing_rows = await pool.fetch(
        """
        SELECT lower(external_id) AS external_id, lower(url) AS url, lower(COALESCE(tag,'')) AS tag
        FROM sources
        WHERE archived_at IS NULL
          AND (lower(external_id)=ANY($1::text[]) OR lower(url)=ANY($2::text[]) OR lower(COALESCE(tag,''))=ANY($3::text[]))
        """,
        [v.lower() for v in normalized_values if v],
        [str(item.get("url") or "").lower() for item in parsed],
        [normalize_hash_tag(str(item.get("tag") or ""), "").lower() for item in parsed if item.get("tag")],
    )
    existing_ids = {str(row["external_id"] or "").lower() for row in existing_rows}
    existing_urls = {str(row["url"] or "").lower() for row in existing_rows}
    existing_tags = {str(row["tag"] or "").lower() for row in existing_rows}

    seen: set[str] = set()
    seen_tags: set[str] = set()
    async with VKAPIClient(rps=2, timeout_total_sec=12, timeout_connect_sec=5, retry_attempts=2) as client:
        preview = []
        for idx, item in enumerate(parsed, start=1):
            url = item.get("url") or ""
            external_id = normalize_vk_source(url) if url else ""
            row = {
                "line_no": idx,
                "platform": PLATFORM_VK,
                "name": item.get("name") or "",
                "tag": normalize_hash_tag(item.get("tag") or external_id, external_id or "source"),
                "url": url,
                "external_id": external_id,
                "external_owner_id": None,
                "active": default_active,
                "ok": False,
                "error": item.get("error") or "",
            }
            key = external_id.lower()
            if not row["error"] and key in seen:
                row["error"] = "Дубль в этом списке"
            if not row["error"] and row["tag"].lower() in seen_tags:
                row["error"] = "Дубль тэга в этом списке"
            if not row["error"] and (key in existing_ids or url.lower() in existing_urls):
                row["error"] = "Уже есть в источниках"
            if not row["error"] and row["tag"].lower() in existing_tags:
                row["error"] = "Такой тэг уже есть"
            if not row["error"] and not external_id:
                row["error"] = "Не удалось разобрать VK-ссылку"
            if not row["error"]:
                try:
                    screen_name, owner_id, resolved_name = await client.resolve_group(url)
                    row["external_id"] = screen_name
                    row["external_owner_id"] = owner_id
                    row["name"] = row["name"] or resolved_name
                    row["tag"] = normalize_hash_tag(row["tag"] or screen_name, screen_name)
                    row["url"] = f"https://vk.com/{screen_name}"
                    row["ok"] = True
                    seen.add(key)
                    seen_tags.add(row["tag"].lower())
                except Exception as exc:
                    row["error"] = str(exc)
            preview.append(row)
    return preview


def pipeline_stage(post: dict[str, Any]) -> dict[str, str]:
    status = str(post.get("status") or "")
    qualification_status = str(post.get("qualification_status") or "").strip().lower()
    if status == "failed":
        return {"key": "failed", "label": "Ошибка", "class": "bad"}
    if status == "skipped":
        return {"key": "skipped", "label": "Пропущен", "class": "muted"}
    if status in {"raw_saved", "storage_pending"}:
        return {"key": status, "label": "Медиа", "class": ""}
    if status != "storage_ready":
        return {"key": status, "label": status or "Raw", "class": ""}
    rewrite_status = str(post.get("rewrite_status") or "").strip().lower()
    if rewrite_status == "ready":
        return {"key": "rewrite_ready", "label": "Рерайт готов", "class": "ok"}
    if rewrite_status == "processing":
        return {"key": "rewrite_processing", "label": "AI пишет", "class": "warn"}
    if rewrite_status == "failed":
        return {"key": "rewrite_failed", "label": "Рерайт ошибка", "class": "bad"}
    if qualification_status in {"accepted"}:
        return {"key": "qualified_accepted", "label": "AI принят", "class": "ok"}
    if qualification_status in {"rejected"}:
        return {"key": "qualified_rejected", "label": "AI отклонён", "class": "bad"}
    if qualification_status == "processing":
        return {"key": "qualification_processing", "label": "AI проверяет", "class": "warn"}
    if qualification_status == "failed":
        return {"key": "qualification_failed", "label": "AI ошибка", "class": "bad"}
    return {"key": "qualification_pending", "label": "Ждёт AI", "class": ""}


def ai_badge(post: dict[str, Any]) -> dict[str, str]:
    status = str(post.get("qualification_status") or "pending").strip().lower()
    score = post.get("qualification_score")
    decision = str(post.get("qualification_decision") or status or "pending")
    reason = str(post.get("qualification_reason") or "")
    if isinstance(score, int):
        if score >= 8:
            css = "ok"
        elif score >= 5:
            css = "warn"
        else:
            css = "bad"
        label = f"{score}/10"
        sublabel = decision
    elif status == "processing":
        css = "warn"
        label = "..."
        sublabel = "processing"
    elif status == "failed":
        css = "bad"
        label = "!"
        sublabel = "failed"
    else:
        css = ""
        label = "AI"
        sublabel = "pending"
    return {"class": css, "label": label, "sublabel": sublabel, "reason": reason}


def prepare_raw_post(row: Any) -> dict[str, Any]:
    post = dict(row)
    media_items = post.get("media_items") or []
    if isinstance(media_items, str):
        try:
            media_items = json.loads(media_items)
        except json.JSONDecodeError:
            media_items = []
    post["media_items"] = media_items
    post["posted_at_fmt"] = format_dt(post.get("posted_at"))
    post["created_at_fmt"] = format_dt(post.get("created_at"))
    post["qualified_at_fmt"] = format_dt(post.get("qualified_at"))
    post["stage"] = pipeline_stage(post)
    post["ai_badge"] = ai_badge(post)
    return post


def prepare_editor_post(row: Any) -> dict[str, Any]:
    post = prepare_raw_post(row)
    category_name = post.get("final_category") or post.get("rewrite_category") or ""
    category_tag = post.get("resolved_category_tag") or post.get("final_category_tag") or post.get("rewrite_category_tag") or category_name
    source_tag = post.get("final_source_tag") or post.get("rewrite_source_tag") or post.get("source_tag") or ""
    post["review_text"] = post.get("final_text") or post.get("rewritten_text") or ""
    post["publication_preview_text"] = build_publication_text(post["review_text"], category_tag, source_tag)
    post["review_category"] = category_name
    post["review_category_tag"] = category_tag
    post["review_source_tag"] = source_tag
    publication_status = post.get("publication_status") or "pending"
    if publication_status == "published":
        post["publication_status_label"] = "опубликовано"
        post["publication_status_class"] = "ok"
    elif publication_status == "publish_failed":
        post["publication_status_label"] = "ошибка публикации"
        post["publication_status_class"] = "bad"
    else:
        post["publication_status_label"] = "не опубликовано"
        post["publication_status_class"] = ""
    platform_rows = post.get("platform_publications") or []
    if isinstance(platform_rows, str):
        try:
            platform_rows = json.loads(platform_rows)
        except json.JSONDecodeError:
            platform_rows = []
    platforms: dict[str, dict[str, Any]] = {}
    for item in platform_rows or []:
        if not isinstance(item, dict) or not item.get("platform"):
            continue
        platforms[str(item["platform"])] = item
    if "tg" not in platforms and post.get("publication_status") and post.get("publication_status") != "pending":
        platforms["tg"] = {
            "platform": "tg",
            "status": post.get("publication_status"),
            "url": post.get("tg_publication_url"),
            "error": post.get("publication_error"),
        }
    labels = {"tg": "TG", "vk": "VK", "max": "MAX", "site": "Сайт"}
    status_labels = {
        "published": "опубликовано",
        "publish_failed": "ошибка",
        "pending": "ожидает",
    }
    status_classes = {"published": "ok", "publish_failed": "bad", "pending": ""}
    post["publication_platforms"] = [
        {
            **platforms.get(platform, {"platform": platform, "status": "pending"}),
            "label": label,
            "status_label": status_labels.get(str(platforms.get(platform, {}).get("status") or "pending"), str(platforms.get(platform, {}).get("status") or "pending")),
            "class": status_classes.get(str(platforms.get(platform, {}).get("status") or "pending"), ""),
        }
        for platform, label in labels.items()
    ]
    post["editorial_status_label"] = next(
        (item["label"] for item in EDITOR_STATUS_OPTIONS if item["value"] == (post.get("editorial_status") or "review")),
        post.get("editorial_status") or "review",
    )
    post["reviewed_at_fmt"] = format_dt(post.get("reviewed_at"))
    post["edited_at_fmt"] = format_dt(post.get("edited_at"))
    return post


async def resolve_category_for_editor(name: str, category_id: int | None = None) -> dict[str, Any]:
    pool = await get_pool()
    row = None
    if category_id is not None:
        row = await pool.fetchrow(
            """
            SELECT sort_order AS id, name, tag
            FROM content_categories
            WHERE sort_order=$1
            LIMIT 1
            """,
            category_id,
        )
    if row is None:
        raw_name = (name or "").strip().strip("#")
        row = await pool.fetchrow(
            """
            SELECT sort_order AS id, name, tag
            FROM content_categories
            WHERE lower(name)=lower($1) OR lower(tag)=lower($1)
            ORDER BY is_active DESC, sort_order, name
            LIMIT 1
            """,
            raw_name,
        )
    if row:
        return {"id": int(row["id"]), "name": str(row["name"]), "tag": normalize_hash_tag(str(row["tag"]), "category")}
    fallback_name = (name or "").strip().strip("#")
    return {"id": category_id, "name": fallback_name, "tag": normalize_hash_tag(fallback_name, "category")}


def uploaded_editor_media_path(url: str) -> Path | None:
    raw = str(url or "").strip()
    prefix = "/uploads/editor_media/"
    if not raw.startswith(prefix):
        return None
    name = Path(raw.removeprefix(prefix)).name
    if not name:
        return None
    path = (EDITOR_MEDIA_DIR / name).resolve()
    try:
        path.relative_to(EDITOR_MEDIA_DIR.resolve())
    except ValueError:
        return None
    return path


async def writer_categories() -> list[str]:
    pool = await get_pool()
    rows = await pool.fetch(
        """
        SELECT name
        FROM content_categories
        WHERE is_active=TRUE
        ORDER BY sort_order, name
        """
    )
    categories = [str(row["name"]) for row in rows]
    if categories:
        return categories
    legacy_categories = parse_categories(await fetch_setting("ai_writer_categories", []))
    return legacy_categories or [
        "защита",
        "одежда",
        "разгрузка",
        "рюкзаки",
        "airsoft",
        "патчи",
        "электроника",
        "аксессуары",
        "производство",
    ]


async def writer_category_options() -> list[dict[str, str]]:
    pool = await get_pool()
    rows = await pool.fetch(
        """
        SELECT name, tag
        FROM content_categories
        WHERE is_active=TRUE
        ORDER BY sort_order, name
        """
    )
    options = [
        {"name": str(row["name"]), "tag": normalize_hash_tag(str(row["tag"]), "category")}
        for row in rows
    ]
    if options:
        return options
    return [{"name": name, "tag": normalize_hash_tag(name, "category")} for name in await writer_categories()]


def normalize_tg_schedule(value: Any) -> list[dict[str, Any]]:
    if not isinstance(value, list):
        value = []
    rows: list[dict[str, Any]] = []
    for idx, item in enumerate(value):
        if not isinstance(item, dict):
            continue
        row_id = str(item.get("id") or f"slot-{idx + 1}").strip()[:80]
        time_value = str(item.get("time") or "").strip()
        count = item.get("count") or 1
        try:
            count_int = clamp_int(int(count), 1, 20)
        except Exception:
            count_int = 1
        if not re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", time_value):
            time_value = "10:00"
        rows.append({"id": row_id, "time": time_value, "count": count_int, "enabled": bool(item.get("enabled", True))})
    return sorted(rows, key=lambda row: row["time"])


async def tg_poster_schedule_rows() -> list[dict[str, Any]]:
    value = await fetch_setting("tg_poster_schedule_json", [])
    rows = normalize_tg_schedule(value)
    if rows:
        return rows
    return [
        {"id": "morning", "time": "10:00", "count": 1, "enabled": True},
        {"id": "evening", "time": "18:00", "count": 1, "enabled": True},
    ]


async def vk_poster_schedule_rows() -> list[dict[str, Any]]:
    value = await fetch_setting("vk_poster_schedule_json", [])
    rows = normalize_tg_schedule(value)
    if rows:
        return rows
    return [
        {"id": "vk-morning", "time": "10:05", "count": 1, "enabled": True},
        {"id": "vk-evening", "time": "18:05", "count": 1, "enabled": True},
    ]


async def save_tg_poster_schedule(rows: list[dict[str, Any]]) -> None:
    pool = await get_pool()
    await pool.execute(
        """
        INSERT INTO app_settings(key, value_json, value_type, title, description, category)
        VALUES(
          'tg_poster_schedule_json',
          $1::jsonb,
          'json',
          'TG publication schedule',
          'EKB time schedule rows: id, time HH:MM, count, enabled.',
          'TG Poster'
        )
        ON CONFLICT (key) DO UPDATE
        SET value_json=EXCLUDED.value_json,
            updated_at=NOW()
        """,
        json.dumps(normalize_tg_schedule(rows), ensure_ascii=False),
    )


async def save_vk_poster_schedule(rows: list[dict[str, Any]]) -> None:
    pool = await get_pool()
    await pool.execute(
        """
        INSERT INTO app_settings(key, value_json, value_type, title, description, category)
        VALUES(
          'vk_poster_schedule_json',
          $1::jsonb,
          'json',
          'VK publication schedule',
          'EKB time schedule rows: id, time HH:MM, count, enabled.',
          'VK Poster'
        )
        ON CONFLICT (key) DO UPDATE
        SET value_json=EXCLUDED.value_json,
            updated_at=NOW()
        """,
        json.dumps(normalize_tg_schedule(rows), ensure_ascii=False),
    )


async def writer_category_payload() -> list[dict[str, Any]]:
    pool = await get_pool()
    rows = await pool.fetch(
        """
        SELECT sort_order AS id, name, tag
        FROM content_categories
        WHERE is_active=TRUE
        ORDER BY sort_order, name
        """
    )
    categories = [{"id": int(row["id"]), "name": str(row["name"]), "tag": str(row["tag"])} for row in rows]
    if categories:
        return categories
    return [
        {"id": idx + 1, "name": name, "tag": normalize_hash_tag(name, "category")}
        for idx, name in enumerate(await writer_categories())
    ]


async def category_rows() -> list[dict[str, Any]]:
    pool = await get_pool()
    rows = await pool.fetch(
        """
        SELECT id, name, tag, site_name, site_slug, site_enabled,
               is_active, sort_order, created_at, updated_at
        FROM content_categories
        ORDER BY is_active DESC, sort_order, name
        """
    )
    result = []
    for row in rows:
        item = dict(row)
        item["created_at_fmt"] = format_dt(item.get("created_at"))
        item["updated_at_fmt"] = format_dt(item.get("updated_at"))
        result.append(item)
    return result


PROMPT_TEST_STATUS_OPTIONS = [
    {"value": "all", "label": "Все"},
    {"value": "qualified", "label": "Оцененные"},
    {"value": "accepted", "label": "Принятые"},
    {"value": "maybe", "label": "Спорные"},
    {"value": "rejected", "label": "Отклоненные"},
    {"value": "pending", "label": "Ждут AI"},
]


def normalize_prompt_test_status(value: str | None) -> str:
    value = str(value or "all").strip().lower()
    allowed = {item["value"] for item in PROMPT_TEST_STATUS_OPTIONS}
    return value if value in allowed else "all"


def prompt_test_status_where(status_filter: str) -> str:
    if status_filter == "qualified":
        return "AND rp.qualification_status IN ('accepted', 'maybe', 'rejected')"
    if status_filter in {"accepted", "maybe", "rejected", "pending"}:
        return f"AND rp.qualification_status = '{status_filter}'"
    return ""


async def prompt_test_posts(
    selected_post_id: int | None = None,
    status_filter: str = "all",
) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
    pool = await get_pool()
    status_filter = normalize_prompt_test_status(status_filter)
    where_sql = prompt_test_status_where(status_filter)
    rows = await pool.fetch(
        f"""
        SELECT rp.id, rp.raw_text, rp.original_url, rp.posted_at, rp.created_at, rp.qualification_status,
               rp.qualification_score, s.name AS source_name, s.tag AS source_tag,
               rp.qualification_reason,
               (SELECT COUNT(*) FROM raw_post_media m WHERE m.raw_post_id=rp.id) AS media_count,
               (SELECT ARRAY_AGG(DISTINCT m.media_type ORDER BY m.media_type)
                FROM raw_post_media m WHERE m.raw_post_id=rp.id) AS media_types
        FROM raw_posts rp
        JOIN sources s ON s.id=rp.source_id
        WHERE TRUE
        {where_sql}
        ORDER BY rp.created_at DESC, rp.id DESC
        LIMIT 150
        """
    )
    posts = []
    for row in rows:
        item = dict(row)
        text = str(item.get("raw_text") or "").strip()
        item["created_at_fmt"] = format_dt(item.get("created_at"))
        item["posted_at_fmt"] = format_dt(item.get("posted_at"))
        item["label"] = f"#{item['id']} · {item.get('source_name') or 'source'} · {item['created_at_fmt']}"
        item["snippet"] = text[:180] + ("..." if len(text) > 180 else "")
        posts.append(item)
    selected = next((item for item in posts if int(item["id"]) == int(selected_post_id or 0)), None)
    if not selected and selected_post_id:
        row = await pool.fetchrow(
            """
            SELECT rp.id, rp.raw_text, rp.original_url, rp.posted_at, rp.created_at, rp.qualification_status,
                   rp.qualification_score, s.name AS source_name, s.tag AS source_tag,
                   rp.qualification_reason,
                   (SELECT COUNT(*) FROM raw_post_media m WHERE m.raw_post_id=rp.id) AS media_count,
                   (SELECT ARRAY_AGG(DISTINCT m.media_type ORDER BY m.media_type)
                    FROM raw_post_media m WHERE m.raw_post_id=rp.id) AS media_types
            FROM raw_posts rp
            JOIN sources s ON s.id=rp.source_id
            WHERE rp.id=$1
            """,
            selected_post_id,
        )
        if row:
            selected = dict(row)
            text = str(selected.get("raw_text") or "").strip()
            selected["created_at_fmt"] = format_dt(selected.get("created_at"))
            selected["posted_at_fmt"] = format_dt(selected.get("posted_at"))
            selected["label"] = f"#{selected['id']} · {selected.get('source_name') or 'source'} · {selected['created_at_fmt']}"
            selected["snippet"] = text[:180] + ("..." if len(text) > 180 else "")
            posts.insert(0, selected)
    if not selected and posts:
        selected = posts[0]
    return posts, selected


def prompt_test_payload(post: dict[str, Any], max_text_chars: int) -> dict[str, Any]:
    return {
        "task": "rewrite_accepted_posts",
        "categories": [],
        "posts": [
            {
                "id": int(post["id"]),
                "producer_name": post.get("source_name") or "",
                "producer_tag": normalize_hash_tag(post.get("source_tag") or post.get("source_name") or "", "source"),
                "original_url": post.get("original_url") or "",
                "qualification_score": post.get("qualification_score"),
                "qualification_reason": post.get("qualification_reason") or "",
                "media_count": int(post.get("media_count") or 0),
                "media_types": list(post.get("media_types") or []),
                "text": str(post.get("raw_text") or "")[:max_text_chars],
            }
        ],
    }


def prompt_test_first_rewrite(parsed: Any) -> dict[str, Any] | None:
    if not isinstance(parsed, dict):
        return None
    rewrites = parsed.get("rewrites")
    if not isinstance(rewrites, list) and isinstance(parsed.get("data"), dict):
        rewrites = parsed["data"].get("rewrites")
    if not isinstance(rewrites, list) or not rewrites:
        return None
    first = rewrites[0]
    return first if isinstance(first, dict) else None


def prompt_test_preview_text(parsed: Any) -> str:
    rewrite = prompt_test_first_rewrite(parsed)
    return str((rewrite or {}).get("text") or "").strip()


def prompt_test_preview_category(parsed: Any) -> str:
    rewrite = prompt_test_first_rewrite(parsed)
    if not rewrite:
        return ""
    category_id = rewrite.get("category_id")
    if category_id not in {None, ""}:
        return f"category_id: {category_id}"
    return str(rewrite.get("category") or "").strip()


def prompt_test_preview_notes(parsed: Any) -> str:
    rewrite = prompt_test_first_rewrite(parsed)
    notes = (rewrite or {}).get("notes")
    return "" if notes in {None, ""} else str(notes).strip()


def setting_value(settings_rows: list[dict], key: str, default: Any = None) -> Any:
    for row in settings_rows:
        if row.get("key") == key:
            return row.get("value_json", default)
    return default


def setting_sort_key(item: dict) -> tuple[int, int, str]:
    category = str(item.get("category") or "General")
    key = str(item.get("key") or "")
    keys = SETTING_ORDER.get(category, [])
    pos = keys.index(key) if key in keys else 1000
    return (CATEGORY_ORDER.get(category, 999), pos, key)


def model_label(model_id: str, name: str | None = None, context: int | None = None) -> str:
    label = name or model_id
    if context:
        label = f"{label} · ctx {context}"
    return label


async def fetch_openrouter_models() -> list[dict[str, str]]:
    async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=8)) as session:
        async with session.get("https://openrouter.ai/api/v1/models") as resp:
            resp.raise_for_status()
            data = await resp.json()
    models = []
    for item in data.get("data", []):
        model_id = str(item.get("id") or "").strip()
        if not model_id:
            continue
        context = item.get("context_length")
        models.append(
            {
                "value": f"openrouter/{model_id}",
                "label": model_label(model_id, item.get("name"), context if isinstance(context, int) else None),
            }
        )
    return models


async def fetch_anthropic_models(api_key: str) -> list[dict[str, str]]:
    if not api_key:
        return []
    headers = {
        "x-api-key": api_key,
        "anthropic-version": "2023-06-01",
    }
    models: list[dict[str, str]] = []
    url = "https://api.anthropic.com/v1/models"
    async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
        for _ in range(5):
            async with session.get(url, headers=headers) as resp:
                resp.raise_for_status()
                data = await resp.json()
            for item in data.get("data", []):
                model_id = str(item.get("id") or "").strip()
                if not model_id:
                    continue
                display_name = item.get("display_name") or model_id
                models.append({"value": f"anthropic/{model_id}", "label": f"{display_name} · live Anthropic"})
            if not data.get("has_more"):
                break
            last_id = data.get("last_id")
            if not last_id:
                break
            url = f"https://api.anthropic.com/v1/models?after_id={last_id}"
    return models


async def fetch_openai_models(api_key: str) -> list[dict[str, str]]:
    if not api_key:
        return []
    headers = {"Authorization": f"Bearer {api_key}"}
    async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
        async with session.get("https://api.openai.com/v1/models", headers=headers) as resp:
            resp.raise_for_status()
            data = await resp.json()
    models = []
    for item in data.get("data", []):
        model_id = str(item.get("id") or "").strip()
        if not model_id:
            continue
        if model_id.startswith(("gpt-", "o1", "o3", "o4")):
            models.append({"value": model_id, "label": f"{model_id} · live OpenAI"})
    return sorted(models, key=lambda item: item["value"])


def litellm_catalog_models(provider: str) -> list[dict[str, str]]:
    try:
        import litellm
    except Exception:
        return []

    catalog = getattr(litellm, "model_cost", {}) or {}
    models: list[dict[str, str]] = []
    for model_id, meta in catalog.items():
        if not isinstance(model_id, str):
            continue
        normalized = model_id
        if provider == "openai":
            if "/" in model_id or not model_id.startswith(("gpt-", "o1", "o3", "o4")):
                continue
        elif provider == "anthropic":
            if model_id.startswith("anthropic/"):
                normalized = model_id
            elif model_id.startswith("claude"):
                normalized = f"anthropic/{model_id}"
            else:
                continue
        elif provider == "gemini":
            if model_id.startswith("gemini/"):
                normalized = model_id
            elif model_id.startswith("gemini"):
                normalized = f"gemini/{model_id}"
            else:
                continue
        else:
            continue
        context = meta.get("max_input_tokens") if isinstance(meta, dict) else None
        models.append(
            {
                "value": normalized,
                "label": f"{model_label(normalized, context=context if isinstance(context, int) else None)} · LiteLLM catalog",
            }
        )
    unique = {m["value"]: m for m in models}
    preferred = PREFERRED_MODELS.get(provider, [])

    def sort_key(item: dict[str, str]) -> tuple[int, str]:
        value = item["value"]
        if value in preferred:
            return (preferred.index(value), "")
        return (1000, item["label"].lower())

    return sorted(unique.values(), key=sort_key)


def sort_model_options(provider: str, models: list[dict[str, str]]) -> list[dict[str, str]]:
    preferred = PREFERRED_MODELS.get(provider, [])

    def sort_key(item: dict[str, str]) -> tuple[int, str]:
        value = item["value"]
        if value in preferred:
            return (preferred.index(value), "")
        return (1000, item["label"].lower())

    unique = {m["value"]: m for m in models}
    return sorted(unique.values(), key=sort_key)


async def model_options_for_provider(provider: str, api_key: str = "") -> list[dict[str, str]]:
    provider = (provider or "openrouter").strip().lower()
    now = time.time()
    cache_key = f"{provider}:{hashlib.sha256((api_key or '').encode('utf-8')).hexdigest()[:10]}"
    if MODEL_CACHE["key"] == cache_key and now - float(MODEL_CACHE["at"]) < 3600:
        return list(MODEL_CACHE["models"])

    models: list[dict[str, str]] = []
    if provider == "openrouter":
        try:
            models = await fetch_openrouter_models()
        except Exception as exc:
            logger.warning("OpenRouter model list failed: {}", exc)
    elif provider == "anthropic":
        try:
            models = await fetch_anthropic_models(api_key)
        except Exception as exc:
            logger.warning("Anthropic model list failed: {}", exc)
    elif provider == "openai":
        try:
            models = await fetch_openai_models(api_key)
        except Exception as exc:
            logger.warning("OpenAI model list failed: {}", exc)
    if not models:
        models = litellm_catalog_models(provider)
    if not models:
        models = [{"value": m, "label": f"{m} · fallback"} for m in MODEL_FALLBACKS.get(provider, [])]

    models = sort_model_options(provider, models)
    MODEL_CACHE.update({"key": cache_key, "at": now, "models": models})
    return list(models)


async def prune_audit_log() -> None:
    pool = await get_pool()
    await pool.execute("DELETE FROM audit_log WHERE created_at < NOW() - INTERVAL '30 days'")


def request_audit_meta(request: Request | None, extra: dict[str, Any] | None = None) -> dict[str, Any]:
    meta = dict(extra or {})
    if request:
        meta.update(
            {
                "ip": client_ip(request),
                "method": request.method,
                "path": request.url.path,
                "query": str(request.url.query),
                "user_agent": (request.headers.get("user-agent") or "")[:300],
            }
        )
    return meta


async def audit(
    actor_id: int | None,
    action: str,
    entity_type: str,
    entity_id: int | None,
    after: Any = None,
    request: Request | None = None,
) -> None:
    pool = await get_pool()
    payload = request_audit_meta(request, after if isinstance(after, dict) else {"value": after} if after is not None else None)
    await pool.execute(
        """
        INSERT INTO audit_log(actor_id, action, entity_type, entity_id, after_json)
        VALUES($1, $2, $3, $4, $5::jsonb)
        """,
        actor_id,
        action,
        entity_type,
        entity_id,
        json.dumps(payload, ensure_ascii=False) if payload else None,
    )


def prepare_audit_row(row: Any) -> dict[str, Any]:
    item = dict(row)
    payload = item.get("after_json")
    if isinstance(payload, str):
        try:
            payload = json.loads(payload)
        except json.JSONDecodeError:
            pass
    item["created_at_fmt"] = format_dt(item.get("created_at"))
    item["actor_label"] = item.get("actor_login") or (f"user #{item['actor_id']}" if item.get("actor_id") else "system")
    item["details_pretty"] = json.dumps(payload, ensure_ascii=False, indent=2, default=str) if payload is not None else ""
    return item


async def start_worker_task(worker: Any, name: str) -> None:
    try:
        logger.info("Starting background worker task: {}", name)
        await worker.run_loop()
    except Exception as exc:
        logger.exception("Background worker task {} stopped unexpectedly: {}", name, exc)


@app.on_event("startup")
async def startup() -> None:
    await bootstrap_admin()
    await prune_audit_log()
    workers = [
        ("ai-qualifier", AIQualifierWorker()),
        ("ai-writer", AIWriterWorker()),
        ("daily-report", DailyReportWorker()),
        ("max-poster", MAXPoster()),
        ("vk-parser", VKParserWorker()),
        ("site-poster", SitePoster()),
        ("tg-poster", TelegramPoster()),
        ("tg-reactor", TelegramReactor()),
        ("vk-poster", VKPoster()),
        ("vk-storage-uploader", TelegramStorageUploader()),
    ]
    for name, worker in workers:
        asyncio.create_task(start_worker_task(worker, name))


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


@app.get("/vk/oauth/callback", response_class=HTMLResponse)
async def vk_oauth_callback(request: Request, code: str = "", error: str = "", error_description: str = ""):
    return templates.TemplateResponse(
        "vk_oauth_callback.html",
        base_context(
            request,
            None,
            code=code,
            code_verifier=request.cookies.get(VK_OAUTH_VERIFIER_COOKIE, ""),
            device_id=request.query_params.get("device_id", ""),
            state=request.query_params.get("state", ""),
            expected_state=request.cookies.get(VK_OAUTH_STATE_COOKIE, ""),
            error=error,
            error_description=error_description,
        ),
    )


@app.get("/vk/oauth/start")
async def vk_oauth_start() -> RedirectResponse:
    verifier = secrets.token_urlsafe(64)
    state = secrets.token_urlsafe(24)
    redirect_uri = "https://sw.exostring.xyz/vk/oauth/callback"
    params = {
        "client_id": "54635120",
        "redirect_uri": redirect_uri,
        "response_type": "code",
        "scope": "wall photos video groups offline",
        "state": state,
        "code_challenge": pkce_challenge(verifier),
        "code_challenge_method": "s256",
        "origin": "https://sw.exostring.xyz",
        "v": "5.199",
    }
    response = RedirectResponse(f"https://id.vk.ru/authorize?{urlencode(params)}")
    response.set_cookie(VK_OAUTH_VERIFIER_COOKIE, verifier, httponly=True, samesite="lax", max_age=15 * 60)
    response.set_cookie(VK_OAUTH_STATE_COOKIE, state, httponly=True, samesite="lax", max_age=15 * 60)
    return response


@app.get("/vk/group-oauth/start")
async def vk_group_oauth_start() -> RedirectResponse:
    group_id = abs(int(settings.vk_storage_group_id))
    params = {
        "client_id": "54635120",
        "group_ids": str(group_id),
        "display": "page",
        "redirect_uri": "https://sw.exostring.xyz/vk/oauth/callback",
        "scope": "manage,photos,docs",
        "response_type": "token",
        "state": secrets.token_urlsafe(24),
        "v": "5.199",
    }
    return RedirectResponse(f"https://oauth.vk.com/authorize?{urlencode(params)}")


@app.get("/vk-oauth/start")
async def vk_poster_oauth_start(request: Request) -> RedirectResponse:
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    client_id = str(await fetch_setting("vk_poster_app_id", "") or "").strip()
    if not client_id:
        return redirect("/workers")
    verifier = secrets.token_urlsafe(64)
    state = secrets.token_urlsafe(24)
    redirect_uri = "https://sw.exostring.xyz/vk-oauth/callback"
    params = {
        "client_id": client_id,
        "redirect_uri": redirect_uri,
        "response_type": "code",
        "scope": "wall photos",
        "state": state,
        "code_challenge": pkce_challenge(verifier),
        "code_challenge_method": "s256",
        "origin": "https://sw.exostring.xyz",
        "v": "5.199",
    }
    response = RedirectResponse(f"https://id.vk.ru/authorize?{urlencode(params)}")
    response.set_cookie(VK_OAUTH_VERIFIER_COOKIE, verifier, httponly=True, secure=(request.url.scheme == "https"), samesite="lax", max_age=15 * 60)
    response.set_cookie(VK_OAUTH_STATE_COOKIE, state, httponly=True, secure=(request.url.scheme == "https"), samesite="lax", max_age=15 * 60)
    return response


@app.get("/vk-oauth/callback", response_class=HTMLResponse)
async def vk_poster_oauth_callback(
    request: Request,
    code: str = "",
    state: str = "",
    error: str = "",
    error_description: str = "",
):
    user = await get_current_user(request)
    result: dict[str, Any] = {
        "ok": False,
        "error": error,
        "error_description": error_description,
        "state_ok": bool(state and state == request.cookies.get(VK_OAUTH_STATE_COOKIE, "")),
    }
    if not user:
        result.update({"error": "auth_required", "error_description": "Сначала войдите в админку, затем повторите VK OAuth."})
    elif error:
        pass
    elif not code:
        result.update({"error": "missing_code", "error_description": "VK callback пришёл без code."})
    elif not result["state_ok"]:
        result.update({"error": "state_mismatch", "error_description": "OAuth state не совпал. Повторите авторизацию из админки."})
    else:
        pool = await get_pool()
        client_id = str(await fetch_setting("vk_poster_app_id", "") or "").strip()
        client_secret = str(await fetch_setting("vk_poster_client_secret", "") or "").strip()
        owner_id = int(await fetch_int_setting("vk_poster_owner_id", 0))
        redirect_uri = "https://sw.exostring.xyz/vk-oauth/callback"
        code_verifier = request.cookies.get(VK_OAUTH_VERIFIER_COOKIE, "")
        device_id = request.query_params.get("device_id", "")
        try:
            async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
                async with session.post(
                    "https://id.vk.ru/oauth2/auth",
                    data={
                        "grant_type": "authorization_code",
                        "client_id": client_id,
                        "client_secret": client_secret,
                        "redirect_uri": redirect_uri,
                        "code": code,
                        "code_verifier": code_verifier,
                        "device_id": device_id,
                        "state": state,
                    },
                ) as resp:
                    token_payload = await resp.json(content_type=None)
            if token_payload.get("error"):
                raise RuntimeError(f"{token_payload.get('error')}: {token_payload.get('error_description')}")
            access_token = str(token_payload.get("access_token") or "").strip()
            if not access_token:
                raise RuntimeError(f"VK did not return access_token: {token_payload}")
            refresh_token = str(token_payload.get("refresh_token") or "").strip()
            expires_in = token_payload.get("expires_in")
            expires_at = None
            try:
                expires_at = (now_utc() + timedelta(seconds=int(expires_in))).isoformat() if expires_in is not None else None
            except Exception:
                expires_at = None
            updates = {
                "vk_poster_access_token": access_token,
                "vk_poster_refresh_token": refresh_token,
                "vk_poster_token_device_id": str(token_payload.get("device_id") or device_id or ""),
                "vk_poster_token_expires_at": expires_at or "",
            }
            for key, value in updates.items():
                await pool.execute(
                    """
                    UPDATE app_settings
                    SET value_json=$2::jsonb,
                        updated_by=$3,
                        updated_at=NOW()
                    WHERE key=$1
                    """,
                    key,
                    json.dumps(value),
                    user["id"],
                )
            check: dict[str, Any] = {}
            async with VKAPIClient(token=access_token, rps=2, timeout_total_sec=30, retry_attempts=1) as client:
                try:
                    upload_url = await client.get_wall_upload_server(abs(owner_id))
                    check["photo_upload_server_ok"] = bool(upload_url)
                except Exception as exc:
                    check["photo_upload_server_ok"] = False
                    check["photo_upload_server_error"] = str(exc)
                try:
                    response = await client.call("wall.get", owner_id=owner_id, count=1, filter="owner")
                    check["wall_get_ok"] = isinstance(response, dict)
                except Exception as exc:
                    check["wall_get_ok"] = False
                    check["wall_get_error"] = str(exc)
            await audit(
                user["id"],
                "vk_oauth.token_saved",
                "vk_poster",
                None,
                {
                    "owner_id": owner_id,
                    "user_id": token_payload.get("user_id"),
                    "expires_in": token_payload.get("expires_in"),
                    "has_refresh_token": bool(refresh_token),
                    "expires_at": expires_at,
                    **check,
                },
                request,
            )
            result.update(
                {
                    "ok": True,
                    "error": "",
                    "error_description": "",
                    "owner_id": owner_id,
                    "user_id": token_payload.get("user_id"),
                    "expires_in": token_payload.get("expires_in"),
                    "has_refresh_token": bool(refresh_token),
                    "expires_at": expires_at,
                    **check,
                }
            )
        except Exception as exc:
            result.update({"error": "token_exchange_failed", "error_description": str(exc)})
    response = templates.TemplateResponse("vk_oauth_callback.html", base_context(request, user, result=result))
    response.delete_cookie(VK_OAUTH_STATE_COOKIE)
    response.delete_cookie(VK_OAUTH_VERIFIER_COOKIE)
    return response


@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    return redirect("/sources")


@app.get("/login", response_class=HTMLResponse)
async def login_get(request: Request):
    return templates.TemplateResponse("login.html", base_context(request, None, error=""))


@app.post("/login", response_class=HTMLResponse)
async def login_post(request: Request, login: str = Form(...), password: str = Form(...)):
    pool = await get_pool()
    login_value = login.strip().lower()
    ip = client_ip(request)
    await pool.execute("DELETE FROM admin_login_attempts WHERE created_at < NOW() - INTERVAL '7 days'")
    ip_failures = int(
        await pool.fetchval(
            """
            SELECT COUNT(*)
            FROM admin_login_attempts
            WHERE ip=$1 AND success=FALSE AND created_at > NOW() - INTERVAL '15 minutes'
            """,
            ip,
        )
        or 0
    )
    login_failures = int(
        await pool.fetchval(
            """
            SELECT COUNT(*)
            FROM admin_login_attempts
            WHERE login=$1 AND success=FALSE AND created_at > NOW() - INTERVAL '15 minutes'
            """,
            login_value,
        )
        or 0
    )
    # Stricter limits: 5 bad attempts per IP or 3 per login in 15 minutes → block.
    if ip_failures >= 5 or login_failures >= 3:
        await pool.execute(
            "INSERT INTO admin_login_attempts(ip, login, success) VALUES($1, $2, FALSE)",
            ip,
            login_value,
        )
        await audit(None, "auth.login_blocked", "admin_login", None, {"login": login_value}, request)
        return templates.TemplateResponse(
            "login.html",
            base_context(request, None, error="Слишком много попыток. Подожди 15 минут и попробуй снова."),
            status_code=status.HTTP_429_TOO_MANY_REQUESTS,
        )
    row = await pool.fetchrow(
        "SELECT id, login, password_hash, role, is_active FROM admin_users WHERE login=$1",
        login_value,
    )
    if not row or not row["is_active"] or not verify_password(password, row["password_hash"]):
        await pool.execute(
            "INSERT INTO admin_login_attempts(ip, login, success) VALUES($1, $2, FALSE)",
            ip,
            login_value,
        )
        await audit(None, "auth.login_failed", "admin_login", None, {"login": login_value}, request)
        # Constant-time delay to slow brute-force attempts.
        await asyncio.sleep(1.0)
        return templates.TemplateResponse("login.html", base_context(request, None, error="Неверный логин или пароль"))

    await pool.execute("INSERT INTO admin_login_attempts(ip, login, success) VALUES($1, $2, TRUE)", ip, login_value)
    token = new_token()
    csrf = new_token()
    await pool.execute(
        """
        INSERT INTO admin_sessions(token_hash, user_id, csrf_token, expires_at)
        VALUES($1, $2, $3, $4)
        """,
        token_hash(token, settings.app_secret_key),
        row["id"],
        csrf,
        now_utc() + timedelta(days=14),
    )
    await audit(row["id"], "auth.login_success", "admin_user", int(row["id"]), {"login": login_value}, request)
    response = redirect("/sources")
    response.set_cookie(COOKIE_NAME, token, httponly=True, secure=(request.url.scheme == "https"), samesite="lax", max_age=14 * 24 * 3600)
    return response


@app.post("/logout")
async def logout(request: Request):
    token = request.cookies.get(COOKIE_NAME)
    user = await get_current_user(request) if token else None
    if token:
        pool = await get_pool()
        await pool.execute("DELETE FROM admin_sessions WHERE token_hash=$1", token_hash(token, settings.app_secret_key))
    if user:
        await audit(user["id"], "auth.logout", "admin_user", int(user["id"]), None, request)
    response = redirect("/login")
    response.delete_cookie(COOKIE_NAME)
    return response


@app.get("/sources", response_class=HTMLResponse)
async def sources_list(request: Request, q: str = "", status_filter: str = "", active_filter: str = "", page: int = 1, per_page: int = 50):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    pool = await get_pool()
    table_filters = parse_table_filters(request, SOURCE_FIELD_SPECS)
    table_sorts = parse_table_sorts(request, SOURCE_FIELD_SPECS)
    filter_clauses, filter_args = build_filter_sql(table_filters, SOURCE_FIELD_SPECS, 4)
    where_parts = [
        "s.archived_at IS NULL",
        "($1='' OR s.name ILIKE '%' || $1 || '%' OR COALESCE(s.tag,'') ILIKE '%' || $1 || '%' OR s.url ILIKE '%' || $1 || '%' OR COALESCE(s.external_id,'') ILIKE '%' || $1 || '%')",
        "($2='' OR s.status=$2)",
        "($3='' OR ($3='on' AND s.active=TRUE) OR ($3='off' AND s.active=FALSE))",
        *filter_clauses,
    ]
    where_sql = " AND ".join(where_parts)
    base_args = [q.strip(), status_filter.strip(), active_filter.strip(), *filter_args]
    total = int(
        await pool.fetchval(
            f"""
            SELECT COUNT(*)
            FROM sources s
            WHERE {where_sql}
            """,
            *base_args,
        )
        or 0
    )
    pager = pagination(page, per_page, total)
    limit_index = len(base_args) + 1
    offset_index = len(base_args) + 2
    order_sql = build_sort_sql(table_sorts, SOURCE_FIELD_SPECS, "s.active DESC, s.priority ASC, s.id DESC")
    rows = await pool.fetch(
        f"""
        SELECT s.*,
               COUNT(rp.id) AS posts_count,
               COUNT(rp.id) FILTER (WHERE rp.created_at > NOW() - INTERVAL '24 hours') AS posts_24h
        FROM sources s
        LEFT JOIN raw_posts rp ON rp.source_id=s.id
        WHERE {where_sql}
        GROUP BY s.id
        ORDER BY {order_sql}
        LIMIT ${limit_index} OFFSET ${offset_index}
        """,
        *base_args,
        pager["per_page"],
        pager["offset"],
    )
    sources = []
    for row in rows:
        item = dict(row)
        item["last_parsed_at_fmt"] = format_dt(item.get("last_parsed_at"))
        item["last_checked_at_fmt"] = format_dt(item.get("last_checked_at"))
        item["created_at_fmt"] = format_dt(item.get("created_at"))
        sources.append(item)
    template_name = "sources_content.html" if request.headers.get("hx-request") else "sources.html"
    return templates.TemplateResponse(
        template_name,
        base_context(
            request,
            user,
            sources=sources,
            q=q,
            status_filter=status_filter,
            active_filter=active_filter,
            pagination=pager,
            table_filters=table_filters,
            table_sorts=table_sorts,
            field_options=field_options(SOURCE_FIELD_SPECS),
            filter_ops=FILTER_OPS,
            filter_op_options=FILTER_OP_OPTIONS,
            source_preview=[],
            bulk_text="",
            bulk_active=True,
        ),
    )


@app.post("/sources/preview", response_class=HTMLResponse)
async def sources_preview(
    request: Request,
    csrf_token: str = Form(...),
    bulk_text: str = Form(""),
    bulk_active: str = Form("off"),
    q: str = Form(""),
    status_filter: str = Form(""),
    per_page: int = Form(50),
):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    require_csrf(user, csrf_token)
    preview = await build_sources_preview(bulk_text, default_active=bulk_active == "on")
    pool = await get_pool()
    table_filters = parse_table_filters(request, SOURCE_FIELD_SPECS)
    table_sorts = parse_table_sorts(request, SOURCE_FIELD_SPECS)
    filter_clauses, filter_args = build_filter_sql(table_filters, SOURCE_FIELD_SPECS, 3)
    where_parts = [
        "s.archived_at IS NULL",
        "($1='' OR s.name ILIKE '%' || $1 || '%' OR COALESCE(s.tag,'') ILIKE '%' || $1 || '%' OR s.url ILIKE '%' || $1 || '%' OR COALESCE(s.external_id,'') ILIKE '%' || $1 || '%')",
        "($2='' OR s.status=$2)",
        *filter_clauses,
    ]
    where_sql = " AND ".join(where_parts)
    base_args = [q.strip(), status_filter.strip(), *filter_args]
    total = int(await pool.fetchval(f"SELECT COUNT(*) FROM sources s WHERE {where_sql}", *base_args) or 0)
    pager = pagination(1, per_page, total)
    limit_index = len(base_args) + 1
    offset_index = len(base_args) + 2
    order_sql = build_sort_sql(table_sorts, SOURCE_FIELD_SPECS, "s.active DESC, s.priority ASC, s.id DESC")
    rows = await pool.fetch(
        f"""
        SELECT s.*,
               COUNT(rp.id) AS posts_count,
               COUNT(rp.id) FILTER (WHERE rp.created_at > NOW() - INTERVAL '24 hours') AS posts_24h
        FROM sources s
        LEFT JOIN raw_posts rp ON rp.source_id=s.id
        WHERE {where_sql}
        GROUP BY s.id
        ORDER BY {order_sql}
        LIMIT ${limit_index} OFFSET ${offset_index}
        """,
        *base_args,
        pager["per_page"],
        pager["offset"],
    )
    sources = []
    for row in rows:
        item = dict(row)
        item["last_parsed_at_fmt"] = format_dt(item.get("last_parsed_at"))
        item["last_checked_at_fmt"] = format_dt(item.get("last_checked_at"))
        item["created_at_fmt"] = format_dt(item.get("created_at"))
        sources.append(item)
    return templates.TemplateResponse(
        "sources.html",
        base_context(
            request,
            user,
            sources=sources,
            q=q,
            status_filter=status_filter,
            pagination=pager,
            table_filters=table_filters,
            table_sorts=table_sorts,
            field_options=field_options(SOURCE_FIELD_SPECS),
            filter_ops=FILTER_OPS,
            filter_op_options=FILTER_OP_OPTIONS,
            source_preview=preview,
            bulk_text=bulk_text,
            bulk_active=bulk_active == "on",
        ),
    )


@app.post("/sources/bulk")
async def sources_bulk_create(
    request: Request,
    csrf_token: str = Form(...),
    bulk_payload: str = Form("[]"),
):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    require_csrf(user, csrf_token)
    try:
        items = json.loads(bulk_payload)
    except json.JSONDecodeError:
        items = []
    pool = await get_pool()
    created = 0
    for item in items:
        if not isinstance(item, dict) or not item.get("ok"):
            continue
        try:
            row = await pool.fetchrow(
                """
                INSERT INTO sources(platform, name, tag, url, external_id, external_owner_id, active, priority, created_by)
                VALUES($1, $2, $3, $4, $5, $6, $7, 100, $8)
                ON CONFLICT DO NOTHING
                RETURNING id
                """,
                PLATFORM_VK,
                str(item.get("name") or item.get("external_id") or "").strip(),
                normalize_hash_tag(str(item.get("tag") or item.get("external_id") or ""), str(item.get("external_id") or "source")),
                str(item.get("url") or "").strip(),
                str(item.get("external_id") or "").strip(),
                item.get("external_owner_id"),
                bool(item.get("active", True)),
                user["id"],
            )
            if row:
                created += 1
                await audit(user["id"], "source.create", "source", int(row["id"]), {"url": item.get("url"), "bulk": True})
        except Exception:
            logger.exception("Bulk source insert failed: {}", item)
    return redirect(f"/sources?q=&status_filter=&created={created}")


@app.get("/sources/new", response_class=HTMLResponse)
async def source_new(request: Request):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    return templates.TemplateResponse(
        "source_form.html",
        base_context(request, user, source=None, action="/sources/new", title="Новый источник"),
    )


@app.post("/sources/new")
async def source_create(
    request: Request,
    csrf_token: str = Form(...),
    platform: str = Form(PLATFORM_VK),
    name: str = Form(...),
    tag: str = Form(""),
    url: str = Form(...),
    active: str = Form("off"),
    priority: int = Form(100),
):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    require_csrf(user, csrf_token)
    platform = platform.strip().lower() or PLATFORM_VK
    external_id = normalize_vk_source(url) if platform == PLATFORM_VK else ""
    external_owner_id = None
    resolved_name = name.strip()
    resolved_url = url.strip()
    status_value = "new"
    status_msg = None
    if platform == PLATFORM_VK and external_id:
        try:
            async with VKAPIClient(rps=2, timeout_total_sec=12, timeout_connect_sec=5, retry_attempts=2) as client:
                screen_name, owner_id, vk_name = await client.resolve_group(url)
            external_id = screen_name
            external_owner_id = owner_id
            resolved_name = resolved_name or vk_name
            resolved_url = f"https://vk.com/{screen_name}"
        except Exception as exc:
            resolved_name = resolved_name or external_id
            status_value = "error"
            status_msg = str(exc)[:500]
    resolved_tag = normalize_hash_tag(tag or external_id or resolved_name, external_id or "source")
    pool = await get_pool()
    row = await pool.fetchrow(
        """
        INSERT INTO sources(platform, name, tag, url, external_id, external_owner_id, active, priority, status, status_msg, created_by)
        VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
        ON CONFLICT DO NOTHING
        RETURNING id
        """,
        platform,
        resolved_name,
        resolved_tag,
        resolved_url,
        external_id,
        external_owner_id,
        active == "on",
        priority,
        status_value,
        status_msg,
        user["id"],
    )
    if row:
        await audit(user["id"], "source.create", "source", int(row["id"]), {"url": url, "platform": platform})
    return redirect("/sources")


@app.get("/sources/{source_id}/edit", response_class=HTMLResponse)
async def source_edit(request: Request, source_id: int):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    pool = await get_pool()
    source = await pool.fetchrow("SELECT * FROM sources WHERE id=$1 AND archived_at IS NULL", source_id)
    if not source:
        return redirect("/sources")
    return templates.TemplateResponse(
        "source_form.html",
        base_context(
            request,
            user,
            source=dict(source),
            action=f"/sources/{source_id}/edit",
            title=f"Источник #{source_id}",
        ),
    )


@app.post("/sources/{source_id}/edit")
async def source_update(
    request: Request,
    source_id: int,
    csrf_token: str = Form(...),
    platform: str = Form(PLATFORM_VK),
    name: str = Form(...),
    tag: str = Form(""),
    url: str = Form(...),
    active: str = Form("off"),
    priority: int = Form(100),
):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    require_csrf(user, csrf_token)
    platform = platform.strip().lower() or PLATFORM_VK
    external_id = normalize_vk_source(url) if platform == PLATFORM_VK else ""
    pool = await get_pool()
    await pool.execute(
        """
        UPDATE sources
        SET platform=$2,
            name=$3,
            tag=$4,
            url=$5,
            external_id=$6,
            active=$7,
            priority=$8,
            updated_at=NOW()
        WHERE id=$1
        """,
        source_id,
        platform,
        name.strip(),
        normalize_hash_tag(tag or external_id or name, external_id or "source"),
        url.strip(),
        external_id,
        active == "on",
        priority,
    )
    await audit(user["id"], "source.update", "source", source_id, {"url": url, "platform": platform})
    return redirect("/sources")


@app.post("/sources/{source_id}/toggle")
async def source_toggle(request: Request, source_id: int, csrf_token: str = Form(...)):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    require_csrf(user, csrf_token)
    pool = await get_pool()
    row = await pool.fetchrow(
        """
        UPDATE sources
        SET active=NOT active,
            status=CASE WHEN active THEN 'paused' ELSE 'new' END,
            updated_at=NOW()
        WHERE id=$1
        RETURNING active
        """,
        source_id,
    )
    await audit(user["id"], "source.toggle", "source", source_id, {"active": bool(row["active"]) if row else None})
    if request.headers.get("hx-request"):
        # Fetch the updated source
        source_row = await pool.fetchrow("SELECT * FROM sources WHERE id=$1", source_id)
        if source_row:
            s = dict(source_row)
            # Fetch stats just for this source to render correctly
            stats = await pool.fetchrow(
                """
                SELECT COUNT(*) AS total_posts,
                       COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '24 hours') AS recent_posts
                FROM raw_posts
                WHERE source_id=$1
                """,
                source_id
            )
            s["posts_count"] = stats["total_posts"]
            s["posts_24h"] = stats["recent_posts"]
            s["last_parsed_at_fmt"] = s["last_parsed_at"].strftime("%d.%m.%Y %H:%M") if s.get("last_parsed_at") else None
            return templates.TemplateResponse("source_row.html", base_context(request, user, s=s))
    return redirect("/sources")


@app.post("/sources/{source_id}/delete")
async def source_delete(request: Request, source_id: int, csrf_token: str = Form(...)):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    require_csrf(user, csrf_token)
    pool = await get_pool()
    await pool.execute("UPDATE sources SET archived_at=NOW(), active=FALSE, updated_at=NOW() WHERE id=$1", source_id)
    await audit(user["id"], "source.archive", "source", source_id)
    if request.headers.get("hx-request"):
        return HTMLResponse("")
    return redirect("/sources")


@app.get("/raw", response_class=HTMLResponse)
async def raw_posts(
    request: Request,
    status_filter: str = "",
    date_from: str = "",
    date_to: str = "",
    score_min: str = "",
    score_max: str = "",
    sort: str = "created_desc",
    page: int = 1,
    per_page: int = 50,
):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    pool = await get_pool()
    order_map = {
        "id_desc": "rp.id DESC",
        "id_asc": "rp.id ASC",
        "created_desc": "rp.created_at DESC",
        "created_asc": "rp.created_at ASC",
        "posted_desc": "rp.posted_at DESC NULLS LAST, rp.created_at DESC",
        "posted_asc": "rp.posted_at ASC NULLS LAST, rp.created_at ASC",
        "source_asc": "lower(s.name) ASC, rp.created_at DESC",
        "source_desc": "lower(s.name) DESC, rp.created_at DESC",
        "stage_asc": "rp.status ASC, COALESCE(rp.qualification_status, 'pending') ASC, COALESCE(rp.rewrite_status, 'pending') ASC, rp.created_at DESC",
        "stage_desc": "rp.status DESC, COALESCE(rp.qualification_status, 'pending') DESC, COALESCE(rp.rewrite_status, 'pending') DESC, rp.created_at DESC",
        "text_asc": "lower(rp.raw_text) ASC, rp.created_at DESC",
        "text_desc": "lower(rp.raw_text) DESC, rp.created_at DESC",
        "score_desc": "rp.qualification_score DESC NULLS LAST, rp.created_at DESC",
        "score_asc": "rp.qualification_score ASC NULLS LAST, rp.created_at DESC",
    }
    raw_statuses = selected_values(request, "raw_status")
    if status_filter.strip() and not raw_statuses:
        raw_statuses = [status_filter.strip()]
    source_ids = selected_int_values(request, "source_id")
    categories_selected = selected_values(request, "category")
    qualification_statuses = selected_values(request, "qualification_status")
    rewrite_statuses = selected_values(request, "rewrite_status")
    where_parts: list[str] = []
    base_args: list[Any] = []
    if raw_statuses:
        add_where(where_parts, base_args, "rp.status = ANY(${i}::text[])", raw_statuses)
    if source_ids:
        add_where(where_parts, base_args, "rp.source_id = ANY(${i}::bigint[])", source_ids)
    if categories_selected:
        add_where(where_parts, base_args, "COALESCE(rp.final_category, rp.rewrite_category, '') = ANY(${i}::text[])", categories_selected)
    if qualification_statuses:
        add_where(where_parts, base_args, "COALESCE(rp.qualification_status, 'pending') = ANY(${i}::text[])", qualification_statuses)
    if rewrite_statuses:
        add_where(where_parts, base_args, "COALESCE(rp.rewrite_status, 'pending') = ANY(${i}::text[])", rewrite_statuses)
    parsed_date_from = parse_date_filter(date_from)
    parsed_date_to = parse_date_filter(date_to)
    if parsed_date_from:
        add_where(where_parts, base_args, "timezone('Asia/Yekaterinburg', COALESCE(rp.posted_at, rp.created_at))::date >= ${i}::date", parsed_date_from)
    if parsed_date_to:
        add_where(where_parts, base_args, "timezone('Asia/Yekaterinburg', COALESCE(rp.posted_at, rp.created_at))::date <= ${i}::date", parsed_date_to)
    if score_min.strip().isdigit():
        add_where(where_parts, base_args, "rp.qualification_score >= ${i}::int", int(score_min.strip()))
    if score_max.strip().isdigit():
        add_where(where_parts, base_args, "rp.qualification_score <= ${i}::int", int(score_max.strip()))
    where_sql = " AND ".join(where_parts) if where_parts else "TRUE"
    order_sql = order_map.get(sort, order_map["created_desc"])
    total = int(
        await pool.fetchval(
            f"""
            SELECT COUNT(*)
            FROM raw_posts rp
            JOIN sources s ON s.id=rp.source_id
            WHERE {where_sql}
            """,
            *base_args,
        )
        or 0
    )
    pager = pagination(page, per_page, total)
    limit_index = len(base_args) + 1
    offset_index = len(base_args) + 2
    rows = await pool.fetch(
        f"""
        SELECT rp.*, s.name AS source_name, s.tag AS source_tag, s.platform AS source_platform,
               COUNT(rpm.id) AS media_count,
               COALESCE(
                 jsonb_agg(
                   jsonb_build_object(
                     'id', rpm.id,
                     'type', rpm.media_type,
                     'url', rpm.original_url,
                     'status', rpm.status,
                     'error', rpm.error,
                     'duration_sec', rpm.duration_sec
                   )
                   ORDER BY rpm.sort_order ASC, rpm.id ASC
                 ) FILTER (WHERE rpm.id IS NOT NULL),
                 '[]'::jsonb
               ) AS media_items,
               COALESCE(
                 (
                   SELECT jsonb_agg(
                     jsonb_build_object(
                       'platform', pp.platform,
                       'status', pp.status,
                       'target_id', pp.target_id,
                       'external_id', pp.external_id,
                       'url', pp.url,
                       'error', pp.error,
                       'published_at', pp.published_at
                     )
                     ORDER BY pp.platform
                   )
                   FROM post_publications pp
                   WHERE pp.raw_post_id=rp.id
                 ),
                 '[]'::jsonb
               ) AS platform_publications
               ,
               COALESCE(
                 (
                   SELECT jsonb_agg(
                     jsonb_build_object(
                       'platform', pp.platform,
                       'status', pp.status,
                       'target_id', pp.target_id,
                       'external_id', pp.external_id,
                       'url', pp.url,
                       'error', pp.error,
                       'published_at', pp.published_at
                     )
                     ORDER BY pp.platform
                   )
                   FROM post_publications pp
                   WHERE pp.raw_post_id=rp.id
                 ),
                 '[]'::jsonb
               ) AS platform_publications
        FROM raw_posts rp
        JOIN sources s ON s.id=rp.source_id
        LEFT JOIN raw_post_media rpm ON rpm.raw_post_id=rp.id AND COALESCE(rpm.editor_added, FALSE)=FALSE
        WHERE {where_sql}
        GROUP BY rp.id, s.name, s.tag, s.platform
        ORDER BY {order_sql}
        LIMIT ${limit_index} OFFSET ${offset_index}
        """,
        *base_args,
        pager["per_page"],
        pager["offset"],
    )
    posts = [prepare_raw_post(row) for row in rows]

    template_name = "raw_posts_content.html" if request.headers.get("hx-request") else "raw_posts.html"

    return templates.TemplateResponse(
        template_name,
        base_context(
            request,
            user,
            posts=posts,
            raw_statuses=raw_statuses,
            source_ids=source_ids,
            categories_selected=categories_selected,
            qualification_statuses=qualification_statuses,
            rewrite_statuses=rewrite_statuses,
            date_from=date_from,
            date_to=date_to,
            score_min=score_min,
            score_max=score_max,
            sort=sort,
            facets=await raw_filter_facets(pool),
            preserved_query=preserved_query(request, {"page", "per_page"}),
            sort_headers=raw_sort_headers(request, sort),
            pagination=pager,
        ),
    )


@app.post("/raw/send-to-rewrite")
async def raw_send_to_rewrite(
    request: Request,
    csrf_token: str = Form(...),
    raw_post_id: list[int] | None = Form(default=None),
    next_url: str = Form("/raw"),
):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    require_csrf(user, csrf_token)
    post_ids = sorted({int(post_id) for post_id in (raw_post_id or []) if int(post_id) > 0})
    safe_next = next_url if next_url.startswith("/raw") and not next_url.startswith("//") else "/raw"
    if not post_ids:
        return redirect(safe_next)
    pool = await get_pool()
    updated_rows = await pool.fetch(
        """
        WITH selected AS (
          SELECT id
          FROM raw_posts
          WHERE id=ANY($1::bigint[])
            AND status='storage_ready'
            AND COALESCE(rewrite_status, 'pending') IN ('pending', 'failed')
          FOR UPDATE
        )
        UPDATE raw_posts rp
        SET qualification_status='accepted',
            qualification_decision='accepted',
            qualification_model_decision='manual',
            qualification_reason='Ручная отправка в рерайт из Raw',
            qualification_reject_tag=NULL,
            qualified_at=NOW(),
            rewrite_status='pending',
            rewrite_notes=NULL,
            rewrite_batch_id=NULL,
            editorial_status=NULL,
            updated_at=NOW()
        FROM selected
        WHERE rp.id=selected.id
        RETURNING rp.id
        """,
        post_ids,
    )
    updated_ids = [int(row["id"]) for row in updated_rows]
    await audit(
        user["id"],
        "raw.manual_send_to_rewrite",
        "raw_post",
        None,
        {"selected_ids": post_ids, "updated_ids": updated_ids, "ignored_count": len(post_ids) - len(updated_ids)},
    )
    return redirect(safe_next)


async def fetch_single_editor_row(pool, post_id: int):
    return await pool.fetchrow(
        """
        SELECT rp.*, s.name AS source_name, s.tag AS source_tag, s.platform AS source_platform, s.url AS source_url,
               u.login AS reviewed_by_login,
               COALESCE(c_id.tag, c_name.tag) AS resolved_category_tag,
               COUNT(rpm.id) AS media_count,
               COALESCE(
                 jsonb_agg(
                   jsonb_build_object(
                     'id', rpm.id,
                     'type', rpm.media_type,
                     'url', rpm.original_url,
                     'status', rpm.status,
                     'error', rpm.error,
                     'duration_sec', rpm.duration_sec
                   )
                   ORDER BY rpm.sort_order ASC, rpm.id ASC
                 ) FILTER (WHERE rpm.id IS NOT NULL),
                 '[]'::jsonb
               ) AS media_items,
               COALESCE(
                 (
                   SELECT jsonb_agg(
                     jsonb_build_object(
                       'platform', pp.platform,
                       'status', pp.status,
                       'target_id', pp.target_id,
                       'external_id', pp.external_id,
                       'url', pp.url,
                       'error', pp.error,
                       'published_at', pp.published_at
                     )
                     ORDER BY pp.platform
                   )
                   FROM post_publications pp
                   WHERE pp.raw_post_id=rp.id
                 ),
                 '[]'::jsonb
               ) AS platform_publications
        FROM raw_posts rp
        JOIN sources s ON s.id=rp.source_id
        LEFT JOIN admin_users u ON u.id=rp.reviewed_by
        LEFT JOIN content_categories c_id ON c_id.sort_order=COALESCE(rp.final_category_id, rp.rewrite_category_id)
        LEFT JOIN content_categories c_name ON lower(c_name.name)=lower(COALESCE(rp.final_category, rp.rewrite_category, ''))
        LEFT JOIN raw_post_media rpm ON rpm.raw_post_id=rp.id AND COALESCE(rpm.editor_hidden, FALSE)=FALSE
        WHERE rp.id = $1
        GROUP BY rp.id, s.name, s.tag, s.platform, s.url, u.login, c_id.tag, c_name.tag
        """,
        post_id
    )

@app.get("/editor", response_class=HTMLResponse)
async def editor_feed(
    request: Request,
    status_filter: str = "review",
    category_filter: str = "",
    date_from: str = "",
    date_to: str = "",
    score_min: str = "",
    score_max: str = "",
    sort: str = "rewritten_desc",
    page: int = 1,
    per_page: int = 25,
):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    pool = await get_pool()
    editorial_statuses = selected_values(request, "editorial_status")
    if status_filter.strip() and not editorial_statuses:
        editorial_statuses = [status_filter.strip()]
    categories_selected = selected_values(request, "category")
    if category_filter.strip() and not categories_selected:
        categories_selected = [category_filter.strip()]
    source_ids = selected_int_values(request, "source_id")
    sort_map = {
        "rewritten_desc": "rp.rewritten_at DESC NULLS LAST, rp.id DESC",
        "rewritten_asc": "rp.rewritten_at ASC NULLS LAST, rp.id ASC",
        "score_desc": "rp.qualification_score DESC NULLS LAST, rp.rewritten_at DESC NULLS LAST",
        "score_asc": "rp.qualification_score ASC NULLS LAST, rp.rewritten_at DESC NULLS LAST",
        "posted_desc": "rp.posted_at DESC NULLS LAST, rp.rewritten_at DESC NULLS LAST",
        "posted_asc": "rp.posted_at ASC NULLS LAST, rp.rewritten_at DESC NULLS LAST",
    }
    where_parts = ["rp.rewrite_status='ready'"]
    base_args: list[Any] = []
    if editorial_statuses:
        add_where(where_parts, base_args, "COALESCE(rp.editorial_status, 'review') = ANY(${i}::text[])", editorial_statuses)
    if categories_selected:
        add_where(where_parts, base_args, "COALESCE(rp.final_category, rp.rewrite_category, '') = ANY(${i}::text[])", categories_selected)
    if source_ids:
        add_where(where_parts, base_args, "rp.source_id = ANY(${i}::bigint[])", source_ids)
    parsed_date_from = parse_date_filter(date_from)
    parsed_date_to = parse_date_filter(date_to)
    if parsed_date_from:
        add_where(where_parts, base_args, "timezone('Asia/Yekaterinburg', COALESCE(rp.posted_at, rp.created_at))::date >= ${i}::date", parsed_date_from)
    if parsed_date_to:
        add_where(where_parts, base_args, "timezone('Asia/Yekaterinburg', COALESCE(rp.posted_at, rp.created_at))::date <= ${i}::date", parsed_date_to)
    if score_min.strip().isdigit():
        add_where(where_parts, base_args, "rp.qualification_score >= ${i}::int", int(score_min.strip()))
    if score_max.strip().isdigit():
        add_where(where_parts, base_args, "rp.qualification_score <= ${i}::int", int(score_max.strip()))
    where_sql = " AND ".join(where_parts)
    total = int(
        await pool.fetchval(
            f"""
            SELECT COUNT(*)
            FROM raw_posts rp
            JOIN sources s ON s.id=rp.source_id
            WHERE {where_sql}
            """,
            *base_args,
        )
        or 0
    )
    pager = pagination(page, per_page, total)
    limit_index = len(base_args) + 1
    offset_index = len(base_args) + 2
    rows = await pool.fetch(
        f"""
        SELECT rp.*, s.name AS source_name, s.tag AS source_tag, s.platform AS source_platform, s.url AS source_url,
               u.login AS reviewed_by_login,
               COALESCE(c_id.tag, c_name.tag) AS resolved_category_tag,
               COUNT(rpm.id) AS media_count,
               COALESCE(
                 jsonb_agg(
                   jsonb_build_object(
                     'id', rpm.id,
                     'type', rpm.media_type,
                     'url', rpm.original_url,
                     'status', rpm.status,
                     'error', rpm.error,
                     'duration_sec', rpm.duration_sec
                   )
                   ORDER BY rpm.sort_order ASC, rpm.id ASC
                 ) FILTER (WHERE rpm.id IS NOT NULL),
                 '[]'::jsonb
               ) AS media_items,
               COALESCE(
                 (
                   SELECT jsonb_agg(
                     jsonb_build_object(
                       'platform', pp.platform,
                       'status', pp.status,
                       'target_id', pp.target_id,
                       'external_id', pp.external_id,
                       'url', pp.url,
                       'error', pp.error,
                       'published_at', pp.published_at
                     )
                     ORDER BY pp.platform
                   )
                   FROM post_publications pp
                   WHERE pp.raw_post_id=rp.id
                 ),
                 '[]'::jsonb
               ) AS platform_publications
        FROM raw_posts rp
        JOIN sources s ON s.id=rp.source_id
        LEFT JOIN admin_users u ON u.id=rp.reviewed_by
        LEFT JOIN content_categories c_id ON c_id.sort_order=COALESCE(rp.final_category_id, rp.rewrite_category_id)
        LEFT JOIN content_categories c_name ON lower(c_name.name)=lower(COALESCE(rp.final_category, rp.rewrite_category, ''))
        LEFT JOIN raw_post_media rpm ON rpm.raw_post_id=rp.id AND COALESCE(rpm.editor_hidden, FALSE)=FALSE
        WHERE {where_sql}
        GROUP BY rp.id, s.name, s.tag, s.platform, s.url, u.login, c_id.tag, c_name.tag
        ORDER BY
          CASE COALESCE(rp.editorial_status, 'review')
            WHEN 'review' THEN 0
            WHEN 'edited' THEN 1
            WHEN 'regenerating' THEN 2
            WHEN 'rejected' THEN 3
            WHEN 'accepted' THEN 4
            WHEN 'publish_failed' THEN 5
            WHEN 'published' THEN 6
            ELSE 7
          END,
          {sort_map.get(sort, sort_map["rewritten_desc"])}
        LIMIT ${limit_index} OFFSET ${offset_index}
        """,
        *base_args,
        pager["per_page"],
        pager["offset"],
    )
    posts = [prepare_editor_post(row) for row in rows]
    categories = await writer_category_options()
    counts_rows = await pool.fetch(
        """
        SELECT COALESCE(editorial_status, 'review') AS status, COUNT(*) AS count
        FROM raw_posts
        WHERE rewrite_status='ready'
        GROUP BY COALESCE(editorial_status, 'review')
        """
    )
    counts = {str(row["status"]): int(row["count"]) for row in counts_rows}
    template_name = "editor_content.html" if request.headers.get("hx-request") else "editor.html"
    return templates.TemplateResponse(
        template_name,
        base_context(
            request,
            user,
            posts=posts,
            categories=categories,
            status_options=EDITOR_STATUS_OPTIONS,
            status_filter=editorial_statuses[0] if len(editorial_statuses) == 1 else "",
            editorial_statuses=editorial_statuses,
            categories_selected=categories_selected,
            source_ids=source_ids,
            date_from=date_from,
            date_to=date_to,
            score_min=score_min,
            score_max=score_max,
            sort=sort,
            facets=await editor_filter_facets(pool),
            counts=counts,
            preserved_query=preserved_query(request, {"page", "per_page"}),
            pagination=pager,
        ),
    )


@app.post("/editor/{post_id}/accept")
async def editor_accept(request: Request, post_id: int, csrf_token: str = Form(...)):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    require_csrf(user, csrf_token)
    pool = await get_pool()
    row = await pool.fetchrow(
        """
        SELECT rp.final_text,
               rp.rewritten_text,
               COALESCE(rp.final_category, rp.rewrite_category, '') AS category_name,
               COALESCE(rp.final_category_id, rp.rewrite_category_id) AS category_id,
               COALESCE(rp.final_source_tag, rp.rewrite_source_tag, s.tag, '') AS source_tag
        FROM raw_posts rp
        JOIN sources s ON s.id=rp.source_id
        WHERE rp.id=$1 AND rp.rewrite_status='ready'
        """,
        post_id,
    )
    if not row:
        return redirect("/editor")
    category = await resolve_category_for_editor(row["category_name"], row["category_id"])
    source_tag = normalize_hash_tag(row["source_tag"] or "", "source")
    final_text = row["final_text"] or row["rewritten_text"] or ""
    await pool.execute(
        """
        UPDATE raw_posts
        SET editorial_status='accepted',
            final_text=$2,
            final_category=$3,
            final_category_tag=$4,
            final_source_tag=$5,
            final_category_id=$6,
            reviewed_by=$7,
            reviewed_at=NOW(),
            updated_at=NOW()
        WHERE id=$1 AND rewrite_status='ready'
        """,
        post_id,
        final_text,
        category["name"],
        category["tag"],
        source_tag,
        category["id"],
        user["id"],
    )
    await audit(user["id"], "editor.accept", "raw_post", post_id)
    if request.headers.get("hx-request"):
        row = await fetch_single_editor_row(pool, post_id)
        if row:
            categories = await writer_category_options()
            return templates.TemplateResponse(
                "editor_post.html",
                base_context(request, user, p=prepare_editor_post(row), categories=categories)
            )
    return redirect("/editor")


@app.post("/editor/{post_id}/reject")
async def editor_reject(request: Request, post_id: int, csrf_token: str = Form(...), editor_notes: str = Form("")):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    require_csrf(user, csrf_token)
    pool = await get_pool()
    await pool.execute(
        """
        UPDATE raw_posts
        SET editorial_status='rejected',
            editor_notes=$2,
            reviewed_by=$3,
            reviewed_at=NOW(),
            updated_at=NOW()
        WHERE id=$1 AND rewrite_status='ready'
        """,
        post_id,
        editor_notes.strip()[:1000],
        user["id"],
    )
    await audit(user["id"], "editor.reject", "raw_post", post_id)
    if request.headers.get("hx-request"):
        row = await fetch_single_editor_row(pool, post_id)
        if row:
            categories = await writer_category_options()
            return templates.TemplateResponse(
                "editor_post.html",
                base_context(request, user, p=prepare_editor_post(row), categories=categories)
            )
    return redirect("/editor")


@app.post("/editor/{post_id}/save")
async def editor_save(
    request: Request,
    post_id: int,
    csrf_token: str = Form(...),
    final_text: str = Form(...),
    final_category: str = Form(...),
    final_source_tag: str = Form(""),
    editor_notes: str = Form(""),
    action: str = Form("save"),
    delete_media_ids: list[int] | None = Form(default=None),
    media_files: list[UploadFile] | None = File(default=None),
):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    require_csrf(user, csrf_token)
    final_category = final_category.strip()
    category = await resolve_category_for_editor(final_category)
    final_category = category["name"]
    final_category_tag = category["tag"]
    final_source_tag = normalize_hash_tag(final_source_tag, "source")
    status_value = "accepted" if action == "accept" else "edited"
    pool = await get_pool()
    await pool.execute(
        """
        UPDATE raw_posts
        SET editorial_status=$2,
            final_text=$3,
            final_category=$4,
            final_category_tag=$5,
            final_source_tag=$6,
            editor_notes=$7,
            final_category_id=$8,
            reviewed_by=CASE WHEN $2='accepted' THEN $9 ELSE reviewed_by END,
            reviewed_at=CASE WHEN $2='accepted' THEN NOW() ELSE reviewed_at END,
            edited_at=NOW(),
            updated_at=NOW()
        WHERE id=$1 AND rewrite_status='ready'
        """,
        post_id,
        status_value,
        final_text.strip(),
        final_category,
        final_category_tag,
        final_source_tag,
        editor_notes.strip()[:1000],
        category["id"],
        user["id"],
    )
    delete_media_ids = delete_media_ids or []
    media_files = media_files or []
    if delete_media_ids:
        local_media_paths = [
            uploaded_editor_media_path(str(row["original_url"] or ""))
            for row in await pool.fetch(
                """
                SELECT original_url
                FROM raw_post_media
                WHERE raw_post_id=$1
                  AND id=ANY($2::bigint[])
                  AND editor_added=TRUE
                """,
                post_id,
                delete_media_ids,
            )
        ]
        await pool.execute(
            """
            UPDATE raw_post_media
            SET editor_hidden=TRUE,
                updated_at=NOW()
            WHERE raw_post_id=$1 AND id=ANY($2::bigint[])
            """,
            post_id,
            delete_media_ids,
        )
        for path in local_media_paths:
            if path and path.is_file():
                try:
                    path.unlink(missing_ok=True)
                except OSError:
                    logger.warning("Could not delete editor media file {}", path)
    for media_file in media_files:
        if not media_file.filename:
            continue
        content = await media_file.read()
        if not content or len(content) > 50 * 1024 * 1024:
            continue
        original_name = Path(media_file.filename or "media").name
        suffix = Path(original_name).suffix.lower()
        if not suffix:
            guessed = mimetypes.guess_extension(media_file.content_type or "")
            suffix = guessed or ".bin"
        safe_name = f"{post_id}_{int(time.time())}_{secrets.token_hex(6)}{suffix}"
        target = EDITOR_MEDIA_DIR / safe_name
        target.write_bytes(content)
        public_url = f"/uploads/editor_media/{safe_name}"
        content_type = media_file.content_type or mimetypes.guess_type(original_name)[0] or ""
        if content_type.startswith("image/"):
            media_type = "photo"
        elif content_type.startswith("video/"):
            media_type = "video"
        else:
            media_type = "doc"
        sort_order = int(
            await pool.fetchval("SELECT COALESCE(MAX(sort_order), -1) + 1 FROM raw_post_media WHERE raw_post_id=$1", post_id)
            or 0
        )
        await pool.execute(
            """
            INSERT INTO raw_post_media(
              raw_post_id, platform, media_type, original_url, preview_url, sort_order, status,
              editor_added, editor_uploaded_by, editor_uploaded_at
            )
            VALUES($1, 'manual', $2, $3, $3, $4, 'ready', TRUE, $5, NOW())
            """,
            post_id,
            media_type,
            public_url,
            sort_order,
            user["id"],
        )
    await audit(user["id"], "editor.save" if status_value == "edited" else "editor.save_accept", "raw_post", post_id)
    if request.headers.get("hx-request"):
        row = await fetch_single_editor_row(pool, post_id)
        if row:
            categories = await writer_category_options()
            return templates.TemplateResponse(
                "editor_post.html",
                base_context(request, user, p=prepare_editor_post(row), categories=categories)
            )
    return redirect("/editor")


@app.get("/raw/{post_id}", response_class=HTMLResponse)
async def raw_post_detail(request: Request, post_id: int):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    pool = await get_pool()
    row = await pool.fetchrow(
        """
        SELECT rp.*, s.name AS source_name, s.tag AS source_tag, s.platform AS source_platform, s.url AS source_url,
               COUNT(rpm.id) AS media_count,
               COALESCE(
                 jsonb_agg(
                   jsonb_build_object(
                     'id', rpm.id,
                     'type', rpm.media_type,
                     'url', rpm.original_url,
                     'status', rpm.status,
                     'error', rpm.error,
                     'duration_sec', rpm.duration_sec
                   )
                   ORDER BY rpm.sort_order ASC, rpm.id ASC
                 ) FILTER (WHERE rpm.id IS NOT NULL),
                 '[]'::jsonb
               ) AS media_items,
               b.provider AS qualification_provider,
               b.model AS batch_model,
               b.status AS batch_status,
               b.posts_count AS batch_posts_count,
               b.accepted_count AS batch_accepted_count,
               b.rejected_count AS batch_rejected_count,
               b.maybe_count AS batch_maybe_count,
               b.error AS batch_error,
               b.prompt_text AS batch_prompt_text,
               b.created_at AS batch_created_at,
               b.completed_at AS batch_completed_at,
               b.response_json AS batch_response_json,
               b.prompt_tokens AS batch_prompt_tokens,
               b.completion_tokens AS batch_completion_tokens,
               b.total_tokens AS batch_total_tokens,
               b.estimated_cost_usd AS batch_estimated_cost_usd,
               wb.provider AS rewrite_provider,
               wb.model AS writer_batch_model,
               wb.status AS writer_batch_status,
               wb.posts_count AS writer_batch_posts_count,
               wb.ready_count AS writer_batch_ready_count,
               wb.failed_count AS writer_batch_failed_count,
               wb.error AS writer_batch_error,
               wb.prompt_text AS writer_batch_prompt_text,
               wb.created_at AS writer_batch_created_at,
               wb.completed_at AS writer_batch_completed_at,
               wb.response_json AS writer_batch_response_json,
               wb.prompt_tokens AS writer_prompt_tokens,
               wb.completion_tokens AS writer_completion_tokens,
               wb.total_tokens AS writer_total_tokens,
               wb.estimated_cost_usd AS writer_estimated_cost_usd
        FROM raw_posts rp
        JOIN sources s ON s.id=rp.source_id
        LEFT JOIN raw_post_media rpm ON rpm.raw_post_id=rp.id
        LEFT JOIN ai_qualification_batches b ON b.id=rp.qualification_batch_id
        LEFT JOIN ai_writer_batches wb ON wb.id=rp.rewrite_batch_id
        WHERE rp.id=$1
        GROUP BY rp.id, s.name, s.tag, s.platform, s.url, b.id, wb.id
        """,
        post_id,
    )
    if not row:
        return redirect("/raw")
    post = prepare_raw_post(row)
    post["batch_created_at_fmt"] = format_dt(post.get("batch_created_at"))
    post["batch_completed_at_fmt"] = format_dt(post.get("batch_completed_at"))
    response_json = post.get("batch_response_json") or {}
    if isinstance(response_json, str):
        try:
            response_json = json.loads(response_json)
        except json.JSONDecodeError:
            pass
    post["batch_response_pretty"] = json.dumps(response_json, ensure_ascii=False, indent=2)
    if not post.get("batch_prompt_text") and post.get("qualification_batch_id"):
        post["batch_prompt_text"] = (
            "Точный prompt для этого старого batch ещё не сохранялся.\n"
            f"Prompt hash: {post.get('qualification_prompt_hash') or '—'}"
        )
    else:
        post["batch_prompt_text"] = post.get("batch_prompt_text") or ""
    writer_response_json = post.get("writer_batch_response_json") or {}
    if isinstance(writer_response_json, str):
        try:
            writer_response_json = json.loads(writer_response_json)
        except json.JSONDecodeError:
            pass
    post["writer_batch_created_at_fmt"] = format_dt(post.get("writer_batch_created_at"))
    post["writer_batch_completed_at_fmt"] = format_dt(post.get("writer_batch_completed_at"))
    post["rewritten_at_fmt"] = format_dt(post.get("rewritten_at"))
    post["writer_batch_response_pretty"] = json.dumps(writer_response_json, ensure_ascii=False, indent=2)
    if not post.get("writer_batch_prompt_text") and post.get("rewrite_batch_id"):
        post["writer_batch_prompt_text"] = (
            "Точный prompt для этого старого writer batch ещё не сохранялся.\n"
            f"Prompt hash: {post.get('rewrite_prompt_hash') or '—'}"
        )
    else:
        post["writer_batch_prompt_text"] = post.get("writer_batch_prompt_text") or ""
    return templates.TemplateResponse("raw_post_detail.html", base_context(request, user, post=post))


@app.get("/prompt-test", response_class=HTMLResponse)
async def prompt_test(request: Request, post_id: int | None = None, q_status: str = "all"):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    q_status = normalize_prompt_test_status(q_status)
    posts, selected = await prompt_test_posts(post_id, q_status)
    category_payload = await writer_category_payload()
    max_text_chars = max(200, int(await fetch_setting("ai_writer_max_text_chars", 3500) or 3500))
    prompt = str(await fetch_setting("ai_writer_prompt", "") or "")
    provider = str(await fetch_setting("ai_writer_provider", "anthropic") or "anthropic")
    model = str(await fetch_setting("ai_writer_model", "") or "")
    payload = prompt_test_payload(selected, max_text_chars) if selected else []
    if payload:
        payload["categories"] = category_payload
    return templates.TemplateResponse(
        "prompt_test.html",
        base_context(
            request,
            user,
            posts=posts,
            selected=selected,
            prompt=prompt,
            provider=provider,
            model=model,
            q_status=q_status,
            status_options=PROMPT_TEST_STATUS_OPTIONS,
            max_text_chars=max_text_chars,
            payload_json=json.dumps(payload, ensure_ascii=False, indent=2),
            result=None,
            error=None,
        ),
    )


@app.post("/prompt-test", response_class=HTMLResponse)
async def prompt_test_run(
    request: Request,
    csrf_token: str = Form(...),
    post_id: int = Form(...),
    prompt: str = Form(...),
    q_status: str = Form("all"),
):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    require_csrf(user, csrf_token)
    q_status = normalize_prompt_test_status(q_status)
    posts, selected = await prompt_test_posts(post_id, q_status)
    category_payload = await writer_category_payload()
    max_text_chars = max(200, int(await fetch_setting("ai_writer_max_text_chars", 3500) or 3500))
    provider = str(await fetch_setting("ai_writer_provider", "anthropic") or "anthropic")
    model = str(await fetch_setting("ai_writer_model", "") or "").strip()
    api_key = str(await fetch_setting("ai_writer_api_key", "") or "").strip()
    api_base = str(await fetch_setting("ai_writer_api_base", "") or "").strip()
    temperature = max(0.0, float(await fetch_setting("ai_writer_temperature", 0.4) or 0.4))
    timeout = max(10, int(await fetch_setting("ai_writer_timeout_sec", 180) or 180))
    normalized_prompt = prompt.replace("\\r\\n", "\n").replace("\\n", "\n").strip()
    payload = prompt_test_payload(selected, max_text_chars) if selected else []
    if payload:
        payload["categories"] = category_payload
    result = None
    error = None
    if not selected:
        error = "Пост не найден."
    elif not model or not api_key:
        error = "Не заполнены модель или API-ключ райтера в настройках."
    elif not normalized_prompt:
        error = "Промпт пустой."
    else:
        try:
            import litellm

            kwargs: dict[str, Any] = {
                "model": normalize_model(provider, model),
                "messages": [
                    {"role": "system", "content": normalized_prompt},
                    {"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
                ],
                "temperature": temperature,
                "timeout": timeout,
            }
            if api_key:
                kwargs["api_key"] = api_key
            if api_base:
                kwargs["api_base"] = api_base
            response = await asyncio.to_thread(litellm.completion, **kwargs)
            content = response.choices[0].message.content
            parsed = None
            raw_json = str(content or "").strip()
            try:
                if raw_json.startswith("```"):
                    raw_json = raw_json.strip("`")
                    if raw_json.lower().startswith("json"):
                        raw_json = raw_json[4:].strip()
                parsed = json.loads(raw_json)
            except Exception:
                parsed = None
            result = {
                "content": content,
                "parsed_json": json.dumps(parsed, ensure_ascii=False, indent=2) if parsed is not None else "",
                "preview_text": prompt_test_preview_text(parsed),
                "preview_category": prompt_test_preview_category(parsed),
                "preview_notes": prompt_test_preview_notes(parsed),
                "usage": response_usage(response),
                "model": kwargs["model"],
            }
            await audit(user["id"], "writer_prompt_test.run", "raw_post", int(selected["id"]), {"provider": provider, "model": model})
        except Exception as exc:
            logger.exception("Prompt test failed")
            error = str(exc)
    return templates.TemplateResponse(
        "prompt_test.html",
        base_context(
            request,
            user,
            posts=posts,
            selected=selected,
            prompt=normalized_prompt,
            provider=provider,
            model=model,
            q_status=q_status,
            status_options=PROMPT_TEST_STATUS_OPTIONS,
            max_text_chars=max_text_chars,
            payload_json=json.dumps(payload, ensure_ascii=False, indent=2),
            result=result,
            error=error,
        ),
    )


@app.get("/workers", response_class=HTMLResponse)
async def workers(request: Request):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    pool = await get_pool()
    rows = await pool.fetch(
        """
        SELECT wc.name, wc.enabled, wc.settings_json, wh.heartbeat_at, wh.status, wh.current_job_id, wh.meta_json
        FROM worker_controls wc
        LEFT JOIN worker_heartbeats wh ON wh.name=wc.name
        ORDER BY wc.name
        """
    )
    settings_rows = await pool.fetch("SELECT * FROM app_settings ORDER BY category, key")
    settings = []
    for row in settings_rows:
        item = dict(row)
        if isinstance(item.get("value_json"), str):
            try:
                item["value_json"] = json.loads(item["value_json"])
            except json.JSONDecodeError:
                pass
        if item.get("key") in {"ai_writer_categories", "tg_poster_schedule_json", "vk_poster_schedule_json", *AI_WORKER_ENABLED_SETTINGS.values()}:
            continue
        settings.append(item)
    settings.sort(key=setting_sort_key)
    ai_provider = str(setting_value(settings, "ai_qualifier_provider", "openrouter") or "openrouter")
    ai_model = str(setting_value(settings, "ai_qualifier_model", "") or "")
    ai_api_key = str(setting_value(settings, "ai_qualifier_api_key", "") or "")
    ai_model_options = await model_options_for_provider(ai_provider, ai_api_key)
    writer_provider = str(setting_value(settings, "ai_writer_provider", "anthropic") or "anthropic")
    writer_model = str(setting_value(settings, "ai_writer_model", "") or "")
    writer_api_key = str(setting_value(settings, "ai_writer_api_key", "") or "")
    writer_model_options = await model_options_for_provider(writer_provider, writer_api_key)
    return templates.TemplateResponse(
        "workers.html",
        base_context(
            request,
            user,
            workers=[dict(r) for r in rows],
            settings=settings,
            provider_options=PROVIDER_OPTIONS,
            ai_provider=ai_provider,
            ai_model=ai_model,
            ai_model_options=ai_model_options,
            writer_provider=writer_provider,
            writer_model=writer_model,
            writer_model_options=writer_model_options,
            categories=await category_rows(),
            tg_schedule=await tg_poster_schedule_rows(),
            vk_schedule=await vk_poster_schedule_rows(),
            category_titles=CATEGORY_TITLES,
            prompt_hints=PROMPT_HINTS,
        ),
    )


@app.post("/tg-poster-schedule/create")
async def tg_poster_schedule_create(
    request: Request,
    csrf_token: str = Form(...),
    time_value: str = Form(..., alias="time"),
    count: int = Form(1),
    enabled: str | None = Form(default=None),
):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    require_csrf(user, csrf_token)
    rows = await tg_poster_schedule_rows()
    rows.append(
        {
            "id": f"slot-{secrets.token_hex(4)}",
            "time": time_value,
            "count": count,
            "enabled": enabled == "on",
        }
    )
    rows = normalize_tg_schedule(rows)
    await save_tg_poster_schedule(rows)
    await audit(user["id"], "tg_schedule.create", "tg_poster_schedule", None, {"rows": rows})
    return redirect("/workers")


@app.post("/tg-poster-schedule/{schedule_id}/update")
async def tg_poster_schedule_update(
    request: Request,
    schedule_id: str,
    csrf_token: str = Form(...),
    time_value: str = Form(..., alias="time"),
    count: int = Form(1),
    enabled: str | None = Form(default=None),
):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    require_csrf(user, csrf_token)
    rows = await tg_poster_schedule_rows()
    for row in rows:
        if row["id"] == schedule_id:
            row.update({"time": time_value, "count": count, "enabled": enabled == "on"})
            break
    rows = normalize_tg_schedule(rows)
    await save_tg_poster_schedule(rows)
    await audit(user["id"], "tg_schedule.update", "tg_poster_schedule", None, {"id": schedule_id, "rows": rows})
    return redirect("/workers")


@app.post("/tg-poster-schedule/{schedule_id}/delete")
async def tg_poster_schedule_delete(request: Request, schedule_id: str, csrf_token: str = Form(...)):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    require_csrf(user, csrf_token)
    rows = [row for row in await tg_poster_schedule_rows() if row["id"] != schedule_id]
    await save_tg_poster_schedule(rows)
    await audit(user["id"], "tg_schedule.delete", "tg_poster_schedule", None, {"id": schedule_id, "rows": rows})
    return redirect("/workers")


@app.post("/vk-poster-schedule/create")
async def vk_poster_schedule_create(
    request: Request,
    csrf_token: str = Form(...),
    time_value: str = Form(..., alias="time"),
    count: int = Form(1),
    enabled: str | None = Form(default=None),
):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    require_csrf(user, csrf_token)
    rows = await vk_poster_schedule_rows()
    rows.append({"id": f"vk-slot-{secrets.token_hex(4)}", "time": time_value, "count": count, "enabled": enabled == "on"})
    rows = normalize_tg_schedule(rows)
    await save_vk_poster_schedule(rows)
    await audit(user["id"], "vk_schedule.create", "vk_poster_schedule", None, {"rows": rows})
    return redirect("/workers")


@app.post("/vk-poster-schedule/{schedule_id}/update")
async def vk_poster_schedule_update(
    request: Request,
    schedule_id: str,
    csrf_token: str = Form(...),
    time_value: str = Form(..., alias="time"),
    count: int = Form(1),
    enabled: str | None = Form(default=None),
):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    require_csrf(user, csrf_token)
    rows = await vk_poster_schedule_rows()
    for row in rows:
        if row["id"] == schedule_id:
            row.update({"time": time_value, "count": count, "enabled": enabled == "on"})
            break
    rows = normalize_tg_schedule(rows)
    await save_vk_poster_schedule(rows)
    await audit(user["id"], "vk_schedule.update", "vk_poster_schedule", None, {"id": schedule_id, "rows": rows})
    return redirect("/workers")


@app.post("/vk-poster-schedule/{schedule_id}/delete")
async def vk_poster_schedule_delete(request: Request, schedule_id: str, csrf_token: str = Form(...)):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    require_csrf(user, csrf_token)
    rows = [row for row in await vk_poster_schedule_rows() if row["id"] != schedule_id]
    await save_vk_poster_schedule(rows)
    await audit(user["id"], "vk_schedule.delete", "vk_poster_schedule", None, {"id": schedule_id, "rows": rows})
    return redirect("/workers")


@app.post("/categories/create")
async def category_create(
    request: Request,
    csrf_token: str = Form(...),
    name: str = Form(...),
    tag: str = Form(""),
    site_name: str = Form(...),
    site_slug: str = Form(...),
    site_enabled: str | None = Form(None),
):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    require_csrf(user, csrf_token)
    name = name.strip().strip("#")
    if not name:
        return redirect("/workers")
    tag = normalize_hash_tag(tag or name, "category")
    site_name = site_name.strip()
    site_slug = re.sub(r"[^a-z0-9-]+", "-", site_slug.strip().lower().replace("_", "-")).strip("-")
    if not site_name or not site_slug:
        return redirect("/workers")
    pool = await get_pool()
    sort_order = int(await pool.fetchval("SELECT COALESCE(MAX(sort_order), 0) + 1 FROM content_categories") or 1)
    await pool.execute(
        """
        INSERT INTO content_categories(name, tag, sort_order, site_name, site_slug, site_enabled)
        VALUES($1, $2, $3, $4, $5, $6)
        ON CONFLICT (name) DO UPDATE
        SET tag=$2,
            site_name=$4,
            site_slug=$5,
            site_enabled=$6,
            is_active=TRUE,
            updated_at=NOW()
        """,
        name,
        tag,
        sort_order,
        site_name,
        site_slug,
        site_enabled is not None,
    )
    await audit(user["id"], "category.create", "content_category", None, {"name": name, "tag": tag, "site_name": site_name, "site_slug": site_slug, "site_enabled": site_enabled is not None})
    return redirect("/workers")


@app.post("/categories/{category_id}/update")
async def category_update(
    request: Request,
    category_id: int,
    csrf_token: str = Form(...),
    name: str = Form(...),
    tag: str = Form(""),
    site_name: str = Form(...),
    site_slug: str = Form(...),
    site_enabled: str | None = Form(None),
):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    require_csrf(user, csrf_token)
    name = name.strip().strip("#")
    if not name:
        return redirect("/workers")
    tag = normalize_hash_tag(tag or name, "category")
    site_name = site_name.strip()
    site_slug = re.sub(r"[^a-z0-9-]+", "-", site_slug.strip().lower().replace("_", "-")).strip("-")
    if not site_name or not site_slug:
        return redirect("/workers")
    pool = await get_pool()
    await pool.execute(
        """
        UPDATE content_categories
        SET name=$2,
            tag=$3,
            site_name=$4,
            site_slug=$5,
            site_enabled=$6,
            updated_at=NOW()
        WHERE id=$1
        """,
        category_id,
        name,
        tag,
        site_name,
        site_slug,
        site_enabled is not None,
    )
    await audit(user["id"], "category.update", "content_category", category_id, {"name": name, "tag": tag, "site_name": site_name, "site_slug": site_slug, "site_enabled": site_enabled is not None})
    return redirect("/workers")


@app.post("/categories/{category_id}/toggle")
async def category_toggle(request: Request, category_id: int, csrf_token: str = Form(...)):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    require_csrf(user, csrf_token)
    pool = await get_pool()
    row = await pool.fetchrow(
        """
        UPDATE content_categories
        SET is_active=NOT is_active,
            updated_at=NOW()
        WHERE id=$1
        RETURNING name, is_active
        """,
        category_id,
    )
    await audit(
        user["id"],
        "category.toggle",
        "content_category",
        category_id,
        {"name": row["name"] if row else None, "is_active": bool(row["is_active"]) if row else None},
    )
    return redirect("/workers")


@app.post("/categories/{category_id}/delete")
async def category_delete(request: Request, category_id: int, csrf_token: str = Form(...)):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    require_csrf(user, csrf_token)
    pool = await get_pool()
    row = await pool.fetchrow(
        """
        UPDATE content_categories
        SET is_active=FALSE,
            updated_at=NOW()
        WHERE id=$1
        RETURNING name
        """,
        category_id,
    )
    await audit(user["id"], "category.archive", "content_category", category_id, {"name": row["name"] if row else None})
    return redirect("/workers")


@app.post("/workers/{worker_name}/toggle")
async def worker_toggle(request: Request, worker_name: str, csrf_token: str = Form(...)):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    require_csrf(user, csrf_token)
    pool = await get_pool()
    async with pool.acquire() as conn:
        async with conn.transaction():
            row = await conn.fetchrow(
                """
                UPDATE worker_controls
                SET enabled=NOT enabled,
                    updated_by=$2,
                    updated_at=NOW()
                WHERE name=$1
                RETURNING enabled
                """,
                worker_name,
                user["id"],
            )
            if not row:
                return redirect("/workers")
            enabled = bool(row["enabled"])
            setting_key = AI_WORKER_ENABLED_SETTINGS.get(worker_name)
            if setting_key:
                await conn.execute(
                    """
                    UPDATE app_settings
                    SET value_json=$2::jsonb,
                        updated_by=$3,
                        updated_at=NOW()
                    WHERE key=$1
                    """,
                    setting_key,
                    json.dumps(enabled),
                    user["id"],
                )
    await audit(
        user["id"],
        "worker.toggle",
        "worker",
        None,
        {
            "name": worker_name,
            "enabled": enabled,
            "synced_setting": AI_WORKER_ENABLED_SETTINGS.get(worker_name),
        },
    )
    return redirect("/workers")


@app.post("/settings/save")
async def settings_save(request: Request, csrf_token: str = Form(...), key: str = Form(...), value: str = Form(...)):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    require_csrf(user, csrf_token)
    pool = await get_pool()
    row = await pool.fetchrow("SELECT value_type FROM app_settings WHERE key=$1", key)
    if not row:
        return redirect("/workers")
    value_type = str(row["value_type"])
    if value_type == "int":
        parsed: Any = int(value)
    elif value_type == "float":
        parsed = float(value)
    elif value_type == "bool":
        parsed = str(value).lower() in {"1", "true", "yes", "on"}
    elif value_type == "secret":
        if not value.strip():
            return redirect("/workers")
        parsed = value.strip()
    elif value_type == "json":
        parsed = json.loads(value)
    else:
        parsed = value
        if key in {"ai_qualifier_prompt", "ai_qualifier_contract", "ai_writer_prompt", "ai_writer_contract"}:
            parsed = parsed.replace("\\r\\n", "\n").replace("\\n", "\n")
    await pool.execute(
        """
        UPDATE app_settings
        SET value_json=$2::jsonb,
            updated_by=$3,
            updated_at=NOW()
        WHERE key=$1
        """,
        key,
        json.dumps(parsed, ensure_ascii=False),
        user["id"],
    )
    await audit(user["id"], "setting.update", "setting", None, {"key": key, "value": parsed})
    return redirect("/workers")


@app.get("/logs", response_class=HTMLResponse)
async def logs_page(
    request: Request,
    q: str = "",
    action: str = "",
    actor_id: int = 0,
    entity_type: str = "",
    date_from: str = "",
    date_to: str = "",
    page: int = 1,
    per_page: int = 50,
):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    pool = await get_pool()
    await prune_audit_log()
    where_parts: list[str] = []
    args: list[Any] = []
    if q.strip():
        add_where(
            where_parts,
            args,
            "(al.action ILIKE ${i} OR al.entity_type ILIKE ${i} OR COALESCE(al.after_json::text, '') ILIKE ${i})",
            f"%{q.strip()}%",
        )
    if action.strip():
        add_where(where_parts, args, "al.action=${i}", action.strip())
    if actor_id > 0:
        add_where(where_parts, args, "al.actor_id=${i}", actor_id)
    if entity_type.strip():
        add_where(where_parts, args, "al.entity_type=${i}", entity_type.strip())
    parsed_date_from = parse_date_filter(date_from)
    parsed_date_to = parse_date_filter(date_to)
    if parsed_date_from:
        add_where(where_parts, args, "timezone('Asia/Yekaterinburg', al.created_at)::date >= ${i}::date", parsed_date_from)
    if parsed_date_to:
        add_where(where_parts, args, "timezone('Asia/Yekaterinburg', al.created_at)::date <= ${i}::date", parsed_date_to)
    where_sql = " AND ".join(where_parts) if where_parts else "TRUE"
    total = int(
        await pool.fetchval(
            f"""
            SELECT COUNT(*)
            FROM audit_log al
            LEFT JOIN admin_users u ON u.id=al.actor_id
            WHERE {where_sql}
            """,
            *args,
        )
        or 0
    )
    pager = pagination(page, per_page, total)
    limit_index = len(args) + 1
    offset_index = len(args) + 2
    rows = await pool.fetch(
        f"""
        SELECT al.*, u.login AS actor_login
        FROM audit_log al
        LEFT JOIN admin_users u ON u.id=al.actor_id
        WHERE {where_sql}
        ORDER BY al.created_at DESC, al.id DESC
        LIMIT ${limit_index} OFFSET ${offset_index}
        """,
        *args,
        pager["per_page"],
        pager["offset"],
    )
    action_rows = await pool.fetch(
        """
        SELECT action, COUNT(*) AS count
        FROM audit_log
        WHERE created_at >= NOW() - INTERVAL '30 days'
        GROUP BY action
        ORDER BY COUNT(*) DESC, action
        LIMIT 80
        """
    )
    actor_rows = await pool.fetch(
        """
        SELECT u.id, u.login, COUNT(al.id) AS count
        FROM audit_log al
        JOIN admin_users u ON u.id=al.actor_id
        WHERE al.created_at >= NOW() - INTERVAL '30 days'
        GROUP BY u.id, u.login
        ORDER BY COUNT(al.id) DESC, u.login
        """
    )
    entity_rows = await pool.fetch(
        """
        SELECT entity_type, COUNT(*) AS count
        FROM audit_log
        WHERE created_at >= NOW() - INTERVAL '30 days'
        GROUP BY entity_type
        ORDER BY COUNT(*) DESC, entity_type
        LIMIT 50
        """
    )
    return templates.TemplateResponse(
        "logs.html",
        base_context(
            request,
            user,
            logs=[prepare_audit_row(row) for row in rows],
            q=q,
            action=action,
            actor_id=actor_id,
            entity_type=entity_type,
            date_from=date_from,
            date_to=date_to,
            actions=[dict(row) for row in action_rows],
            actors=[dict(row) for row in actor_rows],
            entity_types=[dict(row) for row in entity_rows],
            preserved_query=preserved_query(request, {"page", "per_page"}),
            pagination=pager,
        ),
    )


@app.get("/users", response_class=HTMLResponse)
async def users(request: Request, page: int = 1, per_page: int = 50):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    pool = await get_pool()
    total = int(await pool.fetchval("SELECT COUNT(*) FROM admin_users") or 0)
    pager = pagination(page, per_page, total)
    rows = await pool.fetch(
        "SELECT id, login, role, is_active, created_at FROM admin_users ORDER BY id LIMIT $1 OFFSET $2",
        pager["per_page"],
        pager["offset"],
    )
    users_rows = []
    for row in rows:
        item = dict(row)
        item["created_at_fmt"] = format_dt(item.get("created_at"))
        users_rows.append(item)
    return templates.TemplateResponse("users.html", base_context(request, user, users=users_rows, pagination=pager))


@app.post("/users/create")
async def user_create(
    request: Request,
    csrf_token: str = Form(...),
    login: str = Form(...),
    password: str = Form(...),
    role: str = Form("editor"),
):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    require_csrf(user, csrf_token)
    if user["role"] != "admin":
        return redirect("/users")
    role = role if role in {"admin", "editor", "viewer"} else "editor"
    pool = await get_pool()
    await pool.execute(
        "INSERT INTO admin_users(login, password_hash, role) VALUES($1, $2, $3)",
        login.strip().lower(),
        hash_password(password),
        role,
    )
    await audit(user["id"], "user.create", "user", None, {"login": login, "role": role})
    return redirect("/users")


@app.post("/users/{user_id}/toggle")
async def user_toggle(request: Request, user_id: int, csrf_token: str = Form(...)):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    require_csrf(user, csrf_token)
    if user["role"] != "admin" or int(user["id"]) == user_id:
        return redirect("/users")
    pool = await get_pool()
    # Prevent deactivating the bootstrap admin account (managed via env secrets).
    bootstrap_login = settings.admin_bootstrap_login.strip().lower()
    target_login = await pool.fetchval("SELECT login FROM admin_users WHERE id=$1", user_id)
    if target_login and target_login == bootstrap_login:
        logger.warning("Attempt to deactivate bootstrap admin '{}' was blocked", bootstrap_login)
        return redirect("/users")
    await pool.execute("UPDATE admin_users SET is_active=NOT is_active, updated_at=NOW() WHERE id=$1", user_id)
    await audit(user["id"], "user.toggle", "user", user_id)
    return redirect("/users")


@app.post("/users/{user_id}/delete")
async def user_delete(request: Request, user_id: int, csrf_token: str = Form(...)):
    user = await get_current_user(request)
    if not user:
        return redirect("/login")
    require_csrf(user, csrf_token)
    if user["role"] != "admin" or int(user["id"]) == user_id:
        return redirect("/users")
    pool = await get_pool()
    bootstrap_login = settings.admin_bootstrap_login.strip().lower()
    target = await pool.fetchrow("SELECT id, login, role FROM admin_users WHERE id=$1", user_id)
    if not target:
        return redirect("/users")
    if str(target["login"]).strip().lower() == bootstrap_login:
        logger.warning("Attempt to delete bootstrap admin '{}' was blocked", bootstrap_login)
        return redirect("/users")
    await pool.execute("DELETE FROM admin_users WHERE id=$1", user_id)
    await audit(user["id"], "user.delete", "user", user_id, None, {"login": target["login"], "role": target["role"]})
    return redirect("/users")
