#!/usr/local/bin/perl
#
# export_spz.pl — универсальный экспорт БД SPZ в JSON
#
# Скрипт автоматически анализирует структуру базы данных:
# получает список таблиц через SHOW TABLES, описание колонок
# через DESCRIBE, и экспортирует каждую таблицу в отдельный
# JSON-файл без какой-либо зависимости от конкретной схемы.
#
# Также генерирует нормализованный файл "rules" в формате
# getconfig.pl (для обратной совместимости с qmail-smg).
#
# Использование:
#   export_rules.pl              — инкрементально (пропустить без изменений)
#   export_rules.pl -f           — принудительный полный экспорт
#   export_rules.pl -v           — подробный вывод в stdout
#   export_rules.pl -t TABLE     — только одна таблица
#   export_rules.pl --with-logs  — включить журнальные таблицы
#   export_rules.pl --skip TABLE — пропустить таблицу (повторяемый)
#   export_rules.pl --no-control   — не архивировать /var/qmail/control
#   export_rules.pl --no-validate  — не считать контрольные суммы (быстрее)
#   export_rules.pl --list         — показать таблицы в БД и выйти
#   export_rules.pl --help         — справка
#

use strict;
use warnings;
use DBI;
use JSON::PP;
use POSIX        qw(strftime);
use Encode       qw(decode encode);
use Digest::MD5  qw(md5_hex);
use Scalar::Util qw(looks_like_number);
use Cwd          qw(abs_path getcwd);

# ── Пути ─────────────────────────────────────────────────────────────────────
# По умолчанию все данные хранятся в ./backup/ относительно рабочего каталога.
# Переопределяется через --backup-dir PATH.

my $control      = '/var/qmail/control';
my $base_dir     = './backup';                     # базовый каталог (--backup-dir)
my $out_dir;          # = "$base_dir/spzconfig"    — задаётся после парсинга аргументов
my $backup_dir;       # = "$base_dir/archives"
my $log_dir;          # = "$base_dir/logs"
my $rules_file;
my $index_file;
my $sync_file;
my $log_file;
my $pid_file;
my $control_archive;
my $db_cfg_file      = "$control/spamorez/dbconfig";

# Таблицы-журналы — пропускаются без --with-logs
my %LOG_TABLES = map { $_ => 1 } qw(
    spz_userlogs
    spz_useractions
    spz_actionlogs
    spz_sessions
    spz_tokens
);

# ── Аргументы ─────────────────────────────────────────────────────────────────

my $force        = 0;
my $verbose      = 0;
my $with_logs    = 0;
my $list_only    = 0;
my $no_control   = 0;
my $no_backup    = 0;
my $no_validate  = 0;
my $keep_count   = 14;   # сколько архивов хранить
my $only_table   = '';
my %skip_table   = ();
my @extra_dbs    = ();   # дополнительные БД (--extra-db status ...)
# Прямые реквизиты подключения (переопределяют .my.cnf / dbconfig)
my $cli_host = '';
my $cli_user = '';
my $cli_pass = '';
my $cli_port = 0;
my $cli_db   = '';
# Порог строк для полной статистики по колонкам (выше — только checksum+pk)
my $VALIDATE_FULL_THRESHOLD = 50_000;

my @argv = @ARGV;
while (my $arg = shift @argv) {
    if ($arg eq '-f' || $arg eq '--force')      { $force       = 1;               next; }
    if ($arg eq '-v' || $arg eq '--verbose')    { $verbose     = 1;               next; }
    if ($arg eq '--with-logs')                  { $with_logs   = 1;               next; }
    if ($arg eq '--no-control')                 { $no_control  = 1;               next; }
    if ($arg eq '--no-backup')                  { $no_backup   = 1;               next; }
    if ($arg eq '--no-validate')                { $no_validate = 1;               next; }
    if ($arg eq '--keep')                       { $keep_count  = shift(@argv)+0;  next; }
    if ($arg eq '--list')                       { $list_only   = 1;               next; }
    if ($arg eq '-t' || $arg eq '--table')      { $only_table  = shift @argv;     next; }
    if ($arg eq '--skip')                       { $skip_table{shift @argv} = 1;   next; }
    if ($arg eq '--extra-db')                   { push @extra_dbs, shift @argv;   next; }
    if ($arg eq '--backup-dir')                 { $base_dir = shift @argv;         next; }
    if ($arg eq '--host')                       { $cli_host = shift @argv;         next; }
    if ($arg eq '--user' || $arg eq '-u')       { $cli_user = shift @argv;         next; }
    if ($arg eq '--password' || $arg eq '-p')   { $cli_pass = shift @argv;         next; }
    if ($arg eq '--port')                       { $cli_port = shift(@argv)+0;      next; }
    if ($arg eq '--db' || $arg eq '--database') { $cli_db   = shift @argv;         next; }
    if ($arg eq '-h' || $arg eq '--help')       { usage(); exit 0; }
    print STDERR "Неизвестный параметр: $arg\n"; usage(); exit 1;
}

