<?php
/**
 * VK-лидген «Губтор Балконы» — панель управления базой (gubtor.ru/vk-leadgen/)
 *
 * Данные: SQLite /opt/vk-leadgen/data/vk.db (её же пишет сервис /opt/vk-leadgen/run.py).
 * Возможности: просмотр/поиск/фильтры, правка любой ячейки на месте, добавление строк
 * (по одной и массово), удаление, массовые операции по выбранным, импорт CSV/XLSX, выгрузка.
 */
declare(strict_types=1);

/** Поля с датой/временем — показываем их владельцу в формате дд.мм.гггг чч:мм. */
const DATE_FIELDS = ['last_run', 'last_start', 'created_at', 'updated_at'];

/** 2026-09-24 05:00:00 → 24.09.2026 05:00 */
function ru_dt(?string $s, bool $seconds = false): string {
    $s = trim((string)$s);
    if ($s === '') return '';
    $ts = strtotime($s);
    if ($ts === false) return $s;
    return date($seconds ? 'd.m.Y H:i:s' : 'd.m.Y H:i', $ts);
}

/** Даты внутри текстов (отчёты модулей): 2026-09-22 10:17:31 → 22.09.2026 10:17:31 */
function ru_dates_in_text(string $t): string {
    return preg_replace_callback(
        '/(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?/',
        static function (array $m): string {
            $out = $m[3] . '.' . $m[2] . '.' . $m[1];
            if (isset($m[4])) $out .= ' ' . $m[4] . ':' . $m[5] . (isset($m[6]) ? ':' . $m[6] : '');
            return $out;
        },
        $t
    );
}

/** 24.09.2026 05:00 → 2026-09-24 05:00:00 (чтобы правка в панели сохранялась в базовом виде) */
function ru_to_iso(string $v): string {
    $v = trim($v);
    if (preg_match('/^(\d{2})\.(\d{2})\.(\d{4})(?:[ ,]+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/', $v, $m)) {
        return sprintf('%s-%s-%s %02d:%02d:%02d', $m[3], $m[2], $m[1], (int)($m[4] ?? 0), (int)($m[5] ?? 0), (int)($m[6] ?? 0));
    }
    return $v;
}

ini_set('session.gc_maxlifetime', '7776000');
ini_set('session.cookie_lifetime', '7776000');
ini_set('session.use_strict_mode', '0');
session_set_cookie_params([
    'lifetime' => 7776000, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax',
]);
session_start();

$SECRETS = require '/etc/quiz-secrets/vk-leadgen.php';
$DB_PATH = '/opt/vk-leadgen/data/vk.db';

function db(): PDO {
    global $DB_PATH;
    $pdo = new PDO('sqlite:' . $DB_PATH, null, null, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_TIMEOUT => 30,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ]);
    $pdo->exec('PRAGMA busy_timeout=30000');
    return $pdo;
}

function db_set(PDO $pdo, string $k, string $v): void {
    $st = $pdo->prepare('INSERT INTO settings (key, value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value = excluded.value');
    $st->execute([$k, $v]);
}

/* ── описание таблиц: поле => [заголовок, тип, ширина, подсказка] ── */
$TABLES = [
    'contacts' => [
        'title' => 'Контакты',
        'fields' => [
            'id'            => ['ID', 'id', 90],
            'fio'           => ['Имя', 'text', 160],
            'base'          => ['База', 'text', 150],
            'url'           => ['Ссылка', 'text', 150],
            'processed'     => ['Обработан', 'bool', 90],
            'how'           => ['Как обработан', 'text', 180],
            'friend_status' => ['Friend_status', 'text', 170],
            'activity'      => ['Деятельность', 'text', 150],
            'last_run'      => ['Последнее выполнение', 'text', 150],
            'comment'       => ['Коммент', 'text', 220],
            'comment_post'  => ['Пост с комментом', 'text', 170],
            'wrote_dm'      => ['ЛС', 'bool', 60],
            'msg_history'   => ['История сообщений', 'text', 260],
            'reply'         => ['Ответ', 'text', 160],
            'sale'          => ['Продажа', 'text', 140],
            'city'          => ['Город', 'text', 120],
            'source'        => ['Источник', 'text', 110],
            'last_start'    => ['Старт', 'text', 150],
            'last_workflow' => ['Workflow', 'text', 130],
            'note'          => ['Заметка', 'text', 190],
            'created_at'    => ['Создан', 'ro', 150],
            'updated_at'    => ['Обновлён', 'ro', 150],
        ],
    ],
    'other_contacts' => [
        'title' => 'Другие контакты',
        'fields' => [
            'id'            => ['ID', 'id', 90],
            'fio'           => ['Имя', 'text', 160],
            'base'          => ['База', 'text', 150],
            'url'           => ['Ссылка', 'text', 150],
            'processed'     => ['Обработан', 'bool', 90],
            'how'           => ['Как обработан', 'text', 180],
            'friend_status' => ['Friend_status', 'text', 170],
            'activity'      => ['Деятельность', 'text', 150],
            'last_run'      => ['Последнее выполнение', 'text', 150],
            'city'          => ['Город', 'text', 120],
            'source'        => ['Источник', 'text', 110],
            'note'          => ['Заметка', 'text', 190],
            'created_at'    => ['Создан', 'ro', 150],
            'updated_at'    => ['Обновлён', 'ro', 150],
        ],
    ],
    'exclude' => [
        'title' => 'Исключения',
        'fields' => [
            'id'         => ['ID', 'id', 90],
            'url'        => ['Ссылка', 'text', 160],
            'reason'     => ['Причина исключения', 'text', 420],
            'created_at' => ['Создан', 'ro', 150],
        ],
    ],
    'blacklist' => [
        'title' => 'Чёрный список',
        'fields' => [
            'id'         => ['ID', 'id', 90],
            'url'        => ['Ссылка', 'text', 160],
            'note'       => ['Заметка', 'text', 260],
            'created_at' => ['Создан', 'ro', 150],
        ],
    ],
];

/* справочники статусов — для выпадающих списков и массовых операций */
$HOW_VALUES = ['Прокомменчен', 'Не подходит деятельность', 'Нельзя комментить', 'Нет текста в постах', 'Был прокомменчен ранее', 'Писать нельзя (приватность)', 'Ответил на входящее ЛС', 'Агрессия / спам — не отвечаем', 'Деятельность не определена', 'Комментарий удалён (переделать)',
    'Дипсик не написал коммент', 'ЧС', 'Добавлен в друзья с базы', 'Добавлен из новых друзей вне базы',
    'Отправлено сообщение', 'Уже было сообщение', 'Есть в exclude', 'Не прошёл по деятельности',
    'Отменена исходящая заявка'];
$FS_VALUES = ['', 'Добавился', 'Отправлено сообщение', 'Запущен сценарий Messages', 'Не прошёл по деятельности', 'Писать нельзя', 'Заявка отправлена',
    'Есть в exclude', 'ЧС', 'Уже было сообщение', 'Отменена исходящая заявка'];

