<?php
declare(strict_types=1);

if (!function_exists('reclama_ident_ok')) {
    function reclama_ident_ok(string $name): bool
    {
        return (bool)preg_match('/^[a-zA-Z0-9_]+$/', $name);
    }
}

if (!function_exists('reclama_table_exists')) {
    function reclama_table_exists(PDO $pdo, string $table): bool
    {
        static $cache = [];

        if (!reclama_ident_ok($table)) return false;

        $k = 't:' . $table;
        if (array_key_exists($k, $cache)) return (bool)$cache[$k];

        if (function_exists('db_has_table')) {
            try {
                $cache[$k] = (bool)db_has_table($pdo, $table);
                return (bool)$cache[$k];
            } catch (Throwable $e) {
                // fallback below
            }
        }

        try {
            $st = $pdo->prepare("
                SELECT 1
                FROM information_schema.tables
                WHERE table_schema = DATABASE()
                  AND table_name = ?
                LIMIT 1
            ");
            $st->execute([$table]);
            $cache[$k] = (bool)$st->fetchColumn();
        } catch (Throwable $e) {
            $cache[$k] = false;
        }

        return (bool)$cache[$k];
    }
}

if (!function_exists('reclama_column_exists')) {
    function reclama_column_exists(PDO $pdo, string $table, string $column): bool
    {
        static $cache = [];

        if (!reclama_ident_ok($table) || !reclama_ident_ok($column)) return false;

        $k = 'c:' . $table . ':' . $column;
        if (array_key_exists($k, $cache)) return (bool)$cache[$k];

        if (function_exists('db_has_column')) {
            try {
                $cache[$k] = (bool)db_has_column($pdo, $table, $column);
                return (bool)$cache[$k];
            } catch (Throwable $e) {
                // fallback below
            }
        }

        try {
            $st = $pdo->prepare("
                SELECT 1
                FROM information_schema.columns
                WHERE table_schema = DATABASE()
                  AND table_name = ?
                  AND column_name = ?
                LIMIT 1
            ");
            $st->execute([$table, $column]);
            $cache[$k] = (bool)$st->fetchColumn();
        } catch (Throwable $e) {
            $cache[$k] = false;
        }

        return (bool)$cache[$k];
    }
}

if (!function_exists('reclama_safe_int')) {
    function reclama_safe_int(PDO $pdo, string $sql, array $params = []): int
    {
        try {
            $st = $pdo->prepare($sql);
            $st->execute($params);
            return (int)$st->fetchColumn();
        } catch (Throwable $e) {
            return 0;
        }
    }
}

if (!function_exists('reclama_safe_float')) {
    function reclama_safe_float(PDO $pdo, string $sql, array $params = []): float
    {
        try {
            $st = $pdo->prepare($sql);
            $st->execute($params);
            $v = $st->fetchColumn();
            return ($v === null) ? 0.0 : (float)$v;
        } catch (Throwable $e) {
            return 0.0;
        }
    }
}

if (!function_exists('reclama_settings_defaults')) {
    function reclama_settings_defaults(): array
    {
        return [
            'reclama_enabled'                      => '0',
            'reclama_price_per_day'                => '50',
            'reclama_min_days'                     => '1',
            'reclama_max_days'                     => '30',
            'reclama_charge_mode'                  => 'prepay',
            'reclama_feed_interval_posts'          => '5',
            'reclama_frequency_cap_per_user_day'   => '20',
            'reclama_rotation_mode'                => 'round_robin',
            'reclama_auto_activate_after_approval' => '1',
            'reclama_require_moderation'           => '0',
            'reclama_allow_post_campaigns'         => '1',
            'reclama_allow_banner_campaigns'       => '1',
            'reclama_allow_desktop_only'           => '1',
        ];
    }
}

if (!function_exists('reclama_load_settings')) {
    function reclama_load_settings(PDO $pdo): array
    {
        $defaults = reclama_settings_defaults();

        if (!reclama_table_exists($pdo, 'app_settings')) {
            return $defaults;
        }

        try {
            $keys = array_keys($defaults);
            $in   = implode(',', array_fill(0, count($keys), '?'));

            $st = $pdo->prepare("
                SELECT key_name, value_text
                FROM app_settings
                WHERE key_name IN ($in)
            ");
            $st->execute($keys);

            $map = $defaults;
            foreach (($st->fetchAll(PDO::FETCH_ASSOC) ?: []) as $row) {
                $k = (string)($row['key_name'] ?? '');
                if ($k !== '' && array_key_exists($k, $map)) {
                    $map[$k] = (string)($row['value_text'] ?? '');
                }
            }

            return $map;
        } catch (Throwable $e) {
            return $defaults;
        }
    }
}

if (!function_exists('reclama_save_settings')) {
    function reclama_save_settings(PDO $pdo, array $values): void
    {
        if (!reclama_table_exists($pdo, 'app_settings')) {
            throw new RuntimeException('app_settings table is missing');
        }

        $allowed = reclama_settings_defaults();
        $payload = [];

        foreach ($allowed as $key => $default) {
            if (array_key_exists($key, $values)) {
                $payload[$key] = (string)$values[$key];
            }
        }

        if (!$payload) return;

        $ownTx = !$pdo->inTransaction();
        if ($ownTx) {
            $pdo->beginTransaction();
        }

        try {
            $st = $pdo->prepare("
                INSERT INTO app_settings (key_name, value_text, updated_at)
                VALUES (?, ?, NOW())
                ON DUPLICATE KEY UPDATE
                    value_text = VALUES(value_text),
                    updated_at = VALUES(updated_at)
            ");

            foreach ($payload as $key => $value) {
                $st->execute([$key, $value]);
            }

            if ($ownTx) {
                $pdo->commit();
            }
        } catch (Throwable $e) {
            if ($ownTx && $pdo->inTransaction()) $pdo->rollBack();
            throw $e;
        }
    }
}

if (!function_exists('reclama_setting_bool')) {
    function reclama_setting_bool(PDO $pdo, string $key, bool $default = false): bool
    {
        $all = reclama_load_settings($pdo);
        if (!array_key_exists($key, $all)) return $default;
        return ((int)$all[$key] === 1);
    }
}

if (!function_exists('reclama_setting_int')) {
    function reclama_setting_int(PDO $pdo, string $key, int $default = 0): int
    {
        $all = reclama_load_settings($pdo);
        if (!array_key_exists($key, $all)) return $default;
        return (int)$all[$key];
    }
}

if (!function_exists('reclama_setting_string')) {
    function reclama_setting_string(PDO $pdo, string $key, string $default = ''): string
    {
        $all = reclama_load_settings($pdo);
        if (!array_key_exists($key, $all)) return $default;
        return trim((string)$all[$key]);
    }
}

if (!function_exists('reclama_is_enabled')) {
    function reclama_is_enabled(PDO $pdo): bool
    {
        return reclama_setting_bool($pdo, 'reclama_enabled', false);
    }
}

if (!function_exists('reclama_enabled')) {
    function reclama_enabled(PDO $pdo): bool
    {
        return reclama_is_enabled($pdo);
    }
}

if (!function_exists('reclama_price_per_day')) {
    function reclama_price_per_day(PDO $pdo): int
    {
        return max(0, reclama_setting_int($pdo, 'reclama_price_per_day', 50));
    }
}

if (!function_exists('reclama_min_days')) {
    function reclama_min_days(PDO $pdo): int
    {
        return max(1, reclama_setting_int($pdo, 'reclama_min_days', 1));
    }
}

if (!function_exists('reclama_max_days')) {
    function reclama_max_days(PDO $pdo): int
    {
        return max(reclama_min_days($pdo), reclama_setting_int($pdo, 'reclama_max_days', 30));
    }
}

if (!function_exists('reclama_charge_mode')) {
    function reclama_charge_mode(PDO $pdo): string
    {
        $v = reclama_setting_string($pdo, 'reclama_charge_mode', 'prepay');
        return in_array($v, ['prepay', 'reserve'], true) ? $v : 'prepay';
    }
}

if (!function_exists('reclama_require_moderation')) {
    function reclama_require_moderation(PDO $pdo): bool
    {
        return reclama_setting_bool($pdo, 'reclama_require_moderation', false);
    }
}

if (!function_exists('reclama_auto_activate_after_approval')) {
    function reclama_auto_activate_after_approval(PDO $pdo): bool
    {
        return reclama_setting_bool($pdo, 'reclama_auto_activate_after_approval', true);
    }
}

if (!function_exists('reclama_allow_post_campaigns')) {
    function reclama_allow_post_campaigns(PDO $pdo): bool
    {
        return reclama_setting_bool($pdo, 'reclama_allow_post_campaigns', true);
    }
}

if (!function_exists('reclama_allow_banner_campaigns')) {
    function reclama_allow_banner_campaigns(PDO $pdo): bool
    {
        return reclama_setting_bool($pdo, 'reclama_allow_banner_campaigns', true);
    }
}

if (!function_exists('reclama_allow_desktop_only')) {
    function reclama_allow_desktop_only(PDO $pdo): bool
    {
        return reclama_setting_bool($pdo, 'reclama_allow_desktop_only', true);
    }
}

if (!function_exists('reclama_feed_interval_posts')) {
    function reclama_feed_interval_posts(PDO $pdo): int
    {
        return max(1, reclama_setting_int($pdo, 'reclama_feed_interval_posts', 5));
    }
}

if (!function_exists('reclama_frequency_cap_per_user_day')) {
    function reclama_frequency_cap_per_user_day(PDO $pdo): int
    {
        return max(1, reclama_setting_int($pdo, 'reclama_frequency_cap_per_user_day', 20));
    }
}

if (!function_exists('reclama_rotation_mode')) {
    function reclama_rotation_mode(PDO $pdo): string
    {
        $v = reclama_setting_string($pdo, 'reclama_rotation_mode', 'round_robin');
        return in_array($v, ['round_robin', 'random'], true) ? $v : 'round_robin';
    }
}

if (!function_exists('reclama_calc_days')) {
    function reclama_calc_days(?string $startDate, ?string $endDate): int
    {
        $startDate = trim((string)$startDate);
        $endDate   = trim((string)$endDate);

        if ($startDate === '' || $endDate === '') return 0;

        try {
            $a = new DateTimeImmutable($startDate . ' 00:00:00');
            $b = new DateTimeImmutable($endDate . ' 00:00:00');
        } catch (Throwable $e) {
            return 0;
        }

        if ($b < $a) return 0;

        $diff = $a->diff($b);
        return (int)$diff->days + 1;
    }
}

if (!function_exists('reclama_calc_total')) {
    function reclama_calc_total(int $daysCount, int $pricePerDay): int
    {
        $daysCount   = max(0, $daysCount);
        $pricePerDay = max(0, $pricePerDay);
        return $daysCount * $pricePerDay;
    }
}

if (!function_exists('reclama_validate_dates')) {
    function reclama_validate_dates(?string $startDate, ?string $endDate, int $minDays = 1, int $maxDays = 365): array
    {
        $errors = [];
        $startDate = trim((string)$startDate);
        $endDate   = trim((string)$endDate);

        if ($startDate === '' || $endDate === '') {
            $errors[] = 'empty_dates';
            return [
                'ok'         => false,
                'errors'     => $errors,
                'start_date' => '',
                'end_date'   => '',
                'days_count' => 0,
            ];
        }

        $days = reclama_calc_days($startDate, $endDate);
        if ($days <= 0) {
            $errors[] = 'bad_period';
        } elseif ($days < $minDays) {
            $errors[] = 'too_short';
        } elseif ($days > $maxDays) {
            $errors[] = 'too_long';
        }

        return [
            'ok'         => !$errors,
            'errors'     => $errors,
            'start_date' => $startDate,
            'end_date'   => $endDate,
            'days_count' => $days,
        ];
    }
}

if (!function_exists('reclama_normalize_country_code')) {
    function reclama_normalize_country_code(?string $countryCode): ?string
    {
        $countryCode = strtoupper(trim((string)$countryCode));
        if ($countryCode === '') return null;
        if (!preg_match('/^[A-Z]{2}$/', $countryCode)) return null;
        return $countryCode;
    }
}

if (!function_exists('reclama_json_decode_array')) {
    function reclama_json_decode_array(mixed $value): array
    {
        if (is_array($value)) {
            return array_values($value);
        }

        $raw = trim((string)$value);
        if ($raw === '') return [];

        try {
            $decoded = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
            return is_array($decoded) ? array_values($decoded) : [];
        } catch (Throwable $e) {
            return [];
        }
    }
}

if (!function_exists('reclama_json_encode_or_null')) {
    function reclama_json_encode_or_null(array $items): ?string
    {
        $items = array_values(array_unique(array_filter($items, static function ($v): bool {
            return is_string($v) && trim($v) !== '';
        })));

        if (!$items) return null;

        $json = json_encode($items, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        return ($json === false) ? null : $json;
    }
}

if (!function_exists('reclama_normalize_country_codes_list')) {
    function reclama_normalize_country_codes_list(mixed $value): array
    {
        $items = [];

        if (is_array($value)) {
            $items = $value;
        } else {
            $raw = trim((string)$value);
            if ($raw === '') {
                return [];
            }

            $decoded = reclama_json_decode_array($raw);
            if ($decoded) {
                $items = $decoded;
            } else {
                $items = preg_split('/[\s,;|]+/u', $raw) ?: [];
            }
        }

        $out = [];
        foreach ($items as $item) {
            $code = reclama_normalize_country_code((string)$item);
            if ($code !== null) {
                $out[$code] = $code;
            }
        }

        return array_values($out);
    }
}

if (!function_exists('reclama_country_codes_to_storage')) {
    function reclama_country_codes_to_storage(array $codes): ?string
    {
        $codes = reclama_normalize_country_codes_list($codes);
        if (!$codes) {
            return null;
        }

        return count($codes) === 1 ? $codes[0] : null;
    }
}

if (!function_exists('reclama_country_codes_overlap')) {
    function reclama_country_codes_overlap(array $a, array $b): array
    {
        $a = reclama_normalize_country_codes_list($a);
        $b = reclama_normalize_country_codes_list($b);
        if (!$a || !$b) return [];

        $map = [];
        foreach ($a as $code) {
            $map[$code] = true;
        }

        $out = [];
        foreach ($b as $code) {
            if (isset($map[$code])) {
                $out[$code] = $code;
            }
        }

        return array_values($out);
    }
}

if (!function_exists('reclama_target_country_codes')) {
    function reclama_target_country_codes(mixed $value, mixed $jsonValue = null): array
    {
        $codes = reclama_normalize_country_codes_list($jsonValue);
        if ($codes) {
            return $codes;
        }

        return reclama_normalize_country_codes_list($value);
    }
}

if (!function_exists('reclama_normalize_segment_code')) {
    function reclama_normalize_segment_code(?string $code): ?string
    {
        $code = trim((string)$code);
        if ($code === '') return null;

        $code = function_exists('mb_strtolower')
            ? (string)mb_strtolower($code, 'UTF-8')
            : strtolower($code);

        $code = preg_replace('/\s+/u', '_', $code) ?? $code;
        $code = preg_replace('/[^a-z0-9_\-]+/', '', $code) ?? $code;
        $code = trim($code, '_-');

        if ($code === '') return null;
        if (!preg_match('/^[a-z0-9][a-z0-9_\-]{0,49}$/', $code)) return null;

        return $code;
    }
}

if (!function_exists('reclama_normalize_segment_codes')) {
    function reclama_normalize_segment_codes(mixed $value): array
    {
        $items = [];

        if (is_array($value)) {
            $items = $value;
        } else {
            $raw = trim((string)$value);
            if ($raw === '') {
                return [];
            }

            $decoded = reclama_json_decode_array($raw);
            if ($decoded) {
                $items = $decoded;
            } else {
                $items = preg_split('/[\r\n,;|]+/u', $raw) ?: [];
            }
        }

        $out = [];
        foreach ($items as $item) {
            $code = reclama_normalize_segment_code((string)$item);
            if ($code !== null) {
                $out[$code] = $code;
            }
        }

        return array_values($out);
    }
}

if (!function_exists('reclama_normalize_targeting')) {
    function reclama_normalize_targeting(array $input): array
    {
        $gender = trim((string)($input['gender'] ?? 'any'));
        if (!in_array($gender, ['any', 'm', 'f'], true)) $gender = 'any';

        $langCode = trim((string)($input['lang_code'] ?? 'any'));
        if (!in_array($langCode, ['any', 'uk', 'ru', 'en'], true)) $langCode = 'any';

        $deviceType = trim((string)($input['device_type'] ?? 'all'));
        if (!in_array($deviceType, ['all', 'desktop', 'mobile'], true)) $deviceType = 'all';

        $authScope = trim((string)($input['auth_scope'] ?? 'all'));
        if (!in_array($authScope, ['all', 'guests', 'users'], true)) $authScope = 'all';

        $countryCodes = reclama_target_country_codes(
            $input['country_code'] ?? ($input['country_codes'] ?? []),
            $input['country_codes_json'] ?? null
        );

        $city = trim((string)($input['city'] ?? ''));
        if ($city === '') {
            $city = null;
        }
        if ($city !== null) {
            $city = mb_substr($city, 0, 120);
        }

        if (count($countryCodes) !== 1) {
            $city = null;
        }

        $ageFrom = isset($input['age_from']) && $input['age_from'] !== '' ? (int)$input['age_from'] : null;
        $ageTo   = isset($input['age_to']) && $input['age_to'] !== '' ? (int)$input['age_to'] : null;

        if ($ageFrom !== null && ($ageFrom < 13 || $ageFrom > 100)) $ageFrom = null;
        if ($ageTo   !== null && ($ageTo   < 13 || $ageTo   > 100)) $ageTo   = null;

        $excludeCountries = reclama_normalize_country_codes_list(
            $input['exclude_countries'] ?? ($input['exclude_countries_json'] ?? [])
        );

        $segmentCodes = reclama_normalize_segment_codes(
            $input['segment_codes'] ?? ($input['segment_codes_json'] ?? [])
        );

        return [
            'country_code'           => reclama_country_codes_to_storage($countryCodes),
            'country_codes'          => $countryCodes,
            'country_codes_json'     => reclama_json_encode_or_null($countryCodes),
            'city'                   => $city,
            'gender'                 => $gender,
            'age_from'               => $ageFrom,
            'age_to'                 => $ageTo,
            'lang_code'              => $langCode,
            'device_type'            => $deviceType,
            'exclude_countries'      => $excludeCountries,
            'exclude_countries_json' => reclama_json_encode_or_null($excludeCountries),
            'auth_scope'             => $authScope,
            'segment_codes'          => $segmentCodes,
            'segment_codes_json'     => reclama_json_encode_or_null($segmentCodes),
        ];
    }
}

if (!function_exists('reclama_validate_targeting')) {
    function reclama_validate_targeting(array $input): array
    {
        $value  = reclama_normalize_targeting($input);
        $errors = [];

        if ($value['age_from'] !== null && $value['age_to'] !== null && $value['age_from'] > $value['age_to']) {
            $errors[] = 'age_range';
        }

        if (reclama_country_codes_overlap(
            (array)($value['country_codes'] ?? []),
            (array)($value['exclude_countries'] ?? [])
        )) {
            $errors[] = 'country_excluded';
        }

        return [
            'ok'     => !$errors,
            'errors' => $errors,
            'value'  => $value,
        ];
    }
}

if (!function_exists('reclama_detect_device_type')) {
    function reclama_detect_device_type(?string $ua): string
    {
        $ua = strtolower(trim((string)$ua));
        if ($ua === '') return 'unknown';

        if (strpos($ua, 'ipad') !== false || strpos($ua, 'tablet') !== false) return 'tablet';
        if (strpos($ua, 'mobile') !== false || strpos($ua, 'iphone') !== false || strpos($ua, 'android') !== false) return 'mobile';
        return 'desktop';
    }
}

if (!function_exists('reclama_upload_base_dir')) {
    function reclama_upload_base_dir(string $username): string
    {
        $safe = preg_replace('~[^a-zA-Z0-9_\-]+~', '_', trim($username));
        if ($safe === '' || $safe === null) $safe = 'user';

        return '/upload/reclama/' . $safe;
    }
}

if (!function_exists('reclama_campaign_status')) {
    function reclama_campaign_status(array $campaign): string
    {
        return trim((string)($campaign['status'] ?? 'draft'));
    }
}

if (!function_exists('reclama_campaign_window_active_today')) {
    function reclama_campaign_window_active_today(array $campaign): bool
    {
        $today = date('Y-m-d');
        $start = trim((string)($campaign['start_date'] ?? ''));
        $end   = trim((string)($campaign['end_date'] ?? ''));

        if ($start === '' || $end === '') return false;
        return ($start <= $today && $end >= $today);
    }
}

if (!function_exists('reclama_payment_is_settled')) {
    function reclama_payment_is_settled(?string $paymentStatus): bool
    {
        $paymentStatus = trim((string)$paymentStatus);
        return in_array($paymentStatus, ['paid', 'reserved'], true);
    }
}

if (!function_exists('reclama_payment_is_refunded')) {
    function reclama_payment_is_refunded(?string $paymentStatus): bool
    {
        $paymentStatus = trim((string)$paymentStatus);
        return in_array($paymentStatus, ['refunded', 'partial_refund'], true);
    }
}

if (!function_exists('reclama_requires_desktop_banner')) {
    function reclama_requires_desktop_banner(array $campaign): bool
    {
        return trim((string)($campaign['campaign_type'] ?? 'post')) === 'banner';
    }
}

if (!function_exists('reclama_missing_required_assets')) {
    function reclama_missing_required_assets(PDO $pdo, array $campaign): array
    {
        $errors = [];
        $type = trim((string)($campaign['campaign_type'] ?? 'post'));

        if ($type === 'post' && !reclama_allow_post_campaigns($pdo)) {
            $errors[] = 'post_campaigns_disabled';
        }

        if ($type === 'banner') {
            if (!reclama_allow_banner_campaigns($pdo)) {
                $errors[] = 'banner_campaigns_disabled';
            }

            $desktop = trim((string)($campaign['desktop_banner_path'] ?? ''));
            $mobile  = trim((string)($campaign['mobile_banner_path'] ?? ''));

            if ($desktop === '') {
                $errors[] = 'desktop_banner_required';
            }
            if ($desktop === '' && $mobile !== '') {
                $errors[] = 'mobile_without_desktop';
            }
        }

        return array_values(array_unique($errors));
    }
}

if (!function_exists('reclama_is_active_now')) {
    function reclama_is_active_now(array $campaign, ?string $today = null): bool
    {
        $today = trim((string)$today);
        if ($today === '') $today = date('Y-m-d');

        $status           = trim((string)($campaign['status'] ?? ''));
        $moderationStatus = trim((string)($campaign['moderation_status'] ?? ''));
        $paymentStatus    = trim((string)($campaign['payment_status'] ?? ''));
        $start            = trim((string)($campaign['start_date'] ?? ''));
        $end              = trim((string)($campaign['end_date'] ?? ''));
        $desktop          = trim((string)($campaign['desktop_banner_path'] ?? ''));

        if ($status !== 'active') return false;
        if (!in_array($moderationStatus, ['approved'], true)) return false;
        if (!reclama_payment_is_settled($paymentStatus)) return false;
        if ($start === '' || $end === '' || $start > $today || $end < $today) return false;
        if ($desktop === '' && trim((string)($campaign['campaign_type'] ?? 'post')) === 'banner') return false;

        return true;
    }
}

if (!function_exists('reclama_user_can_edit')) {
    function reclama_user_can_edit(array $campaign, int $userId): bool
    {
        if ($userId <= 0) return false;
        if ((int)($campaign['user_id'] ?? 0) !== $userId) return false;

        $status = trim((string)($campaign['status'] ?? 'draft'));
        return in_array($status, ['draft', 'pending', 'rejected', 'approved', 'active', 'paused'], true);
    }
}

if (!function_exists('reclama_can_pay_campaign')) {
    function reclama_can_pay_campaign(PDO $pdo, array $campaign): array
    {
        $errors = [];

        if (!reclama_is_enabled($pdo)) {
            $errors[] = 'module_disabled';
        }

        $status = trim((string)($campaign['status'] ?? 'draft'));
        if (!in_array($status, ['pending', 'approved', 'paused', 'active'], true)) {
            $errors[] = 'status_not_payable';
        }

        $paymentStatus = trim((string)($campaign['payment_status'] ?? 'unpaid'));
        if (reclama_payment_is_settled($paymentStatus)) {
            $errors[] = 'already_paid';
        } elseif (reclama_payment_is_refunded($paymentStatus)) {
            $errors[] = 'already_refunded';
        }

        $dateCheck = reclama_validate_dates(
            (string)($campaign['start_date'] ?? ''),
            (string)($campaign['end_date'] ?? ''),
            reclama_min_days($pdo),
            reclama_max_days($pdo)
        );
        if (!($dateCheck['ok'] ?? false)) {
            $errors = array_merge($errors, (array)($dateCheck['errors'] ?? []));
        }

        if ((int)($campaign['days_count'] ?? 0) <= 0) {
            $errors[] = 'bad_days_count';
        }

        if ((int)($campaign['total_price'] ?? 0) <= 0) {
            $errors[] = 'bad_total_price';
        }

        $errors = array_merge($errors, reclama_missing_required_assets($pdo, $campaign));

        return [
            'ok'     => !$errors,
            'errors' => array_values(array_unique($errors)),
        ];
    }
}

if (!function_exists('reclama_can_launch')) {
    function reclama_can_launch(PDO $pdo, array $campaign): array
    {
        $errors = [];

        if (!reclama_is_enabled($pdo)) {
            $errors[] = 'module_disabled';
        }

        $errors = array_merge($errors, reclama_missing_required_assets($pdo, $campaign));

        $dateCheck = reclama_validate_dates(
            (string)($campaign['start_date'] ?? ''),
            (string)($campaign['end_date'] ?? ''),
            reclama_min_days($pdo),
            reclama_max_days($pdo)
        );
        if (!($dateCheck['ok'] ?? false)) {
            $errors = array_merge($errors, (array)($dateCheck['errors'] ?? []));
        }

        if ((int)($campaign['days_count'] ?? 0) <= 0) {
            $errors[] = 'bad_days_count';
        }

        if ((int)($campaign['total_price'] ?? 0) <= 0) {
            $errors[] = 'bad_total_price';
        }

        $moderationRequired = reclama_require_moderation($pdo);
        $moderationStatus   = trim((string)($campaign['moderation_status'] ?? 'pending'));
        if ($moderationRequired && $moderationStatus !== 'approved') {
            $errors[] = 'not_approved';
        }

        $paymentStatus = trim((string)($campaign['payment_status'] ?? 'unpaid'));
        if (!reclama_payment_is_settled($paymentStatus)) {
            $errors[] = 'not_paid';
        }

        return [
            'ok'     => !$errors,
            'errors' => array_values(array_unique($errors)),
        ];
    }
}

if (!function_exists('reclama_resolve_runtime_status')) {
    function reclama_resolve_runtime_status(PDO $pdo, array $campaign, ?string $today = null): string
    {
        $today = trim((string)$today);
        if ($today === '') $today = date('Y-m-d');

        $current = trim((string)($campaign['status'] ?? 'draft'));
        if (in_array($current, ['canceled', 'finished'], true)) {
            return $current;
        }
        if ($current === 'paused') {
            return 'paused';
        }

        $moderationStatus = trim((string)($campaign['moderation_status'] ?? 'pending'));
        if ($moderationStatus === 'rejected') {
            return 'rejected';
        }

        if ($moderationStatus !== 'approved') {
            return $current === 'draft' ? 'draft' : 'pending';
        }

        $start = trim((string)($campaign['start_date'] ?? ''));
        $end   = trim((string)($campaign['end_date'] ?? ''));
        if ($start !== '' && $end !== '' && $end < $today) {
            return 'finished';
        }

        if (!reclama_payment_is_settled((string)($campaign['payment_status'] ?? 'unpaid'))) {
            return 'approved';
        }

        if (!reclama_auto_activate_after_approval($pdo)) {
            return 'approved';
        }

        if (reclama_campaign_window_active_today($campaign)) {
            $launchCheck = reclama_can_launch($pdo, $campaign);
            if (($launchCheck['ok'] ?? false) === true) {
                return 'active';
            }
        }

        if ($start !== '' && $start > $today) {
            return 'approved';
        }

        return 'approved';
    }
}

if (!function_exists('reclama_sync_campaign_status')) {
    function reclama_sync_campaign_status(PDO $pdo, int $campaignId, ?string $today = null): array
    {
        $campaign = reclama_get_campaign($pdo, $campaignId);
        if (!$campaign) {
            return ['ok' => false, 'reason' => 'not_found'];
        }

        $current   = trim((string)($campaign['status'] ?? 'draft'));
        $newStatus = reclama_resolve_runtime_status($pdo, $campaign, $today);

        if ($newStatus === $current) {
            return ['ok' => true, 'status' => $current, 'changed' => false];
        }

        $set  = ["status = ?", "updated_at = NOW()"];
        $bind = [$newStatus];

        if ($newStatus === 'active' && reclama_column_exists($pdo, 'reclama_campaigns', 'launched_at')) {
            $set[] = "launched_at = COALESCE(launched_at, NOW())";
        }
        if ($newStatus === 'finished' && reclama_column_exists($pdo, 'reclama_campaigns', 'finished_at')) {
            $set[] = "finished_at = COALESCE(finished_at, NOW())";
        }

        $sql = "
            UPDATE reclama_campaigns
            SET " . implode(', ', $set) . "
            WHERE id = ?
            LIMIT 1
        ";
        $bind[] = $campaignId;

        try {
            $st = $pdo->prepare($sql);
            $st->execute($bind);

            return ['ok' => true, 'status' => $newStatus, 'changed' => true];
        } catch (Throwable $e) {
            return ['ok' => false, 'reason' => 'db_error', 'error' => $e->getMessage()];
        }
    }
}

if (!function_exists('reclama_campaigns_admin_stats')) {
    function reclama_campaigns_admin_stats(PDO $pdo): array
    {
        $stats = [
            'total'              => 0,
            'draft'              => 0,
            'pending'            => 0,
            'approved'           => 0,
            'rejected'           => 0,
            'active'             => 0,
            'paused'             => 0,
            'finished'           => 0,
            'canceled'           => 0,
            'paid'               => 0,
            'unpaid'             => 0,
            'reserved'           => 0,
            'total_budget'       => 0,
            'paid_budget'        => 0,
            'impressions_total'  => 0,
            'clicks_total'       => 0,
            'impressions_today'  => 0,
            'clicks_today'       => 0,
        ];

        if (!reclama_table_exists($pdo, 'reclama_campaigns')) {
            return $stats;
        }

        try {
            $row = $pdo->query("
                SELECT
                    COUNT(*) AS total,
                    SUM(CASE WHEN status = 'draft'    THEN 1 ELSE 0 END) AS draft_cnt,
                    SUM(CASE WHEN status = 'pending'  THEN 1 ELSE 0 END) AS pending_cnt,
                    SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) AS approved_cnt,
                    SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) AS rejected_cnt,
                    SUM(CASE WHEN status = 'active'   THEN 1 ELSE 0 END) AS active_cnt,
                    SUM(CASE WHEN status = 'paused'   THEN 1 ELSE 0 END) AS paused_cnt,
                    SUM(CASE WHEN status = 'finished' THEN 1 ELSE 0 END) AS finished_cnt,
                    SUM(CASE WHEN status = 'canceled' THEN 1 ELSE 0 END) AS canceled_cnt,
                    SUM(CASE WHEN payment_status = 'paid'     THEN 1 ELSE 0 END) AS paid_cnt,
                    SUM(CASE WHEN payment_status = 'unpaid'   THEN 1 ELSE 0 END) AS unpaid_cnt,
                    SUM(CASE WHEN payment_status = 'reserved' THEN 1 ELSE 0 END) AS reserved_cnt,
                    SUM(total_price) AS total_budget,
                    SUM(CASE WHEN payment_status = 'paid' THEN total_price ELSE 0 END) AS paid_budget
                FROM reclama_campaigns
            ")->fetch(PDO::FETCH_ASSOC) ?: [];

            $stats['total']        = (int)($row['total'] ?? 0);
            $stats['draft']        = (int)($row['draft_cnt'] ?? 0);
            $stats['pending']      = (int)($row['pending_cnt'] ?? 0);
            $stats['approved']     = (int)($row['approved_cnt'] ?? 0);
            $stats['rejected']     = (int)($row['rejected_cnt'] ?? 0);
            $stats['active']       = (int)($row['active_cnt'] ?? 0);
            $stats['paused']       = (int)($row['paused_cnt'] ?? 0);
            $stats['finished']     = (int)($row['finished_cnt'] ?? 0);
            $stats['canceled']     = (int)($row['canceled_cnt'] ?? 0);
            $stats['paid']         = (int)($row['paid_cnt'] ?? 0);
            $stats['unpaid']       = (int)($row['unpaid_cnt'] ?? 0);
            $stats['reserved']     = (int)($row['reserved_cnt'] ?? 0);
            $stats['total_budget'] = (int)($row['total_budget'] ?? 0);
            $stats['paid_budget']  = (int)($row['paid_budget'] ?? 0);
        } catch (Throwable $e) {
            // keep defaults
        }

        if (reclama_table_exists($pdo, 'reclama_campaign_impressions')) {
            $stats['impressions_total'] = reclama_safe_int($pdo, "SELECT COUNT(*) FROM reclama_campaign_impressions");
            $stats['impressions_today'] = reclama_safe_int(
                $pdo,
                "SELECT COUNT(*) FROM reclama_campaign_impressions WHERE DATE(shown_at) = CURRENT_DATE()"
            );
        }

        if (reclama_table_exists($pdo, 'reclama_campaign_clicks')) {
            $stats['clicks_total'] = reclama_safe_int($pdo, "SELECT COUNT(*) FROM reclama_campaign_clicks");
            $stats['clicks_today'] = reclama_safe_int(
                $pdo,
                "SELECT COUNT(*) FROM reclama_campaign_clicks WHERE DATE(clicked_at) = CURRENT_DATE()"
            );
        }

        return $stats;
    }
}

if (!function_exists('reclama_admin_campaigns')) {
    function reclama_admin_campaigns(PDO $pdo, int $limit = 30, bool $onlyQueue = false): array
    {
        if (!reclama_table_exists($pdo, 'reclama_campaigns')) {
            return [];
        }

        $limit = max(1, min(200, $limit));

        $hasUsers            = reclama_table_exists($pdo, 'users');
        $hasTargeting        = reclama_table_exists($pdo, 'reclama_campaign_targeting');
        $hasCountryCodesJson = $hasTargeting && reclama_column_exists($pdo, 'reclama_campaign_targeting', 'country_codes_json');

        $sql = "
            SELECT
                c.id,
                c.user_id,
                c.campaign_type,
                c.source_post_id,
                c.title,
                c.target_url,
                c.status,
                c.moderation_status,
                c.payment_status,
                c.start_date,
                c.end_date,
                c.days_count,
                c.price_per_day,
                c.total_price,
                c.desktop_banner_path,
                c.mobile_banner_path,
                c.desktop_banner_original_name,
                c.mobile_banner_original_name,
                c.reject_reason,
                c.approved_by_user_id,
                c.rejected_by_user_id,
                c.moderated_at,
                c.paid_at,
                c.launched_at,
                c.paused_at,
                c.finished_at,
                c.created_at,
                c.updated_at,
                " . ($hasUsers ? "u.username," : "'' AS username,") . "
                " . ($hasTargeting ? "
                    t.country_code,
                    " . ($hasCountryCodesJson ? "t.country_codes_json," : "NULL AS country_codes_json,") . "
                    t.exclude_countries_json,
                    t.city,
                    t.gender,
                    t.age_from,
                    t.age_to,
                    t.lang_code,
                    t.device_type,
                    t.auth_scope,
                    t.segment_codes_json
                " : "
                    NULL AS country_code,
                    NULL AS country_codes_json,
                    NULL AS exclude_countries_json,
                    NULL AS city,
                    'any' AS gender,
                    NULL AS age_from,
                    NULL AS age_to,
                    'any' AS lang_code,
                    'all' AS device_type,
                    'all' AS auth_scope,
                    NULL AS segment_codes_json
                ") . "
            FROM reclama_campaigns c
            " . ($hasUsers ? "LEFT JOIN users u ON u.id = c.user_id" : "") . "
            " . ($hasTargeting ? "LEFT JOIN reclama_campaign_targeting t ON t.campaign_id = c.id" : "") . "
        ";

        $where = [];
        if ($onlyQueue) {
            $where[] = "(c.status = 'pending' OR c.moderation_status = 'pending')";
        }
        if ($where) {
            $sql .= " WHERE " . implode(' AND ', $where);
        }

        $sql .= " ORDER BY c.id DESC LIMIT {$limit}";

        try {
            $rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC) ?: [];
            foreach ($rows as &$row) {
                $row['country_codes']     = reclama_target_country_codes($row['country_code'] ?? null, $row['country_codes_json'] ?? null);
                $row['exclude_countries'] = reclama_normalize_country_codes_list($row['exclude_countries_json'] ?? null);
                $row['segment_codes']     = reclama_normalize_segment_codes($row['segment_codes_json'] ?? null);
                $row['auth_scope']        = in_array((string)($row['auth_scope'] ?? 'all'), ['all', 'guests', 'users'], true)
                    ? (string)$row['auth_scope']
                    : 'all';
            }
            unset($row);

            return $rows;
        } catch (Throwable $e) {
            return [];
        }
    }
}

if (!function_exists('reclama_get_campaign')) {
    function reclama_get_campaign(PDO $pdo, int $campaignId): ?array
    {
        if ($campaignId <= 0 || !reclama_table_exists($pdo, 'reclama_campaigns')) return null;

        try {
            $st = $pdo->prepare("
                SELECT *
                FROM reclama_campaigns
                WHERE id = ?
                LIMIT 1
            ");
            $st->execute([$campaignId]);
            $row = $st->fetch(PDO::FETCH_ASSOC) ?: null;
            return $row ?: null;
        } catch (Throwable $e) {
            return null;
        }
    }
}

if (!function_exists('reclama_get_campaign_for_user')) {
    function reclama_get_campaign_for_user(PDO $pdo, int $campaignId, int $userId): ?array
    {
        $row = reclama_get_campaign($pdo, $campaignId);
        if (!$row) return null;
        if ((int)($row['user_id'] ?? 0) !== $userId) return null;
        return $row;
    }
}

if (!function_exists('reclama_get_campaign_targeting')) {
    function reclama_get_campaign_targeting(PDO $pdo, int $campaignId): array
    {
        if ($campaignId <= 0 || !reclama_table_exists($pdo, 'reclama_campaign_targeting')) {
            return [
                'country_code'           => null,
                'country_codes'          => [],
                'country_codes_json'     => null,
                'exclude_countries'      => [],
                'exclude_countries_json' => null,
                'city'                   => null,
                'gender'                 => 'any',
                'age_from'               => null,
                'age_to'                 => null,
                'lang_code'              => 'any',
                'device_type'            => 'all',
                'auth_scope'             => 'all',
                'segment_codes'          => [],
                'segment_codes_json'     => null,
            ];
        }

        $hasCountryCodesJson = reclama_column_exists($pdo, 'reclama_campaign_targeting', 'country_codes_json');

        try {
            $st = $pdo->prepare("
                SELECT
                    country_code,
                    " . ($hasCountryCodesJson ? "country_codes_json," : "NULL AS country_codes_json,") . "
                    exclude_countries_json,
                    city,
                    gender,
                    age_from,
                    age_to,
                    lang_code,
                    device_type,
                    auth_scope,
                    segment_codes_json
                FROM reclama_campaign_targeting
                WHERE campaign_id = ?
                LIMIT 1
            ");
            $st->execute([$campaignId]);
            $row = $st->fetch(PDO::FETCH_ASSOC) ?: [];

            $countryCodes      = reclama_target_country_codes($row['country_code'] ?? null, $row['country_codes_json'] ?? null);
            $excludeCountries  = reclama_normalize_country_codes_list($row['exclude_countries_json'] ?? null);
            $segmentCodes      = reclama_normalize_segment_codes($row['segment_codes_json'] ?? null);
            $authScope         = trim((string)($row['auth_scope'] ?? 'all'));
            if (!in_array($authScope, ['all', 'guests', 'users'], true)) {
                $authScope = 'all';
            }

            return [
                'country_code'           => $row['country_code'] ?? reclama_country_codes_to_storage($countryCodes),
                'country_codes'          => $countryCodes,
                'country_codes_json'     => $row['country_codes_json'] ?? reclama_json_encode_or_null($countryCodes),
                'exclude_countries'      => $excludeCountries,
                'exclude_countries_json' => $row['exclude_countries_json'] ?? reclama_json_encode_or_null($excludeCountries),
                'city'                   => $row['city'] ?? null,
                'gender'                 => $row['gender'] ?? 'any',
                'age_from'               => array_key_exists('age_from', $row) ? $row['age_from'] : null,
                'age_to'                 => array_key_exists('age_to', $row) ? $row['age_to'] : null,
                'lang_code'              => $row['lang_code'] ?? 'any',
                'device_type'            => $row['device_type'] ?? 'all',
                'auth_scope'             => $authScope,
                'segment_codes'          => $segmentCodes,
                'segment_codes_json'     => $row['segment_codes_json'] ?? reclama_json_encode_or_null($segmentCodes),
            ];
        } catch (Throwable $e) {
            return [
                'country_code'           => null,
                'country_codes'          => [],
                'country_codes_json'     => null,
                'exclude_countries'      => [],
                'exclude_countries_json' => null,
                'city'                   => null,
                'gender'                 => 'any',
                'age_from'               => null,
                'age_to'                 => null,
                'lang_code'              => 'any',
                'device_type'            => 'all',
                'auth_scope'             => 'all',
                'segment_codes'          => [],
                'segment_codes_json'     => null,
            ];
        }
    }
}

if (!function_exists('reclama_get_campaign_admin_view')) {
    function reclama_get_campaign_admin_view(PDO $pdo, int $campaignId): ?array
    {
        if ($campaignId <= 0 || !reclama_table_exists($pdo, 'reclama_campaigns')) {
            return null;
        }

        $hasUsers            = reclama_table_exists($pdo, 'users');
        $userHasUsername     = $hasUsers && reclama_column_exists($pdo, 'users', 'username');
        $userHasName         = $hasUsers && reclama_column_exists($pdo, 'users', 'name');
        $hasTargeting        = reclama_table_exists($pdo, 'reclama_campaign_targeting');
        $hasPosts            = reclama_table_exists($pdo, 'posts');
        $hasCountryCodesJson = $hasTargeting && reclama_column_exists($pdo, 'reclama_campaign_targeting', 'country_codes_json');

        $hasApprovedByUserId = reclama_column_exists($pdo, 'reclama_campaigns', 'approved_by_user_id');
        $hasRejectedByUserId = reclama_column_exists($pdo, 'reclama_campaigns', 'rejected_by_user_id');

        $select = [
            'c.*',
        ];

        if ($hasUsers && $userHasUsername) {
            $select[] = 'u.username AS owner_username';
        } else {
            $select[] = '"" AS owner_username';
        }

        if ($hasUsers && $userHasName) {
            $select[] = 'u.name AS owner_name';
        } else {
            $select[] = '"" AS owner_name';
        }

        if ($hasUsers && $hasApprovedByUserId && $userHasUsername) {
            $select[] = 'ap.username AS approved_by_username';
        } else {
            $select[] = '"" AS approved_by_username';
        }

        if ($hasUsers && $hasApprovedByUserId && $userHasName) {
            $select[] = 'ap.name AS approved_by_name';
        } else {
            $select[] = '"" AS approved_by_name';
        }

        if ($hasUsers && $hasRejectedByUserId && $userHasUsername) {
            $select[] = 'rj.username AS rejected_by_username';
        } else {
            $select[] = '"" AS rejected_by_username';
        }

        if ($hasUsers && $hasRejectedByUserId && $userHasName) {
            $select[] = 'rj.name AS rejected_by_name';
        } else {
            $select[] = '"" AS rejected_by_name';
        }

        if ($hasTargeting) {
            $select[] = 't.country_code';
            $select[] = $hasCountryCodesJson ? 't.country_codes_json' : 'NULL AS country_codes_json';
            $select[] = 't.exclude_countries_json';
            $select[] = 't.city';
            $select[] = 't.gender';
            $select[] = 't.age_from';
            $select[] = 't.age_to';
            $select[] = 't.lang_code';
            $select[] = 't.device_type';
            $select[] = 't.auth_scope';
            $select[] = 't.segment_codes_json';
        } else {
            $select[] = 'NULL AS country_code';
            $select[] = 'NULL AS country_codes_json';
            $select[] = 'NULL AS exclude_countries_json';
            $select[] = 'NULL AS city';
            $select[] = '"any" AS gender';
            $select[] = 'NULL AS age_from';
            $select[] = 'NULL AS age_to';
            $select[] = '"any" AS lang_code';
            $select[] = '"all" AS device_type';
            $select[] = '"all" AS auth_scope';
            $select[] = 'NULL AS segment_codes_json';
        }

        if ($hasPosts) {
            $select[] = 'p.body AS source_post_body';
            $select[] = 'p.created_at AS source_post_created_at';
            $select[] = 'p.user_id AS source_post_user_id';
        } else {
            $select[] = 'NULL AS source_post_body';
            $select[] = 'NULL AS source_post_created_at';
            $select[] = 'NULL AS source_post_user_id';
        }

        $joins = [];

        if ($hasUsers) {
            $joins[] = 'LEFT JOIN users u ON u.id = c.user_id';

            if ($hasApprovedByUserId) {
                $joins[] = 'LEFT JOIN users ap ON ap.id = c.approved_by_user_id';
            }

            if ($hasRejectedByUserId) {
                $joins[] = 'LEFT JOIN users rj ON rj.id = c.rejected_by_user_id';
            }
        }

        if ($hasTargeting) {
            $joins[] = 'LEFT JOIN reclama_campaign_targeting t ON t.campaign_id = c.id';
        }

        if ($hasPosts) {
            $joins[] = 'LEFT JOIN posts p ON p.id = c.source_post_id';
        }

        $sql = '
            SELECT
                ' . implode(",\n                ", $select) . '
            FROM reclama_campaigns c
            ' . implode("\n            ", $joins) . '
            WHERE c.id = ?
            LIMIT 1
        ';

        try {
            $st = $pdo->prepare($sql);
            $st->execute([$campaignId]);
            $row = $st->fetch(PDO::FETCH_ASSOC) ?: null;
            if (!$row) return null;

            $row['country_codes']     = reclama_target_country_codes($row['country_code'] ?? null, $row['country_codes_json'] ?? null);
            $row['exclude_countries'] = reclama_normalize_country_codes_list($row['exclude_countries_json'] ?? null);
            $row['segment_codes']     = reclama_normalize_segment_codes($row['segment_codes_json'] ?? null);
            $row['auth_scope']        = in_array((string)($row['auth_scope'] ?? 'all'), ['all', 'guests', 'users'], true)
                ? (string)$row['auth_scope']
                : 'all';

            return $row;
        } catch (Throwable $e) {
            error_log('reclama_get_campaign_admin_view error: ' . $e->getMessage());
            return null;
        }
    }
}

if (!function_exists('reclama_get_campaign_moderation_log')) {
    function reclama_get_campaign_moderation_log(PDO $pdo, int $campaignId, int $limit = 50): array
    {
        if ($campaignId <= 0 || !reclama_table_exists($pdo, 'reclama_campaign_moderation')) {
            return [];
        }

        $limit    = max(1, min(200, $limit));
        $hasUsers = reclama_table_exists($pdo, 'users');

        $sql = "
            SELECT
                m.id,
                m.campaign_id,
                m.admin_user_id,
                m.action,
                m.note,
                m.created_at,
                " . ($hasUsers ? "u.username AS admin_username, u.name AS admin_name" : "'' AS admin_username, '' AS admin_name") . "
            FROM reclama_campaign_moderation m
            " . ($hasUsers ? "LEFT JOIN users u ON u.id = m.admin_user_id" : "") . "
            WHERE m.campaign_id = ?
            ORDER BY m.id DESC
            LIMIT {$limit}
        ";

        try {
            $st = $pdo->prepare($sql);
            $st->execute([$campaignId]);
            return $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
        } catch (Throwable $e) {
            return [];
        }
    }
}

if (!function_exists('reclama_log_moderation')) {
    function reclama_log_moderation(PDO $pdo, int $campaignId, string $action, ?int $adminUserId = null, ?string $note = null): void
    {
        if ($campaignId <= 0) return;
        if (!reclama_table_exists($pdo, 'reclama_campaign_moderation')) return;

        $action = trim($action);
        if (!in_array($action, ['created', 'submitted', 'approved', 'rejected', 'paused', 'resumed', 'finished', 'canceled', 'edited'], true)) {
            return;
        }

        $note = trim((string)$note);
        if ($note === '') $note = null;
        if ($note !== null) $note = mb_substr($note, 0, 500);

        try {
            $st = $pdo->prepare("
                INSERT INTO reclama_campaign_moderation (campaign_id, admin_user_id, action, note, created_at)
                VALUES (?, ?, ?, ?, NOW())
            ");
            $st->execute([
                $campaignId,
                ($adminUserId && $adminUserId > 0) ? $adminUserId : null,
                $action,
                $note,
            ]);
        } catch (Throwable $e) {
            // do not break flow
        }
    }
}

if (!function_exists('reclama_admin_change_status')) {
    function reclama_admin_change_status(PDO $pdo, int $campaignId, string $action, int $adminUserId, string $note = ''): array
    {
        $campaign = reclama_get_campaign($pdo, $campaignId);
        if (!$campaign) {
            return ['ok' => false, 'reason' => 'not_found'];
        }

        $action = trim($action);
        if (!in_array($action, ['approved', 'rejected', 'paused', 'resumed', 'finished', 'canceled'], true)) {
            return ['ok' => false, 'reason' => 'bad_action'];
        }

        $now  = date('Y-m-d H:i:s');
        $set  = [];
        $bind = [];
        $note = trim($note);
        if ($note !== '') $note = mb_substr($note, 0, 500);

        switch ($action) {
            case 'approved':
                $set[] = "moderation_status = 'approved'";
                $set[] = "approved_by_user_id = ?";
                $bind[] = $adminUserId;

                if (reclama_column_exists($pdo, 'reclama_campaigns', 'rejected_by_user_id')) {
                    $set[] = "rejected_by_user_id = NULL";
                }
                if (reclama_column_exists($pdo, 'reclama_campaigns', 'reject_reason')) {
                    $set[] = "reject_reason = NULL";
                }

                $set[] = "moderated_at = ?";
                $bind[] = $now;
                break;

            case 'rejected':
                $set[] = "status = 'rejected'";
                $set[] = "moderation_status = 'rejected'";
                $set[] = "rejected_by_user_id = ?";
                $bind[] = $adminUserId;
                $set[] = "moderated_at = ?";
                $bind[] = $now;
                if (reclama_column_exists($pdo, 'reclama_campaigns', 'reject_reason')) {
                    $set[] = "reject_reason = ?";
                    $bind[] = ($note !== '' ? $note : 'Rejected by admin');
                }
                break;

            case 'paused':
                $set[] = "status = 'paused'";
                if (reclama_column_exists($pdo, 'reclama_campaigns', 'paused_at')) {
                    $set[] = "paused_at = ?";
                    $bind[] = $now;
                }
                break;

            case 'resumed':
                break;

            case 'finished':
                $set[] = "status = 'finished'";
                if (reclama_column_exists($pdo, 'reclama_campaigns', 'finished_at')) {
                    $set[] = "finished_at = ?";
                    $bind[] = $now;
                }
                break;

            case 'canceled':
                $set[] = "status = 'canceled'";
                break;
        }

        if ($action === 'resumed') {
            $set[] = "status = 'approved'";
        }

        if (!$set) {
            return ['ok' => false, 'reason' => 'nothing_to_update'];
        }

        $sql = "
            UPDATE reclama_campaigns
            SET " . implode(', ', $set) . ", updated_at = NOW()
            WHERE id = ?
            LIMIT 1
        ";
        $bind[] = $campaignId;

        $refundResult = null;
        $refundReason = match ($action) {
            'rejected' => 'Rejected by admin',
            'finished' => 'Finished by admin',
            'canceled' => 'Canceled by admin',
            default    => '',
        };

        $ownTx = !$pdo->inTransaction();
        if ($ownTx) {
            $pdo->beginTransaction();
        }

        try {
            $st = $pdo->prepare($sql);
            $st->execute($bind);

            if (in_array($action, ['approved', 'resumed'], true)) {
                $sync = reclama_sync_campaign_status($pdo, $campaignId);
                if (!($sync['ok'] ?? false)) {
                    throw new RuntimeException((string)($sync['reason'] ?? 'sync_failed'));
                }
            }

            if (
                in_array($action, ['rejected', 'finished', 'canceled'], true)
                && reclama_payment_is_settled((string)($campaign['payment_status'] ?? 'unpaid'))
            ) {
                $refundResult = reclama_refund_unused($pdo, $campaign, date('Y-m-d'), $refundReason);
                if (($refundResult['ok'] ?? false) !== true && !in_array((string)($refundResult['reason'] ?? ''), ['nothing_to_refund', 'duplicate_refund'], true)) {
                    throw new RuntimeException('refund_failed');
                }
            }

            reclama_log_moderation(
                $pdo,
                $campaignId,
                $action === 'resumed' ? 'resumed' : $action,
                $adminUserId,
                $note
            );

            if ($ownTx) {
                $pdo->commit();
            }

            return ['ok' => true, 'refund' => $refundResult];
        } catch (Throwable $e) {
            if ($ownTx && $pdo->inTransaction()) $pdo->rollBack();
            return ['ok' => false, 'reason' => 'db_error', 'error' => $e->getMessage()];
        }
    }
}

if (!function_exists('reclama_vmcoins_ready')) {
    function reclama_vmcoins_ready(PDO $pdo): bool
    {
        return
            reclama_table_exists($pdo, 'users') &&
            reclama_table_exists($pdo, 'vm_coin_transactions') &&
            reclama_column_exists($pdo, 'users', 'vm_coins_balance') &&
            reclama_column_exists($pdo, 'users', 'vm_coins_earned_total') &&
            reclama_column_exists($pdo, 'users', 'vm_coins_spent_total');
    }
}

if (!function_exists('reclama_get_user_balance')) {
    function reclama_get_user_balance(PDO $pdo, int $userId): int
    {
        if ($userId <= 0) return 0;

        if (function_exists('vmc_get_balance')) {
            try {
                return (int)vmc_get_balance($pdo, $userId);
            } catch (Throwable $e) {
                // fallback below
            }
        }

        if (!reclama_table_exists($pdo, 'users') || !reclama_column_exists($pdo, 'users', 'vm_coins_balance')) {
            return 0;
        }

        try {
            $st = $pdo->prepare("SELECT vm_coins_balance FROM users WHERE id = ? LIMIT 1");
            $st->execute([$userId]);
            return (int)$st->fetchColumn();
        } catch (Throwable $e) {
            return 0;
        }
    }
}

if (!function_exists('reclama_tx_op_exists')) {
    function reclama_tx_op_exists(PDO $pdo, string $opKey): bool
    {
        if ($opKey === '' || !reclama_table_exists($pdo, 'vm_coin_transactions')) return false;

        try {
            $st = $pdo->prepare("
                SELECT 1
                FROM vm_coin_transactions
                WHERE op_key = ?
                LIMIT 1
            ");
            $st->execute([$opKey]);
            return (bool)$st->fetchColumn();
        } catch (Throwable $e) {
            return false;
        }
    }
}

if (!function_exists('reclama_apply_vmcoins_delta')) {
    function reclama_apply_vmcoins_delta(
        PDO $pdo,
        int $userId,
        int $deltaAmount,
        string $eventKey,
        ?string $entityType = null,
        ?int $entityId = null,
        ?int $relatedUserId = null,
        ?string $opKey = null,
        ?string $note = null,
        ?array $meta = null

    ): array {
        if ($userId <= 0) {
            return ['ok' => false, 'reason' => 'bad_user'];
        }

        if ($deltaAmount === 0) {
            return ['ok' => false, 'reason' => 'zero_delta'];
        }

        if (!reclama_vmcoins_ready($pdo)) {
            return ['ok' => false, 'reason' => 'vmcoins_schema_missing'];
        }

        $opKey = trim((string)$opKey);
        if ($opKey !== '' && reclama_tx_op_exists($pdo, $opKey)) {
            return ['ok' => false, 'reason' => 'duplicate_op'];
        }

        $note = trim((string)$note);
        if ($note === '') $note = null;
        if ($note !== null) $note = mb_substr($note, 0, 255);

        $entityType = trim((string)$entityType);
        if ($entityType === '') $entityType = null;

        $metaJson = null;
        if (is_array($meta) && $meta !== []) {
            $metaJson = json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            if ($metaJson === false) $metaJson = null;
        }

        $ownTx = !$pdo->inTransaction();
        if ($ownTx) {
            $pdo->beginTransaction();
        }

        try {
            $st = $pdo->prepare("
                SELECT id, vm_coins_balance, vm_coins_earned_total, vm_coins_spent_total
                FROM users
                WHERE id = ?
                LIMIT 1
                FOR UPDATE
            ");
            $st->execute([$userId]);
            $u = $st->fetch(PDO::FETCH_ASSOC) ?: null;

            if (!$u) {
                throw new RuntimeException('User not found');
            }

            $balance = (int)($u['vm_coins_balance'] ?? 0);
            $earned  = (int)($u['vm_coins_earned_total'] ?? 0);
            $spent   = (int)($u['vm_coins_spent_total'] ?? 0);

            $balanceAfter = $balance + $deltaAmount;
            if ($balanceAfter < 0) {
                if ($ownTx && $pdo->inTransaction()) $pdo->rollBack();
                return [
                    'ok'       => false,
                    'reason'   => 'insufficient_balance',
                    'balance'  => $balance,
                    'required' => abs($deltaAmount),
                ];
            }

            if ($deltaAmount > 0) {
                $earned += $deltaAmount;
            } else {
                $spent += abs($deltaAmount);
            }

            $st = $pdo->prepare("
                UPDATE users
                SET
                    vm_coins_balance = ?,
                    vm_coins_earned_total = ?,
                    vm_coins_spent_total = ?,
                    vm_coins_updated_at = NOW()
                WHERE id = ?
                LIMIT 1
            ");
            $st->execute([$balanceAfter, $earned, $spent, $userId]);

            $st = $pdo->prepare("
                INSERT INTO vm_coin_transactions
                    (user_id, delta_amount, balance_after, event_key, entity_type, entity_id, related_user_id, op_key, note, meta_json, created_at)
                VALUES
                    (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
            ");
            $st->execute([
                $userId,
                $deltaAmount,
                $balanceAfter,
                $eventKey,
                $entityType,
                $entityId,
                $relatedUserId,
                $opKey !== '' ? $opKey : null,
                $note,
                $metaJson,
            ]);

            if ($ownTx) {
                $pdo->commit();
            }

            return [
                'ok'             => true,
                'balance_before' => $balance,
                'balance_after'  => $balanceAfter,
                'delta'          => $deltaAmount,
            ];
        } catch (Throwable $e) {
            if ($ownTx && $pdo->inTransaction()) $pdo->rollBack();
            return ['ok' => false, 'reason' => 'db_error', 'error' => $e->getMessage()];
        }
    }
}

if (!function_exists('reclama_mark_payment_state')) {
    function reclama_mark_payment_state(PDO $pdo, int $campaignId, string $paymentStatus, bool $touchPaidAt = false): void
    {
        if ($campaignId <= 0 || !reclama_table_exists($pdo, 'reclama_campaigns')) return;

        $paymentStatus = trim($paymentStatus);
        if (!in_array($paymentStatus, ['unpaid', 'reserved', 'paid', 'refunded', 'partial_refund'], true)) {
            return;
        }

        $sql = "
            UPDATE reclama_campaigns
            SET payment_status = ?,
                updated_at = NOW()
        ";
        $params = [$paymentStatus];

        if ($touchPaidAt && reclama_column_exists($pdo, 'reclama_campaigns', 'paid_at')) {
            $sql .= ", paid_at = NOW()";
        }

        $sql .= " WHERE id = ? LIMIT 1";
        $params[] = $campaignId;

        $st = $pdo->prepare($sql);
        $st->execute($params);
    }
}

if (!function_exists('reclama_reserve_or_charge_coins')) {
    function reclama_reserve_or_charge_coins(PDO $pdo, array $campaign, ?string $mode = null): array
    {
        $campaignId = (int)($campaign['id'] ?? 0);
        $userId     = (int)($campaign['user_id'] ?? 0);
        $totalPrice = max(0, (int)($campaign['total_price'] ?? 0));
        $mode       = trim((string)$mode);
        if ($mode === '') $mode = reclama_charge_mode($pdo);

        if ($campaignId <= 0 || $userId <= 0) {
            return ['ok' => false, 'reason' => 'bad_campaign'];
        }

        $canPay = reclama_can_pay_campaign($pdo, $campaign);
        if (!($canPay['ok'] ?? false)) {
            return ['ok' => false, 'reason' => 'campaign_not_payable', 'errors' => (array)($canPay['errors'] ?? [])];
        }

        if ($totalPrice <= 0) {
            return ['ok' => false, 'reason' => 'bad_total_price'];
        }

        if (!in_array($mode, ['prepay', 'reserve'], true)) {
            $mode = 'prepay';
        }

        $eventKey = ($mode === 'reserve') ? 'reclama_reserve' : 'reclama_charge';
        $note = ($mode === 'reserve')
            ? ('Reserve for reclame campaign #' . $campaignId)
            : ('Charge for reclame campaign #' . $campaignId);
        $opKey = 'reclama:' . $mode . ':' . $campaignId;

        $meta = [
            'campaign_id'   => $campaignId,
            'campaign_type' => (string)($campaign['campaign_type'] ?? 'post'),
            'days_count'    => (int)($campaign['days_count'] ?? 0),
            'price_per_day' => (int)($campaign['price_per_day'] ?? 0),
            'total_price'   => $totalPrice,
            'mode'          => $mode,
        ];

        $ownTx = !$pdo->inTransaction();
        if ($ownTx) {
            $pdo->beginTransaction();
        }

        try {
            $res = reclama_apply_vmcoins_delta(
                $pdo,
                $userId,
                -$totalPrice,
                $eventKey,
                'reclama_campaign',
                $campaignId,
                null,
                $opKey,
                $note,
                $meta
            );

            if (!($res['ok'] ?? false)) {
                if ($ownTx && $pdo->inTransaction()) $pdo->rollBack();
                return $res;
            }

            reclama_mark_payment_state($pdo, $campaignId, $mode === 'reserve' ? 'reserved' : 'paid', $mode !== 'reserve');

            $sync = reclama_sync_campaign_status($pdo, $campaignId);
            if (!($sync['ok'] ?? false)) {
                throw new RuntimeException((string)($sync['reason'] ?? 'sync_failed'));
            }

            if ($ownTx) {
                $pdo->commit();
            }

            return [
                'ok'             => true,
                'payment_status' => ($mode === 'reserve' ? 'reserved' : 'paid'),
                'balance_after'  => (int)($res['balance_after'] ?? 0),
                'status'         => (string)($sync['status'] ?? ''),
            ];
        } catch (Throwable $e) {
            if ($ownTx && $pdo->inTransaction()) $pdo->rollBack();
            return ['ok' => false, 'reason' => 'db_error', 'error' => $e->getMessage()];
        }
    }
}

if (!function_exists('reclama_calc_unused_days')) {
    function reclama_calc_unused_days(array $campaign, ?string $today = null): int
    {
        $today = trim((string)$today);
        if ($today === '') $today = date('Y-m-d');

        $start = trim((string)($campaign['start_date'] ?? ''));
        $end   = trim((string)($campaign['end_date'] ?? ''));

        if ($start === '' || $end === '') return 0;

        if ($today < $start) {
            return reclama_calc_days($start, $end);
        }

        if ($today > $end) {
            return 0;
        }

        try {
            $from = new DateTimeImmutable($today . ' 00:00:00');
            $to   = new DateTimeImmutable($end . ' 00:00:00');
        } catch (Throwable $e) {
            return 0;
        }

        if ($to <= $from) return 0;

        $diff = $from->diff($to);
        return (int)$diff->days;
    }
}

if (!function_exists('reclama_refund_unused')) {
    function reclama_refund_unused(PDO $pdo, array $campaign, ?string $today = null, string $reason = ''): array
    {
        $campaignId = (int)($campaign['id'] ?? 0);
        $userId     = (int)($campaign['user_id'] ?? 0);

        if ($campaignId <= 0 || $userId <= 0) {
            return ['ok' => false, 'reason' => 'bad_campaign'];
        }

        $paymentStatus = trim((string)($campaign['payment_status'] ?? 'unpaid'));
        if (!reclama_payment_is_settled($paymentStatus)) {
            return ['ok' => false, 'reason' => 'nothing_to_refund'];
        }

        $unusedDays  = reclama_calc_unused_days($campaign, $today);
        $pricePerDay = max(0, (int)($campaign['price_per_day'] ?? 0));
        $refund      = reclama_calc_total($unusedDays, $pricePerDay);

        if ($refund <= 0) {
            return ['ok' => false, 'reason' => 'nothing_to_refund'];
        }

        $reason = trim($reason);
        if ($reason === '') {
            $reason = 'Refund unused reclame period';
        }

        $opKey = 'reclama:refund:' . $campaignId;
        if (reclama_tx_op_exists($pdo, $opKey)) {
            return ['ok' => false, 'reason' => 'duplicate_refund'];
        }

        $meta = [
            'campaign_id'   => $campaignId,
            'unused_days'   => $unusedDays,
            'price_per_day' => $pricePerDay,
            'refund_amount' => $refund,
            'today'         => ($today !== null && trim($today) !== '') ? trim($today) : date('Y-m-d'),
        ];

        $ownTx = !$pdo->inTransaction();
        if ($ownTx) {
            $pdo->beginTransaction();
        }

        try {
            $res = reclama_apply_vmcoins_delta(
                $pdo,
                $userId,
                $refund,
                'reclama_refund',
                'reclama_campaign',
                $campaignId,
                null,
                $opKey,
                $reason . ' #' . $campaignId,
                $meta
            );

            if (!($res['ok'] ?? false)) {
                if ($ownTx && $pdo->inTransaction()) $pdo->rollBack();
                return $res;
            }

            $newPaymentStatus = ($refund >= max(0, (int)($campaign['total_price'] ?? 0)))
                ? 'refunded'
                : 'partial_refund';

            reclama_mark_payment_state($pdo, $campaignId, $newPaymentStatus, false);

            if ($ownTx) {
                $pdo->commit();
            }

            return [
                'ok'             => true,
                'refund_amount'  => $refund,
                'unused_days'    => $unusedDays,
                'payment_status' => $newPaymentStatus,
                'balance_after'  => (int)($res['balance_after'] ?? 0),
            ];
        } catch (Throwable $e) {
            if ($ownTx && $pdo->inTransaction()) $pdo->rollBack();
            return ['ok' => false, 'reason' => 'db_error', 'error' => $e->getMessage()];
        }
    }
}

/*
 |-----------------------------------------------------------
 | Delivery / stats logic lives in dedicated files.
 | They are required after common helpers above, so the new
 | implementations are used and old duplicates do not block
 | them via function_exists(...).
 |-----------------------------------------------------------
 */

$reclamaDeliveryFile = __DIR__ . '/_delivery.php';
if (is_file($reclamaDeliveryFile)) {
    require_once $reclamaDeliveryFile;
}
unset($reclamaDeliveryFile);

$reclamaStatsFile = __DIR__ . '/_stats.php';
if (is_file($reclamaStatsFile)) {
    require_once $reclamaStatsFile;
}
unset($reclamaStatsFile);