# ── Инициализация путей на основе $base_dir ───────────────────────────────────
# Переводим в абсолютный путь — нужно для tar -C и других внешних команд
mkdir $base_dir unless -d $base_dir;   # создать заранее, чтобы abs_path сработал
$base_dir = abs_path($base_dir) // do {
    # Если abs_path вернул undef — каталог не создался, строим вручную
    my $cwd = getcwd();
    $base_dir =~ s{^\.}{$cwd};
    $base_dir;
};
$out_dir         = "$base_dir/spzconfig";
$backup_dir      = "$base_dir/archives";
$log_dir         = "$base_dir/logs";
$rules_file      = "$out_dir/rules";
$index_file      = "$out_dir/export_index.json";
$sync_file       = "$out_dir/spz_export.sync";
$log_file        = "$log_dir/export_rules.log";
$pid_file        = "$out_dir/export_rules.pid";
$control_archive = "$out_dir/control.tar.gz";

# ── Инициализация ─────────────────────────────────────────────────────────────

mkdir $log_dir    unless -d $log_dir;
mkdir $out_dir    unless -d $out_dir;
mkdir $backup_dir unless -d $backup_dir;

open(LOG, ">>$log_file") or die "Не удалось открыть лог $log_file: $!\n";

# Защита от параллельного запуска (PID-файл)
if (-f $pid_file) {
    my $old_pid = load_file($pid_file);
    chomp $old_pid;
    if ($old_pid && $old_pid =~ /^\d+$/ && kill(0, $old_pid)) {
        log_die("Уже запущен (PID $old_pid)");
    }
    # PID-файл есть, но процесс мёртв — удаляем
    unlink $pid_file;
}
save_file($pid_file, $$);
END { unlink $pid_file if defined $pid_file && -f $pid_file; }

# ── Подключение к БД ──────────────────────────────────────────────────────────

my ($db_host, $db_user, $db_pass, $db_name, $db_port) = load_dbconfig($db_cfg_file, 'spz');

# Параметры командной строки перекрывают конфиг
$db_host = $cli_host if $cli_host;
$db_user = $cli_user if $cli_user;
$db_pass = $cli_pass if $cli_pass;
$db_port = $cli_port if $cli_port;
$db_name = $cli_db   if $cli_db;

log_msg("БД: host=$db_host db=$db_name user=$db_user port=$db_port");

my $dbh = db_connect($db_host, $db_user, $db_pass, $db_name, $db_port);
my $json = JSON::PP->new->utf8->pretty->canonical;

# ── --list: показать таблицы и выйти ─────────────────────────────────────────

if ($list_only) {
    my @tables = db_tables($dbh, $db_name);
    printf "Таблицы в БД %s (%d):\n", $db_name, scalar @tables;
    for my $t (@tables) {
        my @cols = db_columns($dbh, $t);
        my $log  = $LOG_TABLES{$t} ? ' [журнал]' : '';
        printf "  %-40s %d колонок%s\n", $t, scalar @cols, $log;
    }
    for my $extra_db (@extra_dbs) {
        $dbh->do("USE `$extra_db`") or do {
            printf "БД %s: ошибка подключения: %s\n", $extra_db, $dbh->errstr // '?';
            next;
        };
        my @etables = db_tables($dbh, $extra_db);
        printf "\nТаблицы в БД %s (%d):\n", $extra_db, scalar @etables;
        for my $t (@etables) {
            my @cols = db_columns($dbh, $t);
            printf "  %-40s %d колонок\n", $t, scalar @cols;
        }
        $dbh->do("USE `$db_name`");
    }
    $dbh->disconnect();
    close LOG;
    exit 0;
}

# ── Проверка наличия изменений ────────────────────────────────────────────────

my $last_sync = load_file($sync_file) || '';

unless ($force) {
    my $today = strftime('%Y-%m-%d', localtime);
    if (substr($last_sync, 0, 10) ne $today) {
        log_msg("Суточный принудительный экспорт (последний: " . ($last_sync || 'никогда') . ")");
        $force = 1;
    }
}

# ── Обнаружение и экспорт всех таблиц ────────────────────────────────────────

my @all_tables = db_tables($dbh, $db_name);
log_msg("Таблиц в БД: " . scalar @all_tables);

my (@exported, @skipped, @failed);
my $total_rows = 0;

TABLE: for my $tname (@all_tables) {

    # Фильтры
    next TABLE if $only_table  && $tname ne $only_table;
    next TABLE if $skip_table{$tname};
    next TABLE if $LOG_TABLES{$tname} && !$with_logs;

    # Получаем структуру таблицы
    my @cols = db_columns($dbh, $tname);
    unless (@cols) {
        log_msg("  ПРОПУСК $tname: не удалось получить колонки");
        push @skipped, $tname;
        next TABLE;
    }

    # Инкрементальная проверка через chtime/ctime колонку
    unless ($force) {
        my $chfield = find_chtime_col(\@cols);
        if ($chfield && $last_sync) {
            my $cnt = db_count_changed($dbh, $tname, $chfield, $last_sync);
            if ($cnt == 0) {
                log_msg("  ПРОПУСК $tname: нет изменений с $last_sync ($chfield)");
                push @skipped, $tname;
                next TABLE;
            }
            log_msg("  $tname: изменений $cnt") if $verbose;
        }
    }

    # Экспорт в JSON
    my ($rows, $err) = export_table_json($dbh, $tname, \@cols, $out_dir);
    if (defined $err) {
        log_msg("  ОШИБКА $tname: $err");
        push @failed, $tname;
    } else {
        log_msg(sprintf("  %-40s %5d строк", $tname, $rows));
        $total_rows += $rows;
        push @exported, { table => $tname, rows => $rows, cols => scalar @cols };
    }
}