/* русские заголовки из Google-таблицы → поля БД (для импорта) */
$IMPORT_MAP = [
    'id' => 'id', 'ID' => 'id', 'ссыль' => 'url', 'ссылка' => 'url', 'url' => 'url',
    'обработан' => 'processed', 'как обработан' => 'how', 'последнее выполнение' => 'last_run',
    'старт последнего выполнения' => 'last_start', 'последний выполненный workflow' => 'last_workflow',
    'деятельность' => 'activity', 'коммент' => 'comment', 'пост с комментом' => 'comment_post',
    'написал в лс' => 'wrote_dm', 'ответ' => 'reply', 'продажа' => 'sale',
    'friend_status' => 'friend_status', 'история сообщений' => 'msg_history',
    'имя' => 'fio', 'фио' => 'fio', 'город' => 'city', 'комментарий' => 'note', 'заметка' => 'note',
    'база' => 'base', 'название базы' => 'base', 'источник базы' => 'base',
    'причина исключения' => 'reason', 'вк' => 'url',
];

/**
 * Достаёт ID из произвольного текста: по одной записи в строке.
 * Понимает «123456», «vk.com/id123456», «https://vk.com/id123», «123456; Иван; Москва».
 */
function extract_ids(string $raw): array {
    $ids = []; $bad = 0;
    foreach (preg_split('/\r\n|\r|\n/', $raw) as $line) {
        $line = trim($line);
        if ($line === '') continue;
        if (preg_match('/(?:^|[^\w])(?:id)?(\d{3,12})(?![\d])/i', $line, $m)) {
            $ids[] = (int)$m[1];
        } else {
            $bad++;
        }
    }
    return ['ids' => $ids, 'bad' => $bad];
}

/** Вставка списка ID: дубли не добавляются, существующие строки не перетираются. */
function insert_ids(PDO $pdo, string $table, array $schema, array $ids, string $base = '', array $extra = []): array {
    $now = date('Y-m-d H:i:s');
    $added = 0; $exists = 0;
    foreach ($ids as $id) {
        $id = (int)$id;
        if ($id <= 0) continue;
        $row = ['id' => $id];
        foreach (($extra[$id] ?? []) as $k => $v) if ($v !== '' && isset($schema[$k])) $row[$k] = $v;
        if (isset($schema['url'])) $row['url'] = "vk.com/id$id";
        if (isset($schema['source'])) $row['source'] = 'manual';
        if (isset($schema['base']) && $base !== '') $row['base'] = $base;
        if (isset($schema['processed'])) $row['processed'] = 0;
        if ($table === 'exclude' && isset($schema['reason'])) $row['reason'] = $base !== '' ? $base : 'импорт списка';
        if ($table === 'blacklist' && isset($schema['note'])) $row['note'] = $base;
        $row = array_intersect_key($row, $schema);
        $cols = array_keys($row);
        if (!$cols) continue;
        $ph = implode(',', array_fill(0, count($cols), '?'));
        $tail = isset($schema['updated_at']) ? ', created_at, updated_at' : ', created_at';
        $tv = isset($schema['updated_at']) ? [$now, $now] : [$now];
        $st = $pdo->prepare("INSERT INTO $table (" . implode(',', $cols) . "$tail) VALUES ($ph"
            . str_repeat(',?', count($tv)) . ") ON CONFLICT(id) DO NOTHING");
        $st->execute(array_merge(array_values($row), $tv));
        if ($st->rowCount() > 0) $added++; else $exists++;
    }
    return ['added' => $added, 'exists' => $exists];
}

/**
 * Разбирает параметры списка (поиск, фильтры, сортировка, страница) в готовое условие SQL.
 * Используется и при отрисовке, и в AJAX живого обновления.
 */
function list_query(array $schema, array $q, int $per = 100): array {
    $g = fn(string $k, string $d = '') => trim((string)($q[$k] ?? $d));
    $search = $g('s');
    $fp = $g('fp'); $ffs = $g('ffs'); $fhow = $g('fhow'); $fdm = $g('fdm'); $fb = $g('fb');
    $sort = $g('sort', 'id');
    if (!isset($schema[$sort])) $sort = 'id';
    $dir = strtolower($g('dir', 'desc')) === 'asc' ? 'ASC' : 'DESC';
    $page = max(1, (int)$g('p', '1'));

    $where = []; $params = [];
    if ($search !== '') {
        $or = ['CAST(id AS TEXT) LIKE ?', "COALESCE(fio,'') LIKE ?", "COALESCE(url,'') LIKE ?", "COALESCE(note,'') LIKE ?"];
        if (isset($schema['activity'])) $or[] = "COALESCE(activity,'') LIKE ?";
        if (isset($schema['comment']))  $or[] = "COALESCE(comment,'') LIKE ?";
        if (isset($schema['msg_history'])) $or[] = "COALESCE(msg_history,'') LIKE ?";
        $where[] = '(' . implode(' OR ', $or) . ')';
        foreach ($or as $_) $params[] = "%$search%";
    }
    if ($fp !== '' && isset($schema['processed'])) { $where[] = 'processed = ?'; $params[] = (int)$fp; }
    if ($ffs !== '' && isset($schema['friend_status'])) {
        if ($ffs === '__empty') $where[] = "COALESCE(friend_status,'') = ''";
        else { $where[] = 'friend_status = ?'; $params[] = $ffs; }
    }
    if ($fhow !== '' && isset($schema['how'])) { $where[] = 'how = ?'; $params[] = $fhow; }
    if ($fdm !== '' && isset($schema['wrote_dm'])) { $where[] = 'wrote_dm = ?'; $params[] = (int)$fdm; }
    if ($fb !== '' && isset($schema['base'])) {
        if ($fb === '__empty') $where[] = "COALESCE(base,'') = ''";
        else { $where[] = 'base = ?'; $params[] = $fb; }
    }
    return ['search' => $search, 'fp' => $fp, 'ffs' => $ffs, 'fhow' => $fhow, 'fdm' => $fdm, 'fb' => $fb,
            'sort' => $sort, 'dir' => $dir, 'page' => $page, 'per' => $per, 'offset' => ($page - 1) * $per,
            'where' => $where ? ('WHERE ' . implode(' AND ', $where)) : '', 'params' => $params];
}

function table_of(string $t): string {
    global $TABLES;
    return isset($TABLES[$t]) ? $t : 'contacts';
}
function json_out($data): void {
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode($data, JSON_UNESCAPED_UNICODE);
    exit;
}
function post(string $k, string $d = ''): string {
    return isset($_POST[$k]) ? trim((string)$_POST[$k]) : $d;
}
function q(string $k, string $d = ''): string {
    return isset($_GET[$k]) ? trim((string)$_GET[$k]) : $d;
}

/* ── вход/выход ── */
$ERR = '';
if (isset($_GET['logout'])) {
    $_SESSION = [];
    session_destroy();
    header('Location: ?');
    exit;
}
if (isset($_POST['do_login'])) {
    $login = strtolower(trim((string)($_POST['login'] ?? '')));
    $pass = (string)$_POST['password'];
    if ($login === strtolower($SECRETS['login']) && password_verify($pass, $SECRETS['password_hash'])) {
        $_SESSION['vk_ok'] = true;
        $_SESSION['vk_login'] = $login;
        header('Location: ?');
        exit;
    }
    $ERR = 'Неверный логин или пароль';
}
$AUTHED = !empty($_SESSION['vk_ok']);

if (!$AUTHED) {
    ?><!doctype html><html lang="ru"><head><meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>Вход · VK-лидген</title><meta name="robots" content="noindex,nofollow">
    <link rel="stylesheet" href="assets/style.css"></head>
    <body class="login-body"><div class="login-card">
    <h1>VK-лидген</h1><div class="login-sub">Губтор Балконы · база контактов</div>
    <?php if ($ERR): ?><div class="alert"><?= htmlspecialchars($ERR) ?></div><?php endif; ?>
    <form method="post">
      <div class="form-group"><label>Логин</label><input name="login" autofocus></div>
      <div class="form-group"><label>Пароль</label><input type="password" name="password"></div>
      <button class="btn btn-primary btn-block" name="do_login" value="1">Войти</button>
    </form></div></body></html><?php
    exit;
}

/* ══════════ AJAX: правка/добавление/удаление/массовые операции/импорт ══════════ */
$action = q('a');
$SETTINGS_ACTIONS = ['settings_save', 'prompts_reset', 'schedule_save', 'run_now', 'stop_run', 'force_stop',
                     'runs_status', 'unblock_vk'];
if ($action !== '' && in_array($action, array_merge(['save', 'add', 'add_bulk', 'delete', 'bulk', 'import', 'cells', 'set_base_empty', 'live', 'run_progress',
                  'retry_llm', 'retry_vk_errors'], $SETTINGS_ACTIONS), true)) {
    $pdo = db();
    $table = table_of(post('table', 'contacts'));
    global $TABLES, $IMPORT_MAP;
    $schema = $TABLES[$table]['fields'];

    if (in_array($action, $SETTINGS_ACTIONS, true)) {
        require __DIR__ . '/settings_actions.php';   // обработчик отвечает сам и завершает запрос
    }

    if ($action === 'run_progress') {  // состояние текущего прогона для индикатора на странице таблицы
        $LBL   = ['friends_check' => 'Friends_check', 'comments' => 'Comments',
                  'messages' => 'Messages', 'cleanup' => 'Friend_requests_delete'];
        $NAMES = ['friends_check' => 'Проверка друзей', 'comments' => 'Комментарии',
                  'messages' => 'Сообщения', 'cleanup' => 'Чистка заявок'];
        $r = $pdo->query("SELECT * FROM module_runs WHERE status IN ('running','stopping') ORDER BY id DESC LIMIT 1")->fetch();
        if (!$r) json_out(['ok' => true, 'running' => false]);
        $mod = (string)$r['module'];
        $started = (string)$r['started_at'];
        $st = $pdo->prepare("SELECT value FROM state WHERE key = ?");
        $st->execute(['current_' . ($LBL[$mod] ?? $mod)]);
        $cur = (string)($st->fetchColumn() ?: '');
        if ($cur === '') {   // запасной вариант: последний контакт, к которому обращались
            $q = $pdo->prepare("SELECT id FROM contacts WHERE last_start >= ? ORDER BY last_start DESC LIMIT 1");
            $q->execute([$started]);
            $cur = (string)($q->fetchColumn() ?: '');
        }
        if ($mod === 'cleanup' || $mod === 'friends_check') {
            $c = $pdo->prepare("SELECT COUNT(DISTINCT contact_id) FROM actions WHERE module = ? AND ts >= ?");
            $c->execute([$LBL[$mod] ?? $mod, $started]);
            $queue = 0;
        } else {
            $c = $pdo->prepare("SELECT COUNT(*) FROM contacts WHERE last_start >= ?");
            $c->execute([$started]);
            $queue = (int)$pdo->query("SELECT COUNT(*) FROM contacts WHERE processed = 0")->fetchColumn();
        }
        $done = (int)$c->fetchColumn();
        $limit = (int)$r['limit_n'];
        $planned = $limit > 0 ? $limit : $done + ($mod === 'comments' ? $queue : 0);
        $el = 0;
        $dt = DateTime::createFromFormat('Y-m-d H:i:s', $started, new DateTimeZone('Europe/Moscow'));
        if ($dt) $el = max(0, time() - $dt->getTimestamp());
        // пауза, если она сейчас выдерживается
        $pause = null;
        $pv = function (string $k) use ($pdo): string {
            $s = $pdo->prepare("SELECT value FROM state WHERE key = ?");
            $s->execute([$k]);
            return (string)($s->fetchColumn() ?: '');
        };
        $until = (int)$pv('pause_until');
        if ($until > time()) {
            $pause = ['kind' => $pv('pause_kind'), 'left' => $until - time(), 'total' => (int)$pv('pause_total')];
        }
        json_out(['ok' => true, 'running' => true, 'module' => $mod, 'title' => $NAMES[$mod] ?? $mod,
                  'status' => $r['status'], 'done' => $done, 'planned' => $planned, 'queue' => $queue,
                  'current' => $cur, 'started_at' => $started, 'elapsed' => $el, 'pause' => $pause]);
    }

    if ($action === 'live') {  // живое обновление таблицы без перезагрузки страницы
        parse_str(post('qs'), $qq);
        $ltab = $qq['t'] ?? 'contacts';
        $ltab = isset($TABLES[$ltab]) ? $ltab : 'contacts';
        $lschema = $TABLES[$ltab]['fields'];
        $L = list_query($lschema, $qq, 100);
        $cnt = $pdo->prepare("SELECT COUNT(*) FROM $ltab " . $L['where']);
        $cnt->execute($L['params']);
        $ltotal = (int)$cnt->fetchColumn();
        $st = $pdo->prepare("SELECT * FROM $ltab " . $L['where'] . " ORDER BY " . $L['sort'] . " " . $L['dir']
            . " LIMIT " . $L['per'] . " OFFSET " . $L['offset']);
        $st->execute($L['params']);
        $out = ['ok' => true, 'total' => $ltotal, 'ids' => [], 'rows' => [], 'ts' => date('H:i:s')];
        foreach ($st->fetchAll() as $r) {
            $rid = (int)$r['id'];
            $out['ids'][] = $rid;
            $vals = [];
            foreach ($lschema as $f => $info) {
                if ($info[1] === 'ro') continue;
                $vals[$f] = in_array($f, DATE_FIELDS, true) ? ru_dt((string)($r[$f] ?? '')) : (string)($r[$f] ?? '');
            }
            $out['rows'][$rid] = $vals;
        }
        json_out($out);
    }

    if ($action === 'cells') {  // обновление одной ячейки
        $id = (int)post('id');
        $field = post('field');
        if (!isset($schema[$field]) || in_array($schema[$field][1], ['id', 'ro'], true)) {
            json_out(['ok' => false, 'error' => 'поле недоступно']);
        }
        $val = post('value');
        if (in_array($field, DATE_FIELDS, true)) $val = ru_to_iso($val);
        if ($schema[$field][1] === 'bool') $val = in_array(mb_strtolower($val), ['1', 'да', 'yes', 'true'], true) ? 1 : 0;
        $sets = "$field = ?";
        $params = [$val];
        if (isset($schema['updated_at'])) { $sets .= ', updated_at = ?'; $params[] = date('Y-m-d H:i:s'); }
        $params[] = $id;
        $pdo->prepare("UPDATE $table SET $sets WHERE id = ?")->execute($params);
        json_out(['ok' => true, 'value' => $val]);
    }

    if ($action === 'save') {  // сохранить всю строку
        $id = (int)post('id');
        $vals = json_decode(post('data', '{}'), true) ?: [];
        $sets = []; $params = [];
        foreach ($vals as $f => $v) {
            if (!isset($schema[$f]) || in_array($schema[$f][1], ['id', 'ro'], true)) continue;
            if ($schema[$f][1] === 'bool') $v = in_array(mb_strtolower(trim((string)$v)), ['1', 'да', 'yes', 'true'], true) ? 1 : 0;
            if (in_array($f, DATE_FIELDS, true)) $v = ru_to_iso((string)$v);
            $sets[] = "$f = ?"; $params[] = (string)$v;
        }
        if (!$sets) json_out(['ok' => false, 'error' => 'нет данных']);
        if (isset($schema['updated_at'])) { $sets[] = 'updated_at = ?'; $params[] = date('Y-m-d H:i:s'); }
        $params[] = $id;
        $pdo->prepare("UPDATE $table SET " . implode(', ', $sets) . " WHERE id = ?")->execute($params);
        json_out(['ok' => true]);
    }

    if ($action === 'add' || $action === 'add_bulk') {  // добавить строки (ID списком)
        $base = post('base');
        $ids = []; $extra = []; $bad = 0;
        foreach (preg_split('/\r\n|\r|\n/', post('ids')) as $line) {
            if (trim($line) === '') continue;
            $cells = array_map('trim', explode(';', $line));
            $one = extract_ids($cells[0] ?? '');
            if (!$one['ids']) { $bad++; continue; }
            $cid = $one['ids'][0];
            $ids[] = $cid;
            $meta = [];
            if (!empty($cells[1])) $meta['fio'] = $cells[1];
            if (!empty($cells[2])) $meta['city'] = $cells[2];
            if ($meta) $extra[$cid] = $meta;
        }
        $parsed = ['ids' => $ids, 'bad' => $bad];
        $unique = array_values(array_unique($parsed['ids']));
        $res = insert_ids($pdo, $table, $schema, $unique, $base, $extra);
        json_out(['ok' => true, 'added' => $res['added'], 'exists' => $res['exists'],
                  'dups' => count($parsed['ids']) - count($unique), 'bad' => $parsed['bad']]);
    }

    if ($action === 'retry_llm') {  // вернуть в работу тех, кому ИИ не написал комментарий
        $st = $pdo->prepare("UPDATE contacts SET processed=0, how='', last_run='' WHERE how = ?");
        $st->execute(['Дипсик не написал коммент']);
        json_out(['ok' => true, 'affected' => $st->rowCount()]);
    }

    if ($action === 'retry_vk_errors') {  // вернуть в работу тех, где были ошибки ВК
        $ids = $pdo->query("SELECT DISTINCT contact_id FROM actions WHERE contact_id IS NOT NULL "
                           . "AND result <> 'ok'")->fetchAll(PDO::FETCH_COLUMN);
        $ids = array_map('intval', $ids);
        $n = 0;
        if ($ids) {
            $in = implode(',', $ids);
            $n = $pdo->exec("UPDATE contacts SET processed=0, how='', last_run='' WHERE id IN ($in)");
        }
        json_out(['ok' => true, 'affected' => (int)$n]);
    }

    if ($action === 'set_base_empty') {  // проставить базу всем строкам, где база ещё не указана
        $val = post('value');
        if ($val === '') json_out(['ok' => false, 'error' => 'введите название базы']);
        if (!isset($schema['base'])) json_out(['ok' => false, 'error' => 'у этой таблицы нет поля «База»']);
        $now = date('Y-m-d H:i:s');
        $sql = "UPDATE $table SET base=?" . (isset($schema['updated_at']) ? ", updated_at='$now'" : '') . " WHERE COALESCE(base,'') = ''";
        $st = $pdo->prepare($sql);
        $st->execute([$val]);
        json_out(['ok' => true, 'affected' => $st->rowCount(), 'base' => $val]);
    }

    if ($action === 'delete') {
        $ids = array_filter(array_map('intval', (array)($_POST['ids'] ?? [])));
        if ($ids && isset($_POST['ids'])) {
            $in = implode(',', array_fill(0, count($ids), '?'));
            $pdo->prepare("DELETE FROM $table WHERE id IN ($in)")->execute($ids);
            json_out(['ok' => true, 'deleted' => count($ids)]);
        }
        json_out(['ok' => false, 'error' => 'не выбрано ни одной строки']);
    }

    if ($action === 'bulk') {  // массовые операции над выбранными
        $op = post('op');
        $ids = array_filter(array_map('intval', (array)($_POST['ids'] ?? [])));
        if (!$ids) json_out(['ok' => false, 'error' => 'не выбрано ни одной строки']);
        $in = implode(',', array_fill(0, count($ids), '?'));
        $now = date('Y-m-d H:i:s');
        switch ($op) {
            case 'processed_yes': $pdo->prepare("UPDATE $table SET processed=1" . (isset($schema['updated_at']) ? ", updated_at='$now'" : '') . " WHERE id IN ($in)")->execute($ids); break;
            case 'processed_no':  $pdo->prepare("UPDATE $table SET processed=0" . (isset($schema['updated_at']) ? ", updated_at='$now'" : '') . " WHERE id IN ($in)")->execute($ids); break;
            case 'friend_status':
                $val = post('value');
                $pdo->prepare("UPDATE $table SET friend_status=?" . (isset($schema['updated_at']) ? ", updated_at='$now'" : '') . " WHERE id IN ($in)")->execute(array_merge([$val], $ids));
                break;
            case 'how':
                $val = post('value');
                $pdo->prepare("UPDATE $table SET how=?" . (isset($schema['updated_at']) ? ", updated_at='$now'" : '') . " WHERE id IN ($in)")->execute(array_merge([$val], $ids));
                break;
            case 'set_base':
                $val = post('value');
                $pdo->prepare("UPDATE $table SET base=?" . (isset($schema['updated_at']) ? ", updated_at='$now'" : '') . " WHERE id IN ($in)")->execute(array_merge([$val], $ids));
                break;
            case 'requeue':  // вернуть в работу: сбросить статусы, чтобы модули взяли снова
                $pdo->prepare("UPDATE $table SET processed=0, friend_status='', how='', wrote_dm=0, last_run=''" . (isset($schema['updated_at']) ? ", updated_at='$now'" : '') . " WHERE id IN ($in)")->execute($ids);
                break;
            case 'to_blacklist':
                $st = $pdo->prepare('INSERT INTO blacklist (id, url, note, created_at) VALUES (?,?,?,?) ON CONFLICT(id) DO NOTHING');
                foreach ($ids as $id) $st->execute([$id, "vk.com/id$id", 'из панели', $now]);
                if ($table === 'contacts') $pdo->prepare("UPDATE contacts SET how='ЧС', processed=1, updated_at='$now' WHERE id IN ($in)")->execute($ids);
                if ($table === 'blacklist') $pdo->prepare("DELETE FROM contacts WHERE id IN ($in)")->execute($ids);
                break;
            case 'to_exclude':
                $st = $pdo->prepare('INSERT INTO exclude (id, url, reason, created_at) VALUES (?,?,?,?) ON CONFLICT(id) DO UPDATE SET reason=excluded.reason');
                foreach ($ids as $id) $st->execute([$id, "vk.com/id$id", post('value', 'внесено вручную из панели'), $now]);
                break;
            case 'move_other':
                $st = $pdo->prepare('INSERT INTO other_contacts (id, url, processed, source, created_at, updated_at) VALUES (?,?,1,?,?,?) ON CONFLICT(id) DO UPDATE SET updated_at=excluded.updated_at');
                foreach ($ids as $id) $st->execute([$id, "vk.com/id$id", 'перенос из панели', $now, $now]);
                $pdo->prepare("DELETE FROM contacts WHERE id IN ($in)")->execute($ids);
                break;
            default: json_out(['ok' => false, 'error' => 'неизвестная операция']);
        }
        json_out(['ok' => true, 'affected' => count($ids)]);
    }

    if ($action === 'import') {  // импорт TXT (список ID) / CSV / XLSX
        $table = table_of(post('table', 'contacts'));
        $base = post('base');
        $name = (string)($_FILES['file']['name'] ?? '');
        $isTxt = (bool)preg_match('/\.txt$/i', $name);
        $rows = [];
        if (!empty($_FILES['file']['tmp_name'])) {
            $rows = read_upload($table, $_FILES['file']['tmp_name'], $name);
        } elseif (post('paste') !== '') {
            $isTxt = true;
            $rows = array_map(fn($l) => [trim($l)], preg_split('/\r\n|\r|\n/', post('paste')));
        }
        if (!$rows) json_out(['ok' => false, 'error' => 'не удалось прочитать данные']);

        $schema = $TABLES[$table]['fields'];
        global $IMPORT_MAP;
        $header = array_map(fn($h) => mb_strtolower(trim((string)$h)), $rows[0]);
        $idx = [];
        foreach ($header as $i => $h) {
            if (isset($IMPORT_MAP[$h]) && isset($schema[$IMPORT_MAP[$h]])) $idx[$IMPORT_MAP[$h]] = $i;
        }

        // ── режим «список ID» (txt-файл, вставка или файл без заголовков) ──
        if ($isTxt || !isset($idx['id'])) {
            $ids = []; $bad = 0; $extra = [];
            foreach ($rows as $r) {
                $cells = [];
                foreach ((array)$r as $c) foreach (explode(';', (string)$c) as $p) $cells[] = trim($p);
                $line = implode(';', $cells);
                if ($line === '') continue;
                $one = extract_ids($line);
                if (!$one['ids']) { $bad += $one['bad'] ?: 1; continue; }
                $cid = $one['ids'][0];
                $ids[] = $cid;
                $meta = [];
                if (!empty($cells[1]) && mb_strlen($cells[1]) > 1 && !preg_match('/^\d+$/', $cells[1])) $meta['fio'] = $cells[1];
                if (!empty($cells[2])) $meta['city'] = $cells[2];
                if ($meta) $extra[$cid] = $meta;
            }
            $parsed = ['ids' => $ids, 'bad' => $bad];
            $unique = array_values(array_unique($parsed['ids']));
            $res = insert_ids($pdo, $table, $schema, $unique, $base, $extra);
            json_out(['ok' => true, 'mode' => 'idlist', 'imported' => $res['added'],
                      'exists' => $res['exists'], 'dups' => count($parsed['ids']) - count($unique),
                      'bad' => $parsed['bad'], 'total_lines' => count($rows)]);
        }

        // ── режим таблицы (CSV/XLSX со заголовками) ──
        $ok = 0; $upd = 0; $dups = 0; $bad = 0; $seen = [];
        $now = date('Y-m-d H:i:s');
        foreach ($rows as $n => $r) {
            if ($n === 0 || !$r) continue;
            $rawId = (int)preg_replace('/\D/', '', (string)($r[$idx['id']] ?? ''));
            if ($rawId <= 0) { $bad++; continue; }
            if (isset($seen[$rawId])) { $dups++; continue; }   // дубль внутри файла
            $seen[$rawId] = true;
            $vals = ['id' => $rawId];
            foreach ($idx as $f => $i) {
                if ($f === 'id' || !isset($r[$i])) continue;
                $v = trim((string)$r[$i]);
                if ($v === '') continue;                        // пустые ячейки не затирают данные
                if ($schema[$f][1] === 'bool') $v = in_array(mb_strtolower($v), ['да', 'yes', '1', 'true', '+'], true) ? 1 : 0;
                $vals[$f] = $v;
            }
            if (!isset($vals['url']) && isset($schema['url'])) $vals['url'] = "vk.com/id$rawId";
            if (isset($schema['source'])) $vals['source'] = 'import';
            if (isset($schema['base']) && $base !== '' && empty($vals['base'])) $vals['base'] = $base;
            $exists = (bool)$pdo->query("SELECT 1 FROM $table WHERE id = $rawId")->fetchColumn();
            $keys = array_keys($vals);
            $ph = implode(',', array_fill(0, count($keys), '?'));
            $tail = isset($schema['updated_at']) ? ', created_at, updated_at' : ', created_at';
            $tv = isset($schema['updated_at']) ? [$now, $now] : [$now];
            $updSet = implode(', ', array_map(fn($k) => "$k=excluded.$k", array_filter($keys, fn($k) => $k !== 'id')));
            $sql = "INSERT INTO $table (" . implode(',', $keys) . "$tail) VALUES ($ph" . str_repeat(',?', count($tv)) . ")"
                 . ($updSet ? " ON CONFLICT(id) DO UPDATE SET $updSet" : ' ON CONFLICT(id) DO NOTHING');
            $pdo->prepare($sql)->execute(array_merge(array_values($vals), $tv));
            $exists ? $upd++ : $ok++;
        }
        json_out(['ok' => true, 'mode' => 'table', 'imported' => $ok, 'updated' => $upd,
                  'dups' => $dups, 'bad' => $bad]);
    }
}