# ── Дополнительные базы данных (--extra-db) ──────────────────────────────────

my @extra_exported;
for my $extra_db (@extra_dbs) {
    log_msg("Экспорт дополнительной БД: $extra_db");

    unless ($dbh->do("USE `$extra_db`")) {
        log_msg("  ОШИБКА: не удалось переключиться на $extra_db: " . ($dbh->errstr // '?'));
        next;
    }

    my @etables = db_tables($dbh, $extra_db);
    log_msg("  Таблиц в $extra_db: " . scalar @etables);

    my $prefix = "${extra_db}__";  # имя файла: status__domains.json

    ETABLE: for my $tname (@etables) {
        next ETABLE if $only_table && $tname ne $only_table;
        next ETABLE if $skip_table{$tname};

        my @cols = db_columns($dbh, $tname);
        unless (@cols) {
            log_msg("  ПРОПУСК ${extra_db}.${tname}: не удалось получить колонки");
            push @skipped, "${extra_db}.${tname}";
            next ETABLE;
        }

        # Инкрементальная проверка через chtime-колонку
        unless ($force) {
            my $chfield = find_chtime_col(\@cols);
            if ($chfield && $last_sync) {
                my $cnt = db_count_changed($dbh, $tname, $chfield, $last_sync);
                if ($cnt == 0) {
                    log_msg("  ПРОПУСК ${extra_db}.${tname}: нет изменений с $last_sync ($chfield)");
                    push @skipped, "${extra_db}.${tname}";
                    next ETABLE;
                }
            }
        }

        my ($rows, $err) = export_table_json($dbh, $tname, \@cols, $out_dir, $prefix);
        if (defined $err) {
            log_msg("  ОШИБКА ${extra_db}.${tname}: $err");
            push @failed, "${extra_db}.${tname}";
        } else {
            log_msg(sprintf("  %-40s %5d строк", "${extra_db}.${tname}", $rows));
            $total_rows += $rows;
            push @extra_exported, { db => $extra_db, table => $tname, rows => $rows, file => "${prefix}${tname}.json" };
        }
    }

    # Возвращаемся в основную БД
    $dbh->do("USE `$db_name`");
}

# ── Нормализованный файл rules (getconfig-формат) ─────────────────────────────

if (!$only_table || $only_table eq 'spz_rules') {
    eval {
        my ($cr, $cc, $ca) = export_rules_normalized($dbh, $rules_file);
        log_msg(sprintf("rules (norm): правил=%d критериев=%d действий=%d → %s",
            $cr, $cc, $ca, $rules_file));
    };
    log_msg("ОШИБКА rules normalized: $@") if $@;
}

# ── Архив /var/qmail/control (текущий, без даты) ─────────────────────────────

my $control_info = undef;
unless ($no_control || $only_table) {
    $control_info = backup_control_dir($control, $control_archive);
}

# ── Индексный файл ────────────────────────────────────────────────────────────

my $now_ts = strftime('%Y-%m-%d %H:%M:%S', localtime);

my $index = {
    exported_at      => $now_ts,
    database         => $db_name,
    total_tables     => scalar @exported,
    total_rows       => $total_rows,
    skipped          => \@skipped,
    failed           => \@failed,
    tables           => \@exported,
    control_backup   => $control_info,
};
$index->{extra_databases} = \@extra_exported if @extra_exported;
write_json($index_file, $index);

# ── Сохраняем sync-время ──────────────────────────────────────────────────────

save_file($sync_file, $now_ts);

# ── Общий архив конфигурации с датой/временем в имени ────────────────────────

my $combined_info = undef;
unless ($no_backup || $only_table) {
    my $stamp    = strftime('%Y-%m-%d_%H-%M-%S', localtime);
    my $arc_name = "spz-config-$stamp.tar.gz";
    my $arc_path = "$backup_dir/$arc_name";
    $combined_info = create_combined_archive($control, $out_dir, $arc_path);
}

my $ctrl_summary = ($no_control || $only_table) ? '' :
    defined $control_info->{error}
        ? " control=ОШИБКА"
        : sprintf(" control=%s", $control_info->{size_h} // '?');

my $bak_summary = ($no_backup || $only_table) ? '' :
    defined $combined_info->{error}
        ? " backup=ОШИБКА"
        : sprintf(" backup=%s", $combined_info->{size_h} // '?');

log_msg(sprintf("Готово: экспортировано=%d пропущено=%d ошибок=%d строк=%d%s%s",
    scalar @exported, scalar @skipped, scalar @failed, $total_rows,
    $ctrl_summary, $bak_summary));

$dbh->disconnect();
close LOG;
exit(scalar @failed ? 1 : 0);


# ═══════════════════════════════════════════════════════════════════════════════
# Подпрограммы
# ═══════════════════════════════════════════════════════════════════════════════

# Экспорт одной таблицы в JSON-файл
# Возвращает ($rows_count, undef) или (undef, $error_msg)
# $file_prefix — необязательный префикс имени файла (напр. "status__")
sub export_table_json {
    my ($dbh, $tname, $cols, $dir, $file_prefix) = @_;
    $file_prefix //= '';

    my $sth = $dbh->prepare("SELECT * FROM `$tname`")
        or return (undef, "prepare: " . $dbh->errstr);

    $sth->execute()
        or return (undef, "execute: " . $sth->errstr);

    my @col_names = map { $_->{name} } @$cols;
    my @col_types = map { $_->{type} } @$cols;

    my @rows;
    while (my $row = $sth->fetchrow_arrayref()) {
        my %obj;
        for my $i (0 .. $#col_names) {
            my $val = $row->[$i];
            if (!defined $val) {
                $obj{ $col_names[$i] } = undef;
            } elsif (is_numeric_type($col_types[$i])) {
                # числовые типы — без кавычек
                $obj{ $col_names[$i] } = $val + 0;
            } else {
                $obj{ $col_names[$i] } = $val;
            }
        }
        push @rows, \%obj;
    }
    $sth->finish();

    # Контрольные параметры для верификации импорта
    my $validation = $no_validate
        ? { skipped => JSON::PP::true }
        : compute_validation($dbh, $tname, $cols, \@rows);

    my $out = {
        table       => $tname,
        exported_at => strftime('%Y-%m-%d %H:%M:%S', localtime),
        columns     => $cols,
        count       => scalar @rows,
        rows        => \@rows,
        validation  => $validation,
    };

    my $file = "$dir/${file_prefix}${tname}.json";
    write_json($file, $out)
        or return (undef, "Не удалось записать $file");

    return (scalar @rows, undef);
}

# Вычисляет контрольные параметры таблицы для верификации импорта:
#   row_count    — количество строк
#   checksum     — MySQL CHECKSUM TABLE (CRC32 по всем данным)
#   pk           — min/max/count_distinct первичного ключа
#   col_nulls    — количество NULL по каждой колонке (только ненулевые)
#   col_numeric  — sum/min/max числовых колонок
#   sample_hash  — MD5 от первой, средней и последней строки
sub compute_validation {
    my ($dbh, $tname, $cols, $rows) = @_;

    my $v = { row_count => scalar @$rows };

    # Версия MySQL источника (нужна для сравнения checksum при импорте)
    my ($src_ver) = $dbh->selectrow_array("SELECT VERSION()");
    $v->{mysql_version} = $src_ver // '';

    # 1. MySQL CHECKSUM TABLE — быстрый CRC32 по всем данным
    my $chk = $dbh->selectrow_hashref("CHECKSUM TABLE `$tname`");
    $v->{checksum} = defined $chk->{Checksum} ? $chk->{Checksum}+0 : undef;

    # 2. Первичный ключ: min / max / count_distinct
    my ($pk_col) = grep { ($_->{key}//'') eq 'PRI' || ($_->{extra}//'') =~ /auto_increment/ } @$cols;
    if ($pk_col) {
        my $cn = $pk_col->{name};
        my $pk_numeric = is_numeric_type($pk_col->{type});
        my $ps = $dbh->selectrow_hashref(
            "SELECT MIN(`$cn`) AS mn, MAX(`$cn`) AS mx,
                    COUNT(DISTINCT `$cn`) AS cd FROM `$tname`"
        );
        if ($ps) {
            $v->{pk} = {
                column        => $cn,
                min           => defined $ps->{mn}
                                    ? ($pk_numeric ? $ps->{mn}+0 : $ps->{mn})
                                    : undef,
                max           => defined $ps->{mx}
                                    ? ($pk_numeric ? $ps->{mx}+0 : $ps->{mx})
                                    : undef,
                count_distinct => $ps->{cd}+0,
            };
        }
    }

    # 3. Статистика по колонкам (пропускаем для очень больших таблиц)
    if (@$rows <= $VALIDATE_FULL_THRESHOLD) {
        # Строим один агрегирующий SELECT для всех колонок за один проход
        my (@select_parts, @meta);
        for my $col (@$cols) {
            my $cn = $col->{name};
            push @select_parts, "SUM(`$cn` IS NULL)";
            push @meta, { col => $cn, type => 'nulls' };
            if (is_numeric_type($col->{type})) {
                push @select_parts, "SUM(`$cn`)", "MIN(`$cn`)", "MAX(`$cn`)";
                push @meta, map { { col => $cn, type => $_ } } qw(sum min max);
            }
        }
        my $agg_sql = "SELECT " . join(', ', @select_parts) . " FROM `$tname`";
        my $agg = $dbh->selectrow_arrayref($agg_sql);
        if ($agg) {
            my (%col_nulls, %col_numeric);
            for my $i (0 .. $#meta) {
                my ($cn, $type) = @{$meta[$i]}{qw(col type)};
                my $val = $agg->[$i];
                if ($type eq 'nulls') {
                    $col_nulls{$cn} = $val+0 if defined $val && $val > 0;
                } else {
                    $col_numeric{$cn}{$type} = defined $val
                        ? (looks_like_number($val) ? $val+0 : $val)
                        : undef;
                }
            }
            $v->{col_nulls}   = \%col_nulls   if %col_nulls;
            $v->{col_numeric} = \%col_numeric if %col_numeric;
        }
    } else {
        # Для больших таблиц — только числовые колонки без NULL-статистики
        my (@select_parts, @meta);
        for my $col (grep { is_numeric_type($_->{type}) } @$cols) {
            my $cn = $col->{name};
            push @select_parts, "SUM(`$cn`)", "MIN(`$cn`)", "MAX(`$cn`)";
            push @meta, map { { col => $cn, type => $_ } } qw(sum min max);
        }
        if (@select_parts) {
            my $agg = $dbh->selectrow_arrayref(
                "SELECT " . join(', ', @select_parts) . " FROM `$tname`"
            );
            if ($agg) {
                my %col_numeric;
                for my $i (0 .. $#meta) {
                    my ($cn, $type) = @{$meta[$i]}{qw(col type)};
                    my $val = $agg->[$i];
                    $col_numeric{$cn}{$type} = defined $val
                        ? (looks_like_number($val) ? $val+0 : $val)
                        : undef;
                }
                $v->{col_numeric} = \%col_numeric if %col_numeric;
            }
        }
        $v->{col_nulls_skipped} = JSON::PP::true;  # не считали для большой таблицы
    }

    # 4. sample_hash: MD5 от первой + средней + последней строки
    #    Ловит ошибки кодировки, усечение строк, перепутанные колонки
    if (@$rows) {
        my @samples = ($rows->[0]);
        push @samples, $rows->[ int(@$rows / 2) ] if @$rows > 2;
        push @samples, $rows->[-1]                 if @$rows > 1;
        my $canonical = JSON::PP->new->utf8->canonical;
        $v->{sample_hash}  = md5_hex($canonical->encode(\@samples));
        $v->{sample_count} = scalar @samples;
    }

    return $v;
}

# Нормализованный экспорт правил (getconfig-совместимый формат)
sub export_rules_normalized {
    my ($dbh, $file) = @_;

    open(my $fh, '>', $file) or die "Не удалось открыть $file: $!";
    printf $fh "# SPZ Rules Export — normalized format\n";
    printf $fh "# Generated: %s\n", strftime('%Y-%m-%d %H:%M:%S', localtime);
    printf $fh "# rule+rl_rlid+name+enabled+logic+stop+priority+domain+comment\n";
    printf $fh "# criterion+rl_rlid+rc_rcid+type+match+negate+value+scope+order\n";
    printf $fh "# action+rl_rlid+ra_raid+type+params+order\n#\n";

    my (%crit, %act);

    my $sc = $dbh->prepare("SELECT * FROM spz_rules_criteria ORDER BY rc_rlid,rc_order,rc_rcid");
    $sc && $sc->execute();
    while ($sc && (my $r = $sc->fetchrow_hashref())) {
        push @{$crit{$r->{rc_rlid}}}, $r;
    }
    $sc && $sc->finish();

    my $sa = $dbh->prepare("SELECT * FROM spz_rules_actions ORDER BY ra_rlid,ra_order,ra_raid");
    $sa && $sa->execute();
    while ($sa && (my $r = $sa->fetchrow_hashref())) {
        push @{$act{$r->{ra_rlid}}}, $r;
    }
    $sa && $sa->finish();

    my $sr = $dbh->prepare("
        SELECT r.*, IFNULL(c.username,'') AS username
        FROM spz_rules r
        LEFT JOIN spz_motd_client c ON r.rl_id=c.id
        ORDER BY c.username, r.rl_priority, r.rl_rlid
    ");
    $sr && $sr->execute();

    my ($cr, $cc, $ca, $cur_u) = (0, 0, 0, undef);
    while ($sr && (my $rule = $sr->fetchrow_hashref())) {
        my $rid = $rule->{rl_rlid};
        my $u   = $rule->{username} || '(global)';
        if (!defined $cur_u || $cur_u ne $u) {
            print $fh "\n#### Client: $u ####\n";
            $cur_u = $u;
        }
        print $fh join('+', 'rule', $rid,
            esc($rule->{rl_name}),     $rule->{rl_enabled},
            $rule->{rl_logic},         $rule->{rl_stop},
            $rule->{rl_priority},      esc($rule->{rl_domain}  // ''),
            esc($rule->{rl_comment}   // '')) . "\n";
        $cr++;
        for my $c (@{$crit{$rid} // []}) {
            print $fh join('+', 'criterion', $rid, $c->{rc_rcid},
                $c->{rc_type},  $c->{rc_match},  $c->{rc_negate},
                esc($c->{rc_value}), esc($c->{rc_scope} // ''), $c->{rc_order}) . "\n";
            $cc++;
        }
        for my $a (@{$act{$rid} // []}) {
            print $fh join('+', 'action', $rid, $a->{ra_raid},
                $a->{ra_type}, esc($a->{ra_params} // '{}'), $a->{ra_order}) . "\n";
            $ca++;
        }
    }
    $sr && $sr->finish();
    close $fh;
    return ($cr, $cc, $ca);
}

# Получить список таблиц из БД
sub db_tables {
    my ($dbh, $db) = @_;
    my $sth = $dbh->prepare("SHOW TABLES FROM `$db`") or return ();
    $sth->execute() or return ();
    my @list;
    while (my ($t) = $sth->fetchrow_array()) { push @list, $t; }
    $sth->finish();
    return @list;
}

# Получить описание колонок таблицы
# Возвращает массив хешей: { name, type, nullable, key, default, extra }
sub db_columns {
    my ($dbh, $tname) = @_;
    my $sth = $dbh->prepare("DESCRIBE `$tname`") or return ();
    $sth->execute() or return ();
    my @cols;
    while (my $row = $sth->fetchrow_hashref()) {
        push @cols, {
            name     => $row->{Field},
            type     => $row->{Type},
            nullable => ($row->{Null} eq 'YES' ? \1 : \0),
            key      => $row->{Key}     // '',
            default  => $row->{Default},
            extra    => $row->{Extra}   // '',
        };
    }
    $sth->finish();
    return @cols;
}

# Найти колонку с временем изменения (chtime, ctime, updated_at, ...)
sub find_chtime_col {
    my ($cols) = @_;
    my @candidates = qw(chtime rl_chtime nt_chtime bls_chtime wls_chtime
                        gbl_chtime gblip_chtime gbls_chtime tps_chtime
                        al_chtime att_chtime fr_chtime rlm_chtime ut_chtime
                        ua_ctime al_ctime ul_ulid updated_at);
    my %col_names  = map { $_->{name} => 1 } @$cols;
    for my $c (@candidates) {
        return $c if $col_names{$c};
    }
    # последний резерв: любая колонка с именем *chtime*
    for my $col (@$cols) {
        return $col->{name} if $col->{name} =~ /chtime/i;
    }
    return undef;
}

# Количество строк изменённых после $since
sub db_count_changed {
    my ($dbh, $tname, $field, $since) = @_;
    my $sth = $dbh->prepare("SELECT COUNT(*) FROM `$tname` WHERE `$field` >= ?")
        or return 1;
    $sth->execute($since) or return 1;
    my ($cnt) = $sth->fetchrow_array();
    $sth->finish();
    return $cnt // 0;
}

# Определить, является ли тип числовым (для корректного JSON)
sub is_numeric_type {
    my $type = lc(shift // '');
    return $type =~ /^(int|tinyint|smallint|mediumint|bigint|float|double|decimal|numeric|bit)\b/;
}

# Записать структуру данных в JSON-файл
sub write_json {
    my ($file, $data) = @_;
    open(my $fh, '>', $file) or do { log_msg("Не удалось записать $file: $!"); return 0; };
    print $fh $json->encode($data);
    close $fh;
    return 1;
}

# Архивирование /var/qmail/control в .tar.gz
# Возвращает хеш с информацией: { file, size, files, elapsed, error }
sub backup_control_dir {
    my ($src_dir, $archive) = @_;

    unless (-d $src_dir) {
        log_msg("control: директория $src_dir не найдена — пропуск");
        return { error => "не найдена: $src_dir" };
    }

    my $t0 = time();

    # Считаем файлы
    my $file_count = 0;
    {
        local $/ = undef;
        open(my $fh, '-|', 'find', $src_dir, '-type', 'f') or die $!;
        my $out = <$fh>; close $fh;
        $file_count = scalar(split /\n/, $out // '');
    }

    # Создаём архив: tar czf <archive> -C /var/qmail control
    my $parent = $src_dir;
    $parent =~ s{/[^/]+$}{};           # /var/qmail
    my $dir_name = $src_dir;
    $dir_name =~ s{.*/}{};             # control

    my $tmp = "$archive.tmp";
    my @cmd = ('tar', 'czf', $tmp, '-C', $parent, $dir_name);
    my $rc  = system(@cmd);

    if ($rc != 0) {
        unlink $tmp;
        my $err = "tar завершился с кодом " . ($rc >> 8);
        log_msg("control: ОШИБКА — $err");
        return { error => $err };
    }

    rename($tmp, $archive) or do {
        my $err = "rename $tmp → $archive: $!";
        log_msg("control: ОШИБКА — $err");
        return { error => $err };
    };

    my $size    = -s $archive;
    my $elapsed = time() - $t0;
    my $size_h  = format_size($size);

    log_msg(sprintf("control: %s → %s (%d файлов, %ds)",
        $src_dir, $archive, $file_count, $elapsed));

    return {
        file    => $archive,
        size    => $size,
        size_h  => $size_h,
        files   => $file_count,
        elapsed => $elapsed,
    };
}

# Общий архив: control/ + spzconfig/ с именем вида spz-config-YYYY-MM-DD_HH-MM-SS.tar.gz
# Исключает из spzconfig: *.pid, *.tmp, backups/, control.tar.gz (не дублируем)
sub create_combined_archive {
    my ($ctrl_dir, $cfg_dir, $arc_path) = @_;

    unless (-d $ctrl_dir) {
        my $err = "не найдена: $ctrl_dir";
        log_msg("backup: ОШИБКА — $err");
        return { error => $err };
    }

    my $t0  = time();
    my $tmp = "$arc_path.tmp";

    # Родительские директории для -C
    (my $ctrl_parent = $ctrl_dir) =~ s{/[^/]+$}{};
    (my $ctrl_name   = $ctrl_dir) =~ s{.*/}{};
    (my $cfg_parent  = $cfg_dir)  =~ s{/[^/]+$}{};
    (my $cfg_name    = $cfg_dir)  =~ s{.*/}{};

    my @cmd = (
        'tar', 'czf', $tmp,
        # Исключаем служебные файлы из spzconfig
        '--exclude=*.pid',
        '--exclude=*.tmp',
        '--exclude=backups',
        '--exclude=control.tar.gz',
        # Сначала /var/qmail/control
        '-C', $ctrl_parent, $ctrl_name,
        # Затем /var/qmail/spamorez/spzconfig
        '-C', $cfg_parent, $cfg_name,
    );

    my $rc = system(@cmd);
    if ($rc != 0) {
        unlink $tmp;
        my $err = "tar завершился с кодом " . ($rc >> 8);
        log_msg("backup: ОШИБКА — $err");
        return { error => $err };
    }

    rename($tmp, $arc_path) or do {
        unlink $tmp;
        my $err = "rename: $!";
        log_msg("backup: ОШИБКА — $err");
        return { error => $err };
    };

    my $size    = -s $arc_path;
    my $elapsed = time() - $t0;
    my $size_h  = format_size($size);

    log_msg(sprintf("backup: %s (%s, %ds)", $arc_path, $size_h, $elapsed));
    return { file => $arc_path, size => $size, size_h => $size_h, elapsed => $elapsed };
}

# Ротация архивов: оставляем последние $keep штук, удаляем старые
sub rotate_backups {
    my ($dir, $glob_pat, $keep) = @_;
    $keep = 14 unless defined $keep && $keep > 0;

    my @files = sort glob("$dir/$glob_pat");   # сортировка по имени = по дате
    my $total  = scalar @files;
    my $remove = $total - $keep;
    return unless $remove > 0;

    for my $f (@files[0 .. $remove-1]) {
        if (unlink $f) {
            log_msg("backup: удалён старый архив $f");
        } else {
            log_msg("backup: не удалось удалить $f: $!");
        }
    }
}

# Форматирование размера в человекочитаемый вид
sub format_size {
    my $bytes = shift // 0;
    return sprintf("%.1f GB", $bytes / 1_073_741_824) if $bytes >= 1_073_741_824;
    return sprintf("%.1f MB", $bytes / 1_048_576)     if $bytes >= 1_048_576;
    return sprintf("%.1f KB", $bytes / 1024)          if $bytes >= 1024;
    return "$bytes B";
}

# Подключение к MySQL c UTF-8
# Перебирает варианты DSN: сначала socket, затем TCP
sub db_connect {
    my ($host, $user, $pass, $db, $port, $socket) = @_;
    $port   ||= 3306;
    $socket ||= '/var/run/mysqld/mysqld.sock';

    my $attrs = { RaiseError => 0, PrintError => 0 };
    my $dbh;

    # 1. Попытка через Unix-сокет (только для локального хоста)
    my $is_local = ($host eq '127.0.0.1' || $host eq 'localhost' || $host eq '');
    if ($is_local && -S $socket) {
        $dbh = DBI->connect(
            "DBI:mysql:$db;mysql_socket=$socket",
            $user, $pass, $attrs
        );
    }

    # 2. Попытка через TCP
    if (!$dbh) {
        $dbh = DBI->connect(
            "DBI:mysql:$db;host=$host;port=$port",
            $user, $pass, $attrs
        );
    }

    log_die("Ошибка подключения к БД: " . ($DBI::errstr // 'unknown')) unless $dbh;

    $dbh->do($_) for
        "SET NAMES utf8mb4",
        "SET CHARACTER SET utf8mb4",
        "SET character_set_client     = utf8mb4",
        "SET character_set_connection = utf8mb4",
        "SET character_set_results    = utf8mb4";
    return $dbh;
}

# Чтение конфигурации БД.
# Стратегия (в порядке приоритета):
#   1. Если запущен как root (sudo) — /root/.my.cnf (root всегда имеет доступ)
#   2. ~/.my.cnf текущего пользователя
#   3. Стандартный qmail dbconfig: host:user:pass:db:port:
sub load_dbconfig {
    my ($file, $db_name) = @_;
    $db_name ||= 'spz';

    # Определяем имя БД из dbconfig (нужно даже если используем .my.cnf)
    my $db_from_cfg = $db_name;
    if (-f $file) {
        open(my $fh, '<', $file);
        if ($fh) {
            my $line = <$fh>; close $fh;
            chomp $line; $line =~ s/\s+$//;
            unless ($line =~ /^\s*\[/) {
                my (undef, undef, undef, $db) = split(':', $line);
                $db_from_cfg = $db if $db;
            }
        }
    }

    # 1. /root/.my.cnf (доступен при sudo, root имеет все привилегии)
    # 2. ~/.my.cnf
    for my $mycnf ('/root/.my.cnf', ($ENV{HOME} ? "$ENV{HOME}/.my.cnf" : ())) {
        next unless -f $mycnf;
        my %cfg = parse_mycnf($mycnf);
        next unless $cfg{user};
        log_msg("Конфиг БД: $mycnf (user=$cfg{user})");
        return (
            $cfg{host}     || '127.0.0.1',
            $cfg{user},
            $cfg{password} || '',
            $db_from_cfg,
            $cfg{port}     || 3306,
        );
    }

    # 3. Стандартный dbconfig: host:user:pass:db:port:
    if (-f $file) {
        open(my $fh, '<', $file) or log_die("Не удалось открыть $file: $!");
        my $line = <$fh>; close $fh;
        chomp $line; $line =~ s/\s+$//;
        my ($host, $user, $pass, $db, $port) = split(':', $line);
        log_msg("Конфиг БД: $file (user=$user)");
        return ($host || '127.0.0.1', $user, $pass, $db || $db_from_cfg, $port || 3306);
    }

    log_die("Не найден конфиг БД: ни .my.cnf ни $file");
}

# Разбор INI-файла .my.cnf, секция [client]
sub parse_mycnf {
    my $file = shift;
    my (%cfg, $in_client);
    open(my $fh, '<', $file) or return ();
    while (<$fh>) {
        chomp;
        s/#.*//;           # убираем комментарии
        s/^\s+|\s+$//g;    # trim
        if (/^\[(\w+)\]$/) { $in_client = ($1 eq 'client'); next; }
        if ($in_client && /^(\w+)\s*=\s*(.*)$/) { $cfg{$1} = $2; }
    }
    close $fh;
    return %cfg;
}

sub load_file {
    my $file = shift;
    return '' unless -f $file;
    open(my $fh, '<', $file) or return '';
    my $s = <$fh>; close $fh;
    chomp $s if defined $s;
    return $s // '';
}

sub save_file {
    my ($file, $str) = @_;
    open(my $fh, '>', $file) or do { log_msg("Не удалось сохранить $file: $!"); return 0; };
    print $fh $str; close $fh; return 1;
}

sub esc {
    my $v = shift // '';
    $v =~ s/\\/\\\\/g;
    $v =~ s/\+/\\+/g;
    $v =~ s/\r?\n/\\n/g;
    return $v;
}

sub log_msg {
    my $str = shift;
    $str =~ s/\n/_/g;
    my $line = strftime('%Y-%m-%d %H:%M:%S', localtime) . " $str\n";
    print LOG $line;
    print $line if $verbose;
}

sub log_die {
    my $str = shift;
    log_msg("FATAL: $str");
    close LOG; exit 1;
}

sub usage {
    print <<'END';
Использование: export_rules.pl [параметры]

  -f, --force        Принудительный полный экспорт
  -v, --verbose      Подробный вывод в stdout
  -t, --table NAME   Экспортировать только указанную таблицу
  --skip NAME        Пропустить таблицу (можно повторять)
  --extra-db NAME    Также экспортировать БД NAME (можно повторять; файлы: NAME__table.json)
  --backup-dir PATH  Базовый каталог хранения (умолч: ./backup)
  --host HOST        Хост MySQL (переопределяет .my.cnf / dbconfig)
  --user, -u USER    Пользователь MySQL
  --password, -p PWD Пароль MySQL
  --port PORT        Порт MySQL
  --db, --database DB Имя БД (переопределяет dbconfig)
  --with-logs        Включить журнальные таблицы в экспорт
  --no-control       Не обновлять control.tar.gz
  --no-backup        Не создавать общий архив с датой/временем
  --no-validate      Не вычислять контрольные суммы (быстрее, но без верификации)
  --keep N           Хранить последние N архивов (по умолчанию: 14)
  --list             Показать все таблицы в БД и выйти
  -h, --help         Эта справка

Алгоритм:
  1. SHOW TABLES FROM spz        — обнаружение всех таблиц
  2. DESCRIBE <table>            — анализ структуры каждой таблицы
  3. SELECT * FROM <table>       — выгрузка данных
  4. Запись в <table>.json       — по одному файлу на таблицу

Файлы (при запуске из /data/spz/docker/spzgate_2.0.0):
  Конфиг БД:     /var/qmail/control/spamorez/dbconfig  (или --host/--user/--password)
  JSON-файлы:    ./backup/spzconfig/<table>.json
  Индекс:        ./backup/spzconfig/export_index.json
  Rules (norm.): ./backup/spzconfig/rules
  Control arch.: ./backup/spzconfig/control.tar.gz
  Архивы:        ./backup/archives/spz-config-YYYY-MM-DD_HH-MM-SS.tar.gz
  Синхронизация: ./backup/spzconfig/spz_export.sync
  Лог:           ./backup/logs/export_rules.log

Формат JSON-файла:
  {
    "table":       "spz_rules",
    "exported_at": "2026-06-05 10:00:00",
    "columns":     [{"name":"rl_rlid","type":"int(10) unsigned",...}, ...],
    "count":       42,
    "rows":        [{"rl_rlid":1,"rl_name":"My Rule",...}, ...]
  }

Журнальные таблицы (пропускаются без --with-logs):
  spz_userlogs, spz_useractions, spz_actionlogs, spz_sessions, spz_tokens
END
}