/* ── чтение CSV/XLSX из загрузки ── */
function read_upload(string $table, string $tmp, string $name): array {
    if (preg_match('/\.xlsx?$/i', $name)) return read_xlsx($tmp);
    if (preg_match('/\.txt$/i', $name)) {   // список ID: по одному в строке
        $txt = (string)file_get_contents($tmp);
        $txt = preg_replace('/^\xEF\xBB\xBF/', '', $txt);
        return array_map(fn($l) => [trim($l)], preg_split('/\r\n|\r|\n/', $txt));
    }
    $txt = file_get_contents($tmp) ?: '';
    $txt = preg_replace('/^\xEF\xBB\xBF/', '', $txt);
    $lines = preg_split('/\r\n|\r|\n/', $txt);
    $delim = (substr_count($lines[0] ?? '', ';') >= substr_count($lines[0] ?? '', ',')) ? ';' : ',';
    return array_map(fn($l) => str_getcsv($l, $delim), $lines);
}

function read_xlsx(string $path): array {
    $zip = new ZipArchive();
    if ($zip->open($path) !== true) return [];
    $shared = [];
    if (($s = $zip->getFromName('xl/sharedStrings.xml')) !== false) {
        if (preg_match_all('/<si>(.*?)<\/si>/s', $s, $mm)) {
            foreach ($mm[1] as $si) {
                preg_match_all('/<t[^>]*>(.*?)<\/t>/s', $si, $tt);
                $shared[] = html_entity_decode(implode('', $tt[1]), ENT_QUOTES | ENT_XML1, 'UTF-8');
            }
        }
    }
    $sheet = $zip->getFromName('xl/worksheets/sheet1.xml');
    $zip->close();
    if ($sheet === false) return [];
    $rows = [];
    if (preg_match_all('/<row[^>]*>(.*?)<\/row>/s', $sheet, $rm)) {
        foreach ($rm[1] as $rx) {
            $cells = [];
            if (preg_match_all('/<c r="([A-Z]+)\d+"([^>]*)>(.*?)<\/c>/s', $rx, $cm, PREG_SET_ORDER)) {
                foreach ($cm as $c) {
                    $col = $c[1]; $attrs = $c[2]; $body = $c[3];
                    if (preg_match('/<v>(.*?)<\/v>/s', $body, $v)) {
                        $val = $v[1];
                        if (strpos($attrs, 't="s"') !== false) $val = $shared[(int)$val] ?? '';
                        $cells[$col] = html_entity_decode($val, ENT_QUOTES | ENT_XML1, 'UTF-8');
                    } elseif (preg_match('/<t[^>]*>(.*?)<\/t>/s', $body, $t)) {
                        $cells[$col] = html_entity_decode($t[1], ENT_QUOTES | ENT_XML1, 'UTF-8');
                    }
                }
            }
            $keys = array_keys($cells);
            usort($keys, fn($a, $b) => (strlen($a) - strlen($b)) ?: strcmp($a, $b));
            $rows[] = $keys ? array_map(fn($k) => $cells[$k], $keys) : [];
        }
    }
    return $rows;
}

/* ══════════ рендер ══════════ */
$pdo = db();
if (q('t') === 'settings') {
    require __DIR__ . '/settings_page.php';
    exit;
}
$tab = q('t', 'contacts');
$tab = isset($TABLES[$tab]) ? $tab : 'contacts';
$schema = $TABLES[$tab]['fields'];

$Q = list_query($schema, $_GET, 100);
$search = $Q['search'];
$fProcessed = $Q['fp'];
$fFs = $Q['ffs'];
$fHow = $Q['fhow'];
$fWrote = $Q['fdm'];
$fBase = $Q['fb'];
$sort = $Q['sort'];
$dir = $Q['dir'];
$page = $Q['page'];
$per = $Q['per'];
$offset = $Q['offset'];
$w = $Q['where'];
$params = $Q['params'];

$st = $pdo->prepare("SELECT COUNT(*) FROM $tab $w");
$st->execute($params);
$total = (int)$st->fetchColumn();

$baseList = $pdo->query("SELECT base, COUNT(*) c FROM contacts WHERE COALESCE(base,'') <> '' GROUP BY base ORDER BY c DESC")->fetchAll();
// сколько контактов можно вернуть в работу повторно
$retryLlm = (int)$pdo->query("SELECT COUNT(*) FROM contacts WHERE how = 'Дипсик не написал коммент'")->fetchColumn();
$retryVk = (int)$pdo->query("SELECT COUNT(*) FROM contacts c WHERE c.processed = 1 AND EXISTS "
    . "(SELECT 1 FROM actions a WHERE a.contact_id = c.id AND a.result <> 'ok')")->fetchColumn();
$waitQueue = (int)$pdo->query("SELECT COUNT(*) FROM contacts WHERE processed = 0 AND COALESCE(last_start,'') <> ''")->fetchColumn();
$noBase = isset($TABLES[$tab]['fields']['base'])
    ? (int)$pdo->query("SELECT COUNT(*) FROM $tab WHERE COALESCE(base,'') = ''")->fetchColumn() : 0;
$st = $pdo->prepare("SELECT * FROM $tab $w ORDER BY $sort $dir LIMIT $per OFFSET $offset");
$st->execute($params);
$rows = $st->fetchAll();

$cnt = fn(string $sql): int => (int)$pdo->query($sql)->fetchColumn();
$stats = [
    'всего' => $cnt("SELECT COUNT(*) FROM contacts"),
    'не обработано' => $cnt("SELECT COUNT(*) FROM contacts WHERE processed=0"),
    'написали в ЛС' => $cnt("SELECT COUNT(*) FROM contacts WHERE wrote_dm=1"),
    'с ответом' => $cnt("SELECT COUNT(*) FROM contacts WHERE COALESCE(reply,'') <> ''"),
    'в ЧС' => $cnt("SELECT COUNT(*) FROM blacklist"),
    'в исключениях' => $cnt("SELECT COUNT(*) FROM exclude"),
];
$lastActs = $pdo->query("SELECT ts, contact_id, module, action, result FROM actions ORDER BY id DESC LIMIT 40")->fetchAll();
?>
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>VK-лидген · <?= htmlspecialchars($TABLES[$tab]['title']) ?></title>
<meta name="robots" content="noindex,nofollow">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="assets/style.css?v=<?= @filemtime(__DIR__ . '/assets/style.css') ?>">
</head>
<body>
<header class="top">
  <div class="brand"><span class="dot"></span> VK-лидген <span class="brand-sub">Губтор Балконы</span></div>
  <nav class="tabs">
    <?php foreach ($TABLES as $t => $info): ?>
      <a class="tab <?= $t === $tab ? 'tab-active' : '' ?>" href="?t=<?= $t ?>"><?= htmlspecialchars($info['title']) ?>
        <span class="cnt"><?= $cnt("SELECT COUNT(*) FROM $t") ?></span></a>
    <?php endforeach; ?>
    <a class="tab" href="?t=settings">Настройки</a>
    <a class="tab" href="?t=<?= $tab ?>&panel=acts">Журнал</a>
  </nav>
  <div class="top-right">
    <a class="btn btn-sm" href="export.php?t=<?= $tab ?>&s=<?= urlencode($search) ?>">Выгрузить</a>
    <a class="btn btn-sm" href="?logout=1">Выйти</a>
  </div>
</header>

<main>
  <div class="stats">
    <?php foreach ($stats as $k => $v): ?>
      <div class="stat"><div class="stat-v"><?= $v ?></div><div class="stat-k"><?= htmlspecialchars($k) ?></div></div>
    <?php endforeach; ?>
  </div>

  <section class="card">
    <form class="filters" method="get">
      <input type="hidden" name="t" value="<?= htmlspecialchars($tab) ?>">
      <input class="in" type="text" name="s" value="<?= htmlspecialchars($search) ?>" placeholder="Поиск: ID, имя, ссылка, деятельность, коммент">
      <?php if (isset($schema['processed'])): ?>
        <select class="in" name="fp">
          <option value="">Обработан — любой</option>
          <option value="0" <?= $fProcessed === '0' ? 'selected' : '' ?>>не обработан</option>
          <option value="1" <?= $fProcessed === '1' ? 'selected' : '' ?>>обработан</option>
        </select>
      <?php endif; ?>
      <?php if (isset($schema['friend_status'])): ?>
        <select class="in" name="ffs">
          <option value="">Friend_status — любой</option>
          <option value="__empty" <?= $fFs === '__empty' ? 'selected' : '' ?>>(пусто)</option>
          <?php foreach ($FS_VALUES as $v): if ($v === '') continue; ?>
            <option value="<?= htmlspecialchars($v) ?>" <?= $fFs === $v ? 'selected' : '' ?>><?= htmlspecialchars($v) ?></option>
          <?php endforeach; ?>
        </select>
      <?php endif; ?>
      <?php if (isset($schema['how'])): ?>
        <select class="in" name="fhow">
          <option value="">Как обработан — любой</option>
          <?php foreach ($HOW_VALUES as $v): ?>
            <option value="<?= htmlspecialchars($v) ?>" <?= $fHow === $v ? 'selected' : '' ?>><?= htmlspecialchars($v) ?></option>
          <?php endforeach; ?>
        </select>
      <?php endif; ?>
      <?php if (isset($schema['base']) && $tab === 'contacts'): ?>
        <select class="in" name="fb">
          <option value="">База — любая</option>
          <option value="__empty" <?= $fBase === '__empty' ? 'selected' : '' ?>>(без базы)</option>
          <?php foreach ($baseList as $b): ?>
            <option value="<?= htmlspecialchars((string)$b['base']) ?>" <?= $fBase === (string)$b['base'] ? 'selected' : '' ?>><?= htmlspecialchars((string)$b['base']) ?> (<?= (int)$b['c'] ?>)</option>
          <?php endforeach; ?>
        </select>
      <?php endif; ?>
      <?php if (isset($schema['wrote_dm'])): ?>
        <select class="in" name="fdm">
          <option value="">ЛС — любой</option>
          <option value="1" <?= $fWrote === '1' ? 'selected' : '' ?>>писали</option>
          <option value="0" <?= $fWrote === '0' ? 'selected' : '' ?>>не писали</option>
        </select>
      <?php endif; ?>
      <button class="btn btn-primary" type="submit">Показать</button>
      <a class="btn" href="?t=<?= htmlspecialchars($tab) ?>">Сбросить</a>
      <span class="muted" id="found-count">найдено: <?= $total ?></span>
    </form>

    <div class="runbar" id="runbar">
      <span class="rdot"></span>
      <strong id="runbar-title">—</strong>
      <span class="muted" id="runbar-progress">—</span>
      <div class="pbar"><i id="runbar-fill"></i></div>
      <span class="muted" id="runbar-current"></span>
      <span class="pause-chip" id="runbar-pause" style="display:none"></span>
      <span class="muted" id="runbar-time"></span>
      <span class="runbar-btns">
        <button class="btn btn-sm" onclick="stopRunBar(false)"
                title="Мягкая остановка: модуль закончит текущее действие и запишет статус">Остановить</button>
        <button class="btn btn-sm btn-danger" onclick="stopRunBar(true)"
                title="Принудительно снять процесс сразу">Стоп!</button>
      </span>
    </div>

    <div class="livebar">
      <label class="sw"><input type="checkbox" id="live-on" checked> <span>живое обновление</span></label>
      <span class="muted" id="live-ts">обновлено: —</span>
      <span class="muted live-warn" id="live-note"></span>
    </div>

    <div class="addbox">
      <div class="addbox-head">Добавить строки <span class="muted">— ID по одному в строке (можно txt-список, ссылки vk.com/id…, «ID; Имя; Город»); дубли отбрасываются</span></div>
      <textarea id="add-ids" rows="3" placeholder="123456&#10;https://vk.com/id654321&#10;777888; Иван; Москва"></textarea>
      <div class="addbox-row">
        <label class="lbl-inline">Название базы
          <input class="in in-sm" id="base-name" list="base-list" placeholder="например: Балконы Тольятти">
        </label>
        <datalist id="base-list">
          <?php foreach ($baseList as $b): ?><option value="<?= htmlspecialchars((string)$b['base']) ?>"></option><?php endforeach; ?>
        </datalist>
        <button class="btn btn-primary" onclick="addRows()">Добавить в «<?= htmlspecialchars($TABLES[$tab]['title']) ?>»</button>
        <label class="btn file-btn">Загрузить файл (txt / csv / xlsx)<input type="file" id="imp-file" accept=".csv,.xlsx,.txt" onchange="importFile(this)"></label>
        <span class="muted" id="add-msg"></span>
      </div>
      <div class="hint">TXT-файл: по одному ID в строке — просто числа или ссылки vk.com/id…, дубли в файле и в базе автоматически отбрасываются.</div>
      <?php if (isset($schema['base'])): ?>
      <div class="hint" style="margin-top:10px;border-top:1px dashed #c9d6f5;padding-top:10px">
        <strong>Повторный прогон.</strong>
        Ждут повторной обработки (были ошибки в прошлом прогоне, уже в очереди): <strong><?= $waitQueue ?></strong> —
        их модуль возьмёт сам при следующем запуске.
        Кому ИИ не написал комментарий: <strong><?= $retryLlm ?></strong> —
        <button class="btn btn-sm" onclick="retryQueue('retry_llm')">вернуть их в очередь</button>
        Контакты с ошибками ВК, помеченные обработанными: <strong><?= $retryVk ?></strong> —
        <button class="btn btn-sm" onclick="retryQueue('retry_vk_errors')">вернуть их в очередь</button>
      </div>

      <div class="addbox-row" style="margin-top:10px;border-top:1px dashed #c9d6f5;padding-top:10px">
        <span class="muted">Строк без базы: <strong><?= $noBase ?></strong></span>
        <label class="lbl-inline">Проставить им базу
          <input class="in in-sm" id="base-empty-name" list="base-list" placeholder="например: Балконы Тольятти">
        </label>
        <button class="btn" onclick="setBaseEmpty()">Проставить всем строкам без базы</button>
      </div>
      <?php endif; ?>
    </div>

    <div class="bulkbar" id="bulkbar" hidden>
      <strong><span id="sel-count">0</span> выбрано:</strong>
      <?php if (isset($schema['processed'])): ?>
        <button class="btn btn-sm" onclick="bulk('processed_yes')">Обработан = Да</button>
        <button class="btn btn-sm" onclick="bulk('processed_no')">Обработан = Нет</button>
      <?php endif; ?>
      <?php if (isset($schema['friend_status'])): ?>
        <select class="in in-sm" id="bulk-fs"><option value="">Friend_status…</option>
          <?php foreach ($FS_VALUES as $v): ?><option value="<?= htmlspecialchars($v) ?>"><?= htmlspecialchars($v ?: '(пусто)') ?></option><?php endforeach; ?>
        </select>
        <button class="btn btn-sm" onclick="bulk('friend_status')">Применить</button>
      <?php endif; ?>
      <?php if (isset($schema['how'])): ?>
        <select class="in in-sm" id="bulk-how"><option value="">Как обработан…</option>
          <?php foreach ($HOW_VALUES as $v): ?><option value="<?= htmlspecialchars($v) ?>"><?= htmlspecialchars($v) ?></option><?php endforeach; ?>
        </select>
        <button class="btn btn-sm" onclick="bulk('how')">Применить</button>
      <?php endif; ?>
      <?php if ($tab === 'contacts'): ?>
        <button class="btn btn-sm" onclick="bulk('requeue')" title="Сбросить статусы — модули возьмут контакт заново">В очередь</button>
        <button class="btn btn-sm" onclick="bulk('move_other')">В другие</button>
      <?php endif; ?>
      <?php if (isset($schema['base'])): ?>
        <input class="in in-sm" id="bulk-base" list="base-list" placeholder="База для выбранных">
        <button class="btn btn-sm" onclick="bulk('set_base')">Задать базу</button>
      <?php endif; ?>
      <button class="btn btn-sm" onclick="bulk('to_exclude')">В исключения</button>
      <button class="btn btn-sm" onclick="bulk('to_blacklist')">В ЧС</button>
      <button class="btn btn-sm btn-danger" onclick="bulkDelete()">Удалить выбранные</button>
    </div>

    <div class="tbl-wrap">
      <table class="tbl">
        <thead><tr>
          <th class="chk"><input type="checkbox" id="sel-all" onchange="toggleAll(this)"></th>
          <th class="num">№</th>
          <?php foreach ($schema as $f => $info): ?>
            <th style="min-width:<?= (int)$info[2] ?>px"><a href="?t=<?= $tab ?>&s=<?= urlencode($search) ?>&fp=<?= urlencode($fProcessed) ?>&ffs=<?= urlencode($fFs) ?>&fhow=<?= urlencode($fHow) ?>&sort=<?= $f ?>&dir=<?= $sort === $f && $dir === 'ASC' ? 'desc' : 'asc' ?>"><?= htmlspecialchars($info[0]) ?><?= $sort === $f ? ($dir === 'ASC' ? ' ▲' : ' ▼') : '' ?></a></th>
          <?php endforeach; ?>
          <th></th>
        </tr></thead>
        <tbody>
        <?php if (!$rows): ?>
          <tr><td colspan="<?= count($schema) + 3 ?>" class="empty">Ничего не найдено. Добавьте строки или загрузите выгрузку из таблицы.</td></tr>
        <?php endif; ?>
        <?php foreach ($rows as $i => $r): $rid = (int)$r['id']; ?>
          <tr data-id="<?= $rid ?>">
            <td class="chk"><input type="checkbox" class="row-chk" value="<?= $rid ?>" onchange="updSel()"></td>
            <td class="num"><?= $offset + $i + 1 ?></td>
            <?php foreach ($schema as $f => $info):
                $val = $r[$f] ?? '';
                $raw = (string)$val;
                if (in_array($f, DATE_FIELDS, true)) $val = ru_dt($raw);   // даты — в формате дд.мм.гггг чч:мм
                $type = $info[1]; ?>
              <td class="<?= $type === 'id' ? 'mono' : '' ?>">
              <?php if ($type === 'ro' || $type === 'id'): ?>
                <?= htmlspecialchars((string)$val ?? '') ?>
              <?php elseif ($type === 'bool'): ?>
                <select class="cell" data-f="<?= $f ?>" onchange="saveCell(this)">
                  <option value="1" <?= (int)$val === 1 ? 'selected' : '' ?>>Да</option>
                  <option value="0" <?= (int)$val === 0 ? 'selected' : '' ?>>Нет</option>
                </select>
              <?php elseif ($f === 'how'): ?>
                <select class="cell" data-f="<?= $f ?>" onchange="saveCell(this)">
                  <option value="">—</option>
                  <?php foreach ($HOW_VALUES as $v): ?><option value="<?= htmlspecialchars($v) ?>" <?= (string)$val === $v ? 'selected' : '' ?>><?= htmlspecialchars($v) ?></option><?php endforeach; ?>
                  <?php if ($val !== '' && !in_array($val, $HOW_VALUES, true)): ?><option value="<?= htmlspecialchars((string)$val) ?>" selected><?= htmlspecialchars((string)$val) ?></option><?php endif; ?>
                </select>
              <?php elseif ($f === 'friend_status'): ?>
                <select class="cell" data-f="<?= $f ?>" onchange="saveCell(this)">
                  <?php foreach ($FS_VALUES as $v): ?><option value="<?= htmlspecialchars($v) ?>" <?= (string)$val === $v ? 'selected' : '' ?>><?= htmlspecialchars($v ?: '—') ?></option><?php endforeach; ?>
                  <?php if ($val !== '' && !in_array($val, $FS_VALUES, true)): ?><option value="<?= htmlspecialchars((string)$val) ?>" selected><?= htmlspecialchars((string)$val) ?></option><?php endif; ?>
                </select>
              <?php elseif ($f === 'url'): ?>
                <div class="url-cell">
                  <input class="cell" data-f="<?= $f ?>" value="<?= htmlspecialchars((string)$val ?? '') ?>" onchange="saveCell(this)" onblur="saveCell(this)">
                  <?php if (trim((string)$val) !== ''): ?>
                    <a class="url-go" target="_blank" rel="noopener noreferrer" title="Открыть профиль ВК"
                       href="https://<?= htmlspecialchars(preg_replace('#^https?://#i', '', trim((string)$val))) ?>">↗</a>
                  <?php endif; ?>
                </div>
              <?php else: ?>
                <input class="cell" data-f="<?= $f ?>" value="<?= htmlspecialchars((string)$val ?? '') ?>" onchange="saveCell(this)" onblur="saveCell(this)">
              <?php endif; ?>
              </td>
            <?php endforeach; ?>
            <td class="row-actions">
              <button class="btn btn-sm" onclick="rowFull(<?= $rid ?>)" title="Открыть карточку">…</button>
              <button class="btn btn-sm btn-danger" onclick="rowDelete(<?= $rid ?>)">×</button>
            </td>
          </tr>
        <?php endforeach; ?>
        </tbody>
      </table>
    </div>

    <?php $pages = max(1, (int)ceil($total / $per)); if ($pages > 1): ?>
      <div class="pager">
        <?php for ($i = max(1, $page - 3); $i <= min($pages, $page + 3); $i++): ?>
          <a class="pbtn <?= $i === $page ? 'pbtn-active' : '' ?>" href="?t=<?= $tab ?>&p=<?= $i ?>&s=<?= urlencode($search) ?>&fp=<?= urlencode($fProcessed) ?>&ffs=<?= urlencode($fFs) ?>&fhow=<?= urlencode($fHow) ?>&sort=<?= $sort ?>&dir=<?= strtolower($dir) ?>"><?= $i ?></a>
        <?php endfor; ?>
        <span class="muted">стр. <?= $page ?> из <?= $pages ?></span>
      </div>
    <?php endif; ?>
  </section>

  <section class="card" id="acts" <?= q('panel') === 'acts' ? '' : 'hidden' ?>>
    <h2>Журнал последних действий сервиса</h2>
    <table class="tbl">
      <thead><tr><th>Время</th><th>Контакт</th><th>Модуль</th><th>Действие</th><th>Результат</th></tr></thead>
      <tbody>
      <?php foreach ($lastActs as $a): ?>
        <tr><td class="mono"><?= htmlspecialchars(ru_dt((string)($a['ts'] ?? ''), true)) ?></td>
            <td class="mono"><?= (int)($a['contact_id'] ?? 0) ?></td>
            <td><?= htmlspecialchars((string)($a['module'] ?? '')) ?></td>
            <td><?= htmlspecialchars((string)($a['action'] ?? '')) ?></td>
            <td><?= htmlspecialchars((string)($a['result'] ?? '')) ?></td></tr>
      <?php endforeach; ?>
      </tbody>
    </table>
    <div class="hint">Полный журнал: <span class="mono">python3 /opt/vk-leadgen/run.py stats</span></div>
  </section>
</main>

<div class="modal" id="modal" hidden>
  <div class="modal-card">
    <div class="modal-head"><span id="modal-title">Карточка</span><button class="btn btn-sm" onclick="closeModal()">Закрыть</button></div>
    <div class="modal-body"><table class="tbl" id="modal-tbl"></table></div>
  </div>
</div>

<script>
const TABLE = <?= json_encode($tab) ?>;
const NAMES = <?= json_encode(array_map(fn($i) => $i[0], $schema), JSON_UNESCAPED_UNICODE) ?>;
<?php
// те же данные для живого обновления и карточки: даты — в понятном формате дд.мм.гггг
$rowsRu = [];
foreach ($rows as $r) {
    $rr = (array)$r;
    foreach (DATE_FIELDS as $df) {
        if (array_key_exists($df, $rr)) $rr[$df] = ru_dt((string)$rr[$df]);
    }
    $rowsRu[] = $rr;
}
?>
const ROWS = <?= json_encode($rowsRu, JSON_UNESCAPED_UNICODE) ?>;
</script>
<script src="assets/app.js?v=<?= @filemtime(__DIR__ . '/assets/app.js') ?>"></script>
</body>
</html>
