<?php
require_once __DIR__ . '/db.php';
// Prevent "headers already sent" issues from accidental whitespace/output before redirects.
if (!ob_get_level()) { ob_start(); }
if (session_status() !== PHP_SESSION_ACTIVE) { session_start(); }

// v2.9.16 optional module helpers. These only define functions and stay safe before DB install.
require_once __DIR__ . '/mj_email_helpers.php';
require_once __DIR__ . '/mj_deal_helpers.php';
require_once __DIR__ . '/mj_leads_helpers.php';
require_once __DIR__ . '/mj_update_review_helpers.php';
require_once __DIR__ . '/mj_deal_import_helpers.php';
require_once __DIR__ . '/mj_cart_helpers.php';
require_once __DIR__ . '/mj_v3_helpers.php';
require_once __DIR__ . '/mj_v3041_access_notifications_location.php';

function e($value): string { return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'); }
if (!function_exists('str_starts_with')) {
    function str_starts_with($haystack, $needle): bool { return $needle === '' || strpos((string)$haystack, (string)$needle) === 0; }
}
if (!function_exists('str_ends_with')) {
    function str_ends_with($haystack, $needle): bool { $haystack=(string)$haystack; $needle=(string)$needle; return $needle === '' || substr($haystack, -strlen($needle)) === $needle; }
}
function redirect(string $url) {
    // Safe redirect for pages that accidentally started output before processing.
    if (!headers_sent()) {
        header('Location: ' . $url);
    } else {
        $safe = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
        echo '<script>window.location.href=' . json_encode($url) . ';</script>';
        echo '<noscript><meta http-equiv="refresh" content="0;url=' . $safe . '"></noscript>';
    }
    exit;
}
function mj_app_base(): string {
    $cfg = mj_config();
    $base = trim((string)($cfg['base_url'] ?? ''));

    // Use saved Base URL only when it is a proper absolute URL or root-relative path.
    // This prevents broken links when installer value was entered as "localhost" without http://.
    if ($base !== '' && preg_match('#^https?://#i', $base)) {
        return rtrim($base, '/');
    }
    if ($base !== '' && str_starts_with($base, '/')) {
        return rtrim($base, '/');
    }

    // Auto-detect project folder for localhost/subfolder installs.
    // Examples:
    // /admin/index.php                    => ''
    // /mj-marketplace/admin/index.php     => '/mj-marketplace'
    // /mj-marketplace/install/index.php   => '/mj-marketplace'
    $script = str_replace('\\', '/', (string)($_SERVER['SCRIPT_NAME'] ?? ''));
    $dir = rtrim(str_replace('\\', '/', dirname($script)), '/');
    $dir = preg_replace('#/(admin|install|ajax|api|cron)$#', '', $dir);
    $dir = rtrim((string)$dir, '/');
    if ($dir === '/' || $dir === '.' || $dir === '') { return ''; }
    return $dir;
}
function base_url(string $path = ''): string {
    $base = mj_app_base();
    $path = ltrim($path, '/');
    if ($path === '') { return $base === '' ? '/' : $base . '/'; }
    return ($base === '' ? '' : $base) . '/' . $path;
}
function asset(string $path): string { return base_url('assets/' . ltrim($path, '/')); }
function asset_versioned(string $path): string {
    $clean = ltrim($path, '/');
    $file = __DIR__ . '/../assets/' . $clean;
    $v = is_file($file) ? filemtime($file) : time();
    return asset($clean) . '?v=' . $v;
}
function mj_is_active_url(string $file): string {
    return basename((string)($_SERVER['SCRIPT_NAME'] ?? '')) === $file ? 'active' : '';
}

function csrf_token(): string {
    if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); }
    return $_SESSION['csrf_token'];
}
function csrf_input(): string { return '<input type="hidden" name="csrf_token" value="' . e(csrf_token()) . '">'; }
function verify_csrf(): void {
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        $ok = isset($_POST['csrf_token'], $_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $_POST['csrf_token']);
        if (!$ok) { http_response_code(419); exit('Invalid form token. Please refresh and try again.'); }
    }
}

function slugify(string $text): string {
    $text = strtolower(trim($text));
    $text = preg_replace('/[^a-z0-9]+/', '-', $text);
    $text = trim((string)$text, '-');
    return $text ?: 'item';
}
function money($amount, string $currency = 'SGD'): string {
    return e($currency) . ' ' . number_format((float)$amount, 2);
}
function flash(string $type, string $message): void { $_SESSION['flash'][] = ['type'=>$type,'message'=>$message]; }
function show_flash(): void {
    if (empty($_SESSION['flash'])) { return; }
    foreach ($_SESSION['flash'] as $f) {
        $class = $f['type'] === 'error' ? 'alert-danger' : ($f['type'] === 'warning' ? 'alert-warning' : 'alert-success');
        echo '<div class="alert '.$class.'">'.e($f['message']).'</div>';
    }
    unset($_SESSION['flash']);
}

function current_user(): ?array {
    if (empty($_SESSION['user_id'])) { return null; }
    try { return one('SELECT * FROM mj_users WHERE id=? LIMIT 1', [(int)$_SESSION['user_id']]); }
    catch (Throwable $e) { return null; }
}
function require_user(): array {
    $u = current_user();
    if (!$u) { redirect('login.php'); }
    return $u;
}
function current_admin(): ?array {
    if (empty($_SESSION['admin_id'])) { return null; }
    try { return one('SELECT * FROM mj_admin_users WHERE id=? LIMIT 1', [(int)$_SESSION['admin_id']]); }
    catch (Throwable $e) { return null; }
}
function require_admin(): array {
    $a = current_admin();
    if (!$a) { redirect('index.php'); }
    return $a;
}

function is_super_admin(?array $admin = null): bool {
    $admin = $admin ?: current_admin();
    return $admin && strtolower((string)($admin['role'] ?? '')) === 'super_admin';
}
function require_super_admin(): array {
    $admin = require_admin();
    if (!is_super_admin($admin)) {
        flash('error', 'Super Admin access required.');
        redirect('dashboard.php');
    }
    return $admin;
}
function visitor_area(): array {
    $countryId = (int)($_COOKIE['mj_country_id'] ?? 0);
    $stateId = (int)($_COOKIE['mj_state_id'] ?? 0);
    $cityId = (int)($_COOKIE['mj_city_id'] ?? 0);
    $area = [
        'country_id' => $countryId,
        'state_id' => $stateId,
        'city_id' => $cityId,
        'country_iso2' => strtoupper((string)($_COOKIE['mj_country_iso2'] ?? '')),
        'country_name' => (string)($_COOKIE['mj_country_name'] ?? ''),
        'state_name' => (string)($_COOKIE['mj_state_name'] ?? ''),
        'city_name' => (string)($_COOKIE['mj_city_name'] ?? ''),
        'timezone' => (string)($_COOKIE['mj_timezone'] ?? ''),
        'latitude' => (string)($_COOKIE['mj_latitude'] ?? ''),
        'longitude' => (string)($_COOKIE['mj_longitude'] ?? ''),
    ];
    if (!$area['country_name'] && $countryId) {
        $c = country_by_id($countryId);
        if ($c) { $area['country_name'] = (string)$c['name']; $area['country_iso2'] = (string)$c['iso2']; }
    }
    return $area;
}
function visitor_area_label(): string {
    $a = visitor_area();
    $parts = array_filter([$a['city_name'] ?: null, $a['state_name'] ?: null, $a['country_name'] ?: null]);
    return $parts ? implode(', ', array_unique($parts)) : '';
}
function priority_order_sql(string $alias, array $area, string $featuredColumn = 'is_featured'): string {
    $alias = preg_replace('/[^a-zA-Z0-9_]/', '', $alias) ?: 'l';
    if (setting('auto_area_priority','1') === '0') {
        $featuredColumn = preg_replace('/[^a-zA-Z0-9_]/', '', $featuredColumn) ?: 'is_featured';
        return $alias . '.' . $featuredColumn . ' DESC, ' . $alias . '.id DESC';
    }
    $city = (int)($area['city_id'] ?? 0);
    $state = (int)($area['state_id'] ?? 0);
    $country = (int)($area['country_id'] ?? 0);
    $case = 'CASE ';
    if ($city > 0) { $case .= 'WHEN '.$alias.'.city_id='.$city.' THEN 0 '; }
    if ($state > 0) { $case .= 'WHEN '.$alias.'.state_id='.$state.' THEN 1 '; }
    if ($country > 0) { $case .= 'WHEN '.$alias.'.country_id='.$country.' THEN 2 '; }
    $case .= 'ELSE 3 END';
    $featuredColumn = preg_replace('/[^a-zA-Z0-9_]/', '', $featuredColumn) ?: 'is_featured';
    return $case . ', ' . $alias . '.' . $featuredColumn . ' DESC, ' . $alias . '.id DESC';
}
function safe_random_password(int $length = 12): string {
    $alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%';
    $out = '';
    for ($i=0; $i<$length; $i++) { $out .= $alphabet[random_int(0, strlen($alphabet)-1)]; }
    return $out;
}
function admin_can_manage_user(array $admin, array $user): bool {
    if (is_super_admin($admin)) { return true; }
    $adminCountry = (int)($admin['country_id'] ?? 0);
    return $adminCountry > 0 && $adminCountry === (int)($user['country_id'] ?? 0);
}
function safe_count(string $sql, array $params = []): int {
    try { $row = one($sql, $params); return (int)($row['c'] ?? 0); } catch (Throwable $e) { return 0; }
}
function table_exists(string $table): bool {
    $table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
    if ($table === '') return false;
    static $cache = [];
    if (array_key_exists($table, $cache)) { return (bool)$cache[$table]; }
    try {
        $row = one('SELECT COUNT(*) AS c FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? LIMIT 1', [$table]);
        return $cache[$table] = ((int)($row['c'] ?? 0)) > 0;
    } catch (Throwable $e) {
        try { $row = one('SHOW TABLES LIKE ?', [$table]); return $cache[$table] = (bool)$row; } catch (Throwable $e2) { return $cache[$table] = false; }
    }
}

function admin_log(string $action, string $table = '', int $rowId = 0, array $data = []): void {
    $adminId = (int)($_SESSION['admin_id'] ?? 0);
    try {
        q('INSERT INTO mj_admin_logs(admin_id,action,table_name,row_id,data_json,ip_address,created_at) VALUES(?,?,?,?,?,?,NOW())', [
            $adminId, $action, $table, $rowId, json_encode($data), $_SERVER['REMOTE_ADDR'] ?? ''
        ]);
    } catch (Throwable $e) {}
}

function upload_error_message(int $code): string {
    $map = [
        UPLOAD_ERR_INI_SIZE => 'The uploaded file is bigger than server upload_max_filesize.',
        UPLOAD_ERR_FORM_SIZE => 'The uploaded file is bigger than the form limit.',
        UPLOAD_ERR_PARTIAL => 'The uploaded file was only partially uploaded.',
        UPLOAD_ERR_NO_FILE => 'No file was uploaded.',
        UPLOAD_ERR_NO_TMP_DIR => 'Server temporary upload folder is missing.',
        UPLOAD_ERR_CANT_WRITE => 'Server could not write the uploaded file to disk.',
        UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the upload.',
    ];
    return $map[$code] ?? 'Unknown upload error code: '.$code;
}

function ensure_upload_dir(string $folder): array {
    $safeFolder = trim(preg_replace('/[^a-zA-Z0-9_\-\/]/', '', $folder), '/');
    if ($safeFolder === '') { $safeFolder = 'misc'; }

    $baseDir = realpath(__DIR__ . '/../assets');
    if (!$baseDir) {
        $baseCandidate = __DIR__ . '/../assets';
        if (!is_dir($baseCandidate) && !@mkdir($baseCandidate, 0775, true)) {
            throw new RuntimeException('Upload base folder /assets cannot be created. Please make project folder writable.');
        }
        $baseDir = realpath($baseCandidate);
    }

    $uploadsDir = $baseDir . DIRECTORY_SEPARATOR . 'uploads';
    if (!is_dir($uploadsDir) && !@mkdir($uploadsDir, 0775, true)) {
        throw new RuntimeException('Upload folder /assets/uploads cannot be created. Please create it manually and set permission 755 or 775.');
    }
    if (!is_writable($uploadsDir)) { @chmod($uploadsDir, 0775); }
    if (!is_writable($uploadsDir)) {
        throw new RuntimeException('Upload folder /assets/uploads is not writable. Please set permission to 755/775.');
    }

    $targetDir = $uploadsDir . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $safeFolder);
    if (!is_dir($targetDir) && !@mkdir($targetDir, 0775, true)) {
        throw new RuntimeException('Upload folder /assets/uploads/'.$safeFolder.' cannot be created. Please create it manually and set permission 755 or 775.');
    }
    if (!is_writable($targetDir)) { @chmod($targetDir, 0775); }
    if (!is_writable($targetDir)) {
        throw new RuntimeException('Upload folder /assets/uploads/'.$safeFolder.' is not writable. Please set permission to 755/775.');
    }

    // Prevent PHP execution inside upload folders on Apache. Safe if server ignores it.
    $ht = $uploadsDir . DIRECTORY_SEPARATOR . '.htaccess';
    if (!file_exists($ht)) {
        @file_put_contents($ht, "Options -Indexes\n<FilesMatch \"\\.(php|phtml|phar|cgi|pl|py|jsp|asp|aspx)$\">\nRequire all denied\n</FilesMatch>\n");
    }

    return [$targetDir, 'assets/uploads/'.$safeFolder];
}

function upload_image(string $field, string $folder): ?string {
    if (empty($_FILES[$field]) || empty($_FILES[$field]['name'])) { return null; }

    $err = (int)($_FILES[$field]['error'] ?? UPLOAD_ERR_NO_FILE);
    if ($err === UPLOAD_ERR_NO_FILE) { return null; }
    if ($err !== UPLOAD_ERR_OK) { throw new RuntimeException(upload_error_message($err)); }

    $tmp = $_FILES[$field]['tmp_name'] ?? '';
    if (!$tmp || !is_file($tmp)) { throw new RuntimeException('Uploaded temporary file not found. Check PHP upload_tmp_dir permission.'); }

    $size = (int)($_FILES[$field]['size'] ?? 0);
    if ($size <= 0) { throw new RuntimeException('Uploaded image is empty.'); }
    if ($size > 8 * 1024 * 1024) { throw new RuntimeException('Image max size is 8MB.'); }

    $originalName = (string)($_FILES[$field]['name'] ?? 'image');
    $originalExt = strtolower((string)pathinfo($originalName, PATHINFO_EXTENSION));
    $allowedExts = ['jpg' => 'jpg', 'jpeg' => 'jpg', 'png' => 'png', 'webp' => 'webp', 'gif' => 'gif'];
    if (!isset($allowedExts[$originalExt])) {
        throw new RuntimeException('Only JPG, PNG, WEBP or GIF images allowed. Detected extension: '.($originalExt ?: 'none'));
    }

    $allowedMime = [
        'image/jpeg' => 'jpg',
        'image/pjpeg' => 'jpg',
        'image/jpg' => 'jpg',
        'image/png' => 'png',
        'image/x-png' => 'png',
        'image/webp' => 'webp',
        'image/gif' => 'gif',
    ];
    $imageTypeMap = [];
    if (defined('IMAGETYPE_JPEG')) { $imageTypeMap[IMAGETYPE_JPEG] = 'jpg'; }
    if (defined('IMAGETYPE_PNG')) { $imageTypeMap[IMAGETYPE_PNG] = 'png'; }
    if (defined('IMAGETYPE_WEBP')) { $imageTypeMap[IMAGETYPE_WEBP] = 'webp'; }
    if (defined('IMAGETYPE_GIF')) { $imageTypeMap[IMAGETYPE_GIF] = 'gif'; }

    $mime = '';
    if (function_exists('finfo_open')) {
        $finfo = @finfo_open(FILEINFO_MIME_TYPE);
        if ($finfo) { $mime = strtolower(trim((string)@finfo_file($finfo, $tmp))); @finfo_close($finfo); }
    }
    if (($mime === '' || $mime === 'application/octet-stream') && function_exists('mime_content_type')) {
        $mime2 = strtolower(trim((string)@mime_content_type($tmp)));
        if ($mime2 !== '') { $mime = $mime2; }
    }

    $detectedExt = '';
    if (isset($allowedMime[$mime])) {
        $detectedExt = $allowedMime[$mime];
    }

    if ($detectedExt === '' && function_exists('exif_imagetype')) {
        $imgType = @exif_imagetype($tmp);
        if ($imgType !== false && isset($imageTypeMap[$imgType])) { $detectedExt = $imageTypeMap[$imgType]; }
    }

    if ($detectedExt === '' && function_exists('getimagesize')) {
        $info = @getimagesize($tmp);
        if (is_array($info)) {
            $imgType = (int)($info[2] ?? 0);
            if (isset($imageTypeMap[$imgType])) { $detectedExt = $imageTypeMap[$imgType]; }
            $infoMime = strtolower(trim((string)($info['mime'] ?? '')));
            if ($detectedExt === '' && isset($allowedMime[$infoMime])) { $detectedExt = $allowedMime[$infoMime]; }
        }
    }

    // Some hosts return "unknown" or application/octet-stream for normal JPG files.
    // Accept by extension only after getimagesize/exif could confirm it is a real image type.
    if ($detectedExt === '') {
        throw new RuntimeException('Only JPG, PNG, WEBP or GIF images allowed. Detected: '.($mime ?: 'unknown'));
    }

    [$dir, $publicBase] = ensure_upload_dir($folder);
    $original = pathinfo($originalName, PATHINFO_FILENAME);
    $original = slugify($original);
    if ($original === '') { $original = 'image'; }
    $name = date('YmdHis') . '_' . bin2hex(random_bytes(6)) . '_' . substr($original, 0, 50) . '.' . $detectedExt;
    $target = $dir . DIRECTORY_SEPARATOR . $name;

    $moved = false;
    if (is_uploaded_file($tmp)) { $moved = @move_uploaded_file($tmp, $target); }
    if (!$moved) { $moved = @rename($tmp, $target); }
    if (!$moved) { $moved = @copy($tmp, $target); }

    if (!$moved || !file_exists($target)) {
        throw new RuntimeException('Upload failed. Please check that /assets/uploads/'.trim($folder,'/').' is writable and PHP file_uploads is enabled.');
    }
    @chmod($target, 0644);

    return $publicBase . '/' . $name;
}


function country_by_iso2(?string $iso2): ?array {
    if (!$iso2) { return null; }
    return one('SELECT * FROM mj_countries WHERE UPPER(iso2)=UPPER(?) AND is_active=1 LIMIT 1', [$iso2]);
}
function country_by_id(int $id): ?array {
    if ($id <= 0) { return null; }
    try { return one('SELECT * FROM mj_countries WHERE id=? AND is_active=1 LIMIT 1', [$id]); }
    catch (Throwable $e) { return null; }
}
function state_by_slug(int $countryId, ?string $slug): ?array {
    if (!$countryId || !$slug) { return null; }
    return one('SELECT * FROM mj_states WHERE country_id=? AND slug=? AND is_active=1 LIMIT 1', [$countryId, $slug]);
}
function city_by_slug(int $countryId, ?string $slug, int $stateId = 0): ?array {
    if (!$countryId || !$slug) { return null; }
    if ($stateId > 0) {
        return one('SELECT * FROM mj_cities WHERE country_id=? AND state_id=? AND slug=? AND is_active=1 LIMIT 1', [$countryId, $stateId, $slug]);
    }
    return one('SELECT * FROM mj_cities WHERE country_id=? AND slug=? AND is_active=1 LIMIT 1', [$countryId, $slug]);
}
function is_public_homepage(): bool {
    $script = basename((string)($_SERVER['SCRIPT_NAME'] ?? ''));
    $uri = strtok((string)($_SERVER['REQUEST_URI'] ?? ''), '?');
    return $script === 'index.php' && preg_match('#/(index\.php)?$#', $uri);
}

function country_by_slug(?string $slug): ?array {
    if (!$slug) { return null; }
    try { return one('SELECT * FROM mj_countries WHERE slug=? AND is_active=1 LIMIT 1', [$slug]); }
    catch (Throwable $e) { return null; }
}
function country_path(array $country, string $path = ''): string {
    return base_url(trim($country['slug'], '/') . '/' . ltrim($path, '/'));
}
function country_time_now(array $country): string {
    $tz = $country['timezone_default'] ?: 'UTC';
    try { $dt = new DateTime('now', new DateTimeZone($tz)); } catch(Throwable $e) { $dt = new DateTime('now'); }
    return $dt->format('d M Y, h:i A') . ' ' . $tz;
}
function local_datetime(?string $datetime, ?string $timezone): string {
    if (!$datetime) { return ''; }
    $tz = $timezone ?: 'UTC';
    try { $dt = new DateTime($datetime, new DateTimeZone('UTC')); $dt->setTimezone(new DateTimeZone($tz)); }
    catch(Throwable $e) { return e($datetime); }
    return $dt->format('d M Y, h:i A') . ' ' . $tz;
}
function open_status(array $branch): string {
    $hours = json_decode((string)($branch['opening_hours_json'] ?? ''), true) ?: [];
    $tz = $branch['timezone'] ?: 'UTC';
    try { $now = new DateTime('now', new DateTimeZone($tz)); } catch(Throwable $e) { return 'Hours unavailable'; }
    $day = strtolower($now->format('D'));
    if (empty($hours[$day]) || empty($hours[$day]['open']) || empty($hours[$day]['close'])) { return 'Closed today'; }
    if (!empty($hours[$day]['closed'])) { return 'Closed today'; }
    $open = DateTime::createFromFormat('Y-m-d H:i', $now->format('Y-m-d') . ' ' . $hours[$day]['open'], new DateTimeZone($tz));
    $close = DateTime::createFromFormat('Y-m-d H:i', $now->format('Y-m-d') . ' ' . $hours[$day]['close'], new DateTimeZone($tz));
    if ($open && $close && $now >= $open && $now <= $close) { return 'Open now'; }
    return 'Closed now';
}

function setting(string $key, string $default=''): string {
    try {
        static $installed = null;
        if ($installed === null) { $installed = mj_is_installed(); }
        if (!$installed || !table_exists('mj_settings')) { return $default; }
        if (!isset($GLOBALS['mj_setting_cache']) || !is_array($GLOBALS['mj_setting_cache'])) { $GLOBALS['mj_setting_cache'] = []; }
        if (array_key_exists($key, $GLOBALS['mj_setting_cache'])) { return (string)$GLOBALS['mj_setting_cache'][$key]; }
        $row = one('SELECT setting_value FROM mj_settings WHERE setting_key=? LIMIT 1', [$key]);
        $value = (string)($row['setting_value'] ?? $default);
        $GLOBALS['mj_setting_cache'][$key] = $value;
        return $value;
    } catch (Throwable $e) { return $default; }
}
function module_enabled(string $code): bool {
    try {
        if (!mj_is_installed() || !table_exists('mj_site_modules')) { return true; }
        $row = one('SELECT is_enabled FROM mj_site_modules WHERE module_code=?', [$code]);
        return !$row || (int)$row['is_enabled'] === 1;
    } catch (Throwable $e) { return true; }
}
function seo_title(string $fallback): string { return e($fallback . ' | ' . setting('site_name', 'MJ Marketplace')); }

function safe_all_rows_public(string $sql, array $params = []): array {
    try { return all_rows($sql, $params); }
    catch (Throwable $e) { mj_frontend_runtime_error($e, $sql); return []; }
}
function safe_one_public(string $sql, array $params = []): ?array {
    try { return one($sql, $params); }
    catch (Throwable $e) { mj_frontend_runtime_error($e, $sql); return null; }
}
function mj_frontend_runtime_error(Throwable $e, string $context = ''): void {
    $msg = $e->getMessage();
    error_log('[MJ Marketplace Frontend] '.$msg.' '.$context.' URL='.(($_SERVER['HTTP_HOST'] ?? '').($_SERVER['REQUEST_URI'] ?? '')));
    if (function_exists('mj_log_error_event')) {
        try { mj_log_error_event([
            'error_type' => 'frontend_runtime',
            'error_message' => $msg,
            'error_file' => $e->getFile(),
            'error_line' => $e->getLine(),
            'stack_trace' => $e->getTraceAsString(),
            'priority' => 'urgent'
        ]); } catch (Throwable $ignore) {}
    }
}
function mj_public_safe_query_notice(): string {
    return 'Some homepage data is temporarily unavailable. Please check Admin > Error Centre or run the latest migration.';
}
function safe_count_public(string $sql, array $params = []): int {
    try { $row = one($sql, $params); return (int)($row['c'] ?? 0); }
    catch (Throwable $e) { mj_frontend_runtime_error($e, $sql); return 0; }
}

function mj_public_listing_status_sql(string $alias='l'): string {
    $alias = preg_replace('/[^a-zA-Z0-9_]/', '', $alias) ?: 'l';
    return $alias.".status IN ('active','pending','approved','published')";
}
function mj_public_company_status_sql(string $alias='co'): string {
    $alias = preg_replace('/[^a-zA-Z0-9_]/', '', $alias) ?: 'co';
    return $alias.".status IN ('active','pending','approved','published')";
}
function mj_soft_area_mode(): bool {
    return (int)($_GET['auto_country'] ?? 0) === 1 || (int)($_GET['area_priority'] ?? 0) === 1 || (int)($_GET['near_me'] ?? 0) === 1;
}
function mj_debug_public_notice(array $rows, string $type='records'): string {
    return $rows ? '' : 'No '.$type.' matched this filter. Check status approval, country/state/city filters, or import sample data.';
}


/* ===== MJ Ultimate helpers: alerts, errors, SEO, notifications, safe schema ===== */
if (!function_exists('mj_safe_json')) {
function mj_safe_json($value): string { return json_encode($value, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES) ?: '{}'; }
}
if (!function_exists('mj_column_exists')) {
function mj_column_exists(string $table, string $column): bool {
    $table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
    $column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
    if ($table === '' || $column === '') return false;
    static $cache = [];
    $key = $table;
    if (!isset($cache[$key])) {
        $cache[$key] = [];
        try {
            $rows = all_rows('SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?', [$table]);
            foreach ($rows as $r) { $cache[$key][strtolower($r['COLUMN_NAME'])] = true; }
        } catch (Throwable $e) {
            try {
                $rows = all_rows('SHOW COLUMNS FROM `'.$table.'`');
                foreach ($rows as $r) { $cache[$key][strtolower($r['Field'])] = true; }
            } catch (Throwable $e2) {}
        }
    }
    return isset($cache[$key][strtolower($column)]);
}
}
if (!function_exists('mj_filter_existing_columns')) {
function mj_filter_existing_columns(string $table, array $data): array {
    $out = [];
    foreach ($data as $k => $v) {
        if (mj_column_exists($table, (string)$k)) { $out[$k] = $v; }
    }
    return $out;
}
}
if (!function_exists('mj_admin_unread_alert_count')) {
function mj_admin_unread_alert_count(): int {
    try { return table_exists('mj_admin_alerts') ? safe_count("SELECT COUNT(*) c FROM mj_admin_alerts WHERE is_read=0") : 0; } catch(Throwable $e) { return 0; }
}
}
if (!function_exists('mj_admin_latest_alerts')) {
function mj_admin_latest_alerts(int $limit=7): array {
    try { return table_exists('mj_admin_alerts') ? all_rows("SELECT * FROM mj_admin_alerts ORDER BY is_read ASC, created_at DESC LIMIT ".max(1,min(20,$limit))) : []; } catch(Throwable $e) { return []; }
}
}
if (!function_exists('mj_create_admin_alert')) {
function mj_create_admin_alert(array $data): bool {
    try {
        if (!mj_is_installed() || !table_exists('mj_admin_alerts')) return false;
        $key = $data['alert_key'] ?? md5(($data['alert_type'] ?? 'system').($data['related_type'] ?? '').($data['related_id'] ?? '').($data['title'] ?? '').date('Y-m-d'));
        $exists = one('SELECT id FROM mj_admin_alerts WHERE alert_key=? LIMIT 1', [$key]);
        if ($exists) return false;
        q('INSERT INTO mj_admin_alerts(alert_key,alert_type,module_name,priority,title,message,action_url,action_label,country_id,city_id,related_type,related_id,target_role,created_at,expires_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(),?)', [
            $key, $data['alert_type'] ?? 'system', $data['module_name'] ?? null, $data['priority'] ?? 'normal',
            $data['title'] ?? 'Admin Alert', $data['message'] ?? '', $data['action_url'] ?? null, $data['action_label'] ?? null,
            $data['country_id'] ?? null, $data['city_id'] ?? null, $data['related_type'] ?? null, $data['related_id'] ?? null,
            $data['target_role'] ?? null, $data['expires_at'] ?? null
        ]);
        return true;
    } catch(Throwable $e) { return false; }
}
}
if (!function_exists('mj_log_error_event')) {
function mj_log_error_event(array $data): bool {
    try {
        if (!mj_is_installed() || !table_exists('mj_error_logs')) return false;
        $message = (string)($data['error_message'] ?? 'Unknown error');
        $file = (string)($data['error_file'] ?? '');
        $line = (int)($data['error_line'] ?? 0);
        $type = (string)($data['error_type'] ?? 'error');
        $hash = md5($type.'|'.$file.'|'.$line.'|'.substr($message,0,300));
        $url = (($_SERVER['HTTP_HOST'] ?? '').($_SERVER['REQUEST_URI'] ?? ''));
        $exists = one('SELECT id, occurrence_count FROM mj_error_logs WHERE error_hash=? LIMIT 1', [$hash]);
        if ($exists) {
            q('UPDATE mj_error_logs SET occurrence_count=occurrence_count+1,last_seen_at=NOW(),request_url=? WHERE id=?', [$url, (int)$exists['id']]);
            return true;
        }
        q('INSERT INTO mj_error_logs(error_hash,error_type,error_level,error_message,error_file,error_line,request_url,request_method,referrer_url,user_id,admin_id,ip_address,user_agent,post_data,get_data,session_data,stack_trace,priority,created_at,last_seen_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(),NOW())', [
            $hash,$type,(string)($data['error_level'] ?? ''),$message,$file,$line,$url,$_SERVER['REQUEST_METHOD'] ?? '',$_SERVER['HTTP_REFERER'] ?? '',
            $_SESSION['user_id'] ?? null,$_SESSION['admin_id'] ?? null,$_SERVER['REMOTE_ADDR'] ?? '',$_SERVER['HTTP_USER_AGENT'] ?? '',
            empty($_POST)?'':mj_safe_json($_POST),empty($_GET)?'':mj_safe_json($_GET),'', (string)($data['stack_trace'] ?? ''), $data['priority'] ?? 'high'
        ]);
        mj_create_admin_alert([
            'alert_key'=>'error_'.$hash,
            'alert_type'=>'system_error','module_name'=>'error_monitor','priority'=>$data['priority'] ?? 'high',
            'title'=>'Page error detected','message'=>$type.': '.$message.' on '.$url,
            'action_url'=>'error-center.php','action_label'=>'View Error','related_type'=>'error'
        ]);
        return true;
    } catch(Throwable $e) { return false; }
}
}
if (!function_exists('mj_slug_unique')) {
function mj_slug_unique(string $table, string $title, int $ignoreId=0): string {
    $base = slugify($title); $slug=$base; $i=2; $tableSafe=preg_replace('/[^a-zA-Z0-9_]/','',$table);
    try {
        while(true){
            $params=[$slug]; $sql="SELECT id FROM `$tableSafe` WHERE slug=?";
            if($ignoreId>0){ $sql.=' AND id<>?'; $params[]=$ignoreId; }
            $sql.=' LIMIT 1';
            if(!one($sql,$params)) return $slug;
            $slug=$base.'-'.$i; $i++;
        }
    } catch(Throwable $e) { return $slug; }
}
}
if (!function_exists('mj_module_count')) {
function mj_module_count(string $table, string $where='1=1'): int { return table_exists($table) ? safe_count("SELECT COUNT(*) c FROM `$table` WHERE $where") : 0; }
}
if (!function_exists('mj_register_error_handlers')) {
function mj_register_error_handlers(): void {
    static $on=false; if($on) return; $on=true;
    set_error_handler(function($severity,$message,$file,$line){
        if (!(error_reporting() & $severity)) return false;
        mj_log_error_event(['error_type'=>'php_error','error_level'=>$severity,'error_message'=>$message,'error_file'=>$file,'error_line'=>$line,'priority'=>in_array($severity,[E_WARNING,E_USER_WARNING],true)?'normal':'low']);
        return false;
    });
    register_shutdown_function(function(){
        $e=error_get_last();
        if($e && in_array($e['type'], [E_ERROR,E_PARSE,E_CORE_ERROR,E_COMPILE_ERROR], true)){
            mj_log_error_event(['error_type'=>'php_fatal','error_level'=>$e['type'],'error_message'=>$e['message'],'error_file'=>$e['file'],'error_line'=>$e['line'],'priority'=>'critical']);
        }
    });
}
}
@mj_register_error_handlers();

/* ===== v2.3 Robust public visibility helpers ===== */
if (!function_exists('mj_table_columns')) {
function mj_table_columns(string $table): array {
    static $cache = [];
    $safe = preg_replace('/[^a-zA-Z0-9_]/','',$table);
    if ($safe === '') { return []; }
    if (isset($cache[$safe])) { return $cache[$safe]; }
    try {
        $rows = all_rows("SHOW COLUMNS FROM `{$safe}`");
        $cols = [];
        foreach ($rows as $r) { if (!empty($r['Field'])) { $cols[$r['Field']] = true; } }
        return $cache[$safe] = $cols;
    } catch (Throwable $e) { return $cache[$safe] = []; }
}
}
if (!function_exists('mj_has_col')) {
function mj_has_col(string $table, string $col): bool { $cols = mj_table_columns($table); return isset($cols[$col]); }
}
if (!function_exists('mj_select_col')) {
function mj_select_col(string $table, string $alias, string $col, string $fallback = "''", ?string $as = null): string {
    $a = preg_replace('/[^a-zA-Z0-9_]/','',$alias) ?: 't';
    $c = preg_replace('/[^a-zA-Z0-9_]/','',$col);
    $out = preg_replace('/[^a-zA-Z0-9_]/','',$as ?: $col);
    return (mj_has_col($table, $c) ? "{$a}.`{$c}`" : $fallback) . " AS `{$out}`";
}
}
if (!function_exists('mj_public_status_condition')) {
function mj_public_status_condition(string $table, string $alias): string {
    if (!mj_has_col($table, 'status')) { return '1=1'; }
    $a = preg_replace('/[^a-zA-Z0-9_]/','',$alias) ?: 't';
    // Public should not disappear because admin stored pending/approved/active/published.
    // Only clearly hidden statuses are excluded.
    return "({$a}.status IS NULL OR {$a}.status='' OR {$a}.status NOT IN ('rejected','suspended','deleted','archived'))";
}
}
if (!function_exists('mj_priority_order_public')) {
function mj_priority_order_public(string $table, string $alias, array $area): string {
    $a = preg_replace('/[^a-zA-Z0-9_]/','',$alias) ?: 't';
    $case = 'CASE ';
    if (mj_has_col($table,'city_id') && (int)($area['city_id'] ?? 0) > 0) { $case .= 'WHEN '.$a.'.city_id='.(int)$area['city_id'].' THEN 0 '; }
    if (mj_has_col($table,'state_id') && (int)($area['state_id'] ?? 0) > 0) { $case .= 'WHEN '.$a.'.state_id='.(int)$area['state_id'].' THEN 1 '; }
    if (mj_has_col($table,'country_id') && (int)($area['country_id'] ?? 0) > 0) { $case .= 'WHEN '.$a.'.country_id='.(int)$area['country_id'].' THEN 2 '; }
    $case .= 'ELSE 3 END';
    $extra = '';
    if (mj_has_col($table,'is_sponsored')) { $extra .= ', '.$a.'.is_sponsored DESC'; }
    if (mj_has_col($table,'is_featured')) { $extra .= ', '.$a.'.is_featured DESC'; }
    if (mj_has_col($table,'updated_at')) { $extra .= ', '.$a.'.updated_at DESC'; }
    return $case.$extra.', '.$a.'.id DESC';
}
}
if (!function_exists('mj_public_pick')) {
function mj_public_pick(array $row, array $keys, $default = '') {
    foreach ($keys as $k) {
        if (array_key_exists($k, $row) && $row[$k] !== null && $row[$k] !== '') { return $row[$k]; }
    }
    return $default;
}
}
if (!function_exists('mj_public_enrich_location')) {
function mj_public_enrich_location(array $row): array {
    if (empty($row['country']) && !empty($row['country_id']) && table_exists('mj_countries')) {
        $c = safe_one_public('SELECT name,currency_code,timezone_default,flag_emoji FROM mj_countries WHERE id=? LIMIT 1', [(int)$row['country_id']]);
        if ($c) {
            $row['country'] = $c['name'] ?? '';
            if (empty($row['currency_code']) && !empty($c['currency_code'])) { $row['currency_code'] = $c['currency_code']; }
            if (empty($row['timezone']) && !empty($c['timezone_default'])) { $row['timezone'] = $c['timezone_default']; }
            $row['flag_emoji'] = $c['flag_emoji'] ?? '';
        }
    }
    if (empty($row['city']) && !empty($row['city_id']) && table_exists('mj_cities')) {
        $ci = safe_one_public('SELECT name,timezone FROM mj_cities WHERE id=? LIMIT 1', [(int)$row['city_id']]);
        if ($ci) {
            $row['city'] = $ci['name'] ?? '';
            if (empty($row['timezone']) && !empty($ci['timezone'])) { $row['timezone'] = $ci['timezone']; }
        }
    }
    if (empty($row['category']) && !empty($row['category_id']) && table_exists('mj_categories')) {
        $cat = safe_one_public('SELECT name FROM mj_categories WHERE id=? LIMIT 1', [(int)$row['category_id']]);
        if ($cat) { $row['category'] = $cat['name'] ?? ''; }
    }
    return $row;
}
}
if (!function_exists('mj_public_simple_order')) {
function mj_public_simple_order(string $table): string {
    $order = [];
    foreach (['sort_priority','is_sponsored','is_featured','is_verified','is_urgent'] as $col) {
        if (mj_has_col($table, $col)) { $order[] = '`'.$col.'` DESC'; }
    }
    foreach (['updated_at','published_at','created_at'] as $col) {
        if (mj_has_col($table, $col)) { $order[] = '`'.$col.'` DESC'; break; }
    }
    $order[] = 'id DESC';
    return implode(', ', $order);
}
}
if (!function_exists('mj_public_normalize_listings')) {
function mj_public_normalize_listings(array $rows): array {
    foreach ($rows as &$r) {
        $r['title'] = mj_public_pick($r, ['title','name'], 'Untitled');
        $r['slug'] = mj_public_pick($r, ['slug'], (string)($r['id'] ?? ''));
        $r['description'] = mj_public_pick($r, ['description'], '');
        $r['price'] = mj_public_pick($r, ['price','amount'], 0);
        $r['currency_code'] = mj_public_pick($r, ['currency_code','currency'], 'USD');
        $r['condition_label'] = mj_public_pick($r, ['condition_label','condition','listing_type'], 'Listing');
        $r['listing_type'] = mj_public_pick($r, ['listing_type'], 'Listing');
        $r['status'] = mj_public_pick($r, ['status'], 'active');
        $r['timezone'] = mj_public_pick($r, ['timezone'], 'UTC');
        $r['created_at'] = mj_public_pick($r, ['created_at','published_at','updated_at'], date('Y-m-d H:i:s'));
        $r['is_featured'] = (int)mj_public_pick($r, ['is_featured'], 0);
        $r['is_urgent'] = (int)mj_public_pick($r, ['is_urgent'], 0);
        $r = mj_public_enrich_location($r);
        if (empty($r['image_path']) && !empty($r['id'])) {
            try {
                $img = one('SELECT image_path FROM mj_listing_images WHERE listing_id=? ORDER BY sort_order,id LIMIT 1', [(int)$r['id']]);
                $r['image_path'] = $img['image_path'] ?? '';
            } catch (Throwable $e) { $r['image_path'] = ''; }
        }
    }
    unset($r);
    return $rows;
}
}
if (!function_exists('mj_public_normalize_companies')) {
function mj_public_normalize_companies(array $rows): array {
    foreach ($rows as &$r) {
        $r['name'] = mj_public_pick($r, ['name','company_name','entity_name'], 'Company');
        $r['slug'] = mj_public_pick($r, ['slug'], (string)($r['id'] ?? ''));
        $r['description'] = mj_public_pick($r, ['description'], '');
        $r['phone'] = mj_public_pick($r, ['phone','contact_number','mobile'], '');
        $r['whatsapp'] = mj_public_pick($r, ['whatsapp','mobile'], '');
        $r['email'] = mj_public_pick($r, ['email'], '');
        $r['website'] = mj_public_pick($r, ['website','url'], '');
        $r['address'] = mj_public_pick($r, ['address','address_text'], '');
        $r['logo'] = mj_public_pick($r, ['logo','logo_url'], '');
        $r['cover_image'] = mj_public_pick($r, ['cover_image','photo_url'], '');
        $r['status'] = mj_public_pick($r, ['status'], 'active');
        $r['is_verified'] = (int)mj_public_pick($r, ['is_verified'], 0);
        $r['is_featured'] = (int)mj_public_pick($r, ['is_featured'], 0);
        $r['rating_avg'] = mj_public_pick($r, ['rating_avg'], 0);
        $r['rating_count'] = mj_public_pick($r, ['rating_count'], 0);
        $r['timezone'] = mj_public_pick($r, ['timezone'], 'UTC');
        $r = mj_public_enrich_location($r);
    }
    unset($r);
    return $rows;
}
}
if (!function_exists('mj_public_fetch_listings')) {
function mj_public_fetch_listings(array $filters = [], int $limit = 60): array {
    $limit = max(1, min(120, $limit));
    mj_ensure_listing_module_schema(false);
    $qstr = trim((string)($filters['q'] ?? ''));
    $where = []; $params = [];
    // Public visibility: hide drafts, pending, rejected, inactive, expired, deleted unless old data has blank status.
    if (mj_has_col('mj_listings','status')) {
        $where[] = "(status IS NULL OR status='' OR status IN ('active','approved','published','sold','reserved'))";
    }
    if (mj_has_col('mj_listings','is_deleted')) { $where[] = "(is_deleted IS NULL OR is_deleted=0)"; }
    if (mj_has_col('mj_listings','deleted_at')) { $where[] = "deleted_at IS NULL"; }
    if (mj_has_col('mj_listings','expires_at')) { $where[] = "(expires_at IS NULL OR expires_at='' OR expires_at>=NOW())"; }
    if (!empty($filters['country_id']) && mj_has_col('mj_listings','country_id')) { $where[]='country_id=?'; $params[]=(int)$filters['country_id']; }
    if (!empty($filters['state_id']) && empty($filters['soft_area']) && mj_has_col('mj_listings','state_id')) { $where[]='state_id=?'; $params[]=(int)$filters['state_id']; }
    if (!empty($filters['city_id']) && empty($filters['soft_area']) && mj_has_col('mj_listings','city_id')) { $where[]='city_id=?'; $params[]=(int)$filters['city_id']; }
    if (!empty($filters['category_id']) && mj_has_col('mj_listings','category_id')) { $where[]='category_id=?'; $params[]=(int)$filters['category_id']; }
    if (!empty($filters['company_id']) && mj_has_col('mj_listings','company_id')) { $where[]='company_id=?'; $params[]=(int)$filters['company_id']; }
    if (!empty($filters['type']) && mj_has_col('mj_listings','listing_type')) { $where[]='listing_type=?'; $params[]=(string)$filters['type']; }
    if ($qstr !== '') {
        $parts=[]; $like='%'.$qstr.'%';
        foreach(['title','description','brand','model','condition_label','sponsor_keywords','search_boost_keywords'] as $col) {
            if (mj_has_col('mj_listings',$col)) { $parts[]="`{$col}` LIKE ?"; $params[]=$like; }
        }
        if ($parts) { $where[]='('.implode(' OR ', $parts).')'; }
    }
    $sql='SELECT * FROM mj_listings'.($where?' WHERE '.implode(' AND ',$where):'').' ORDER BY id DESC LIMIT '.max($limit,60);
    $rows=[];
    try { $rows=all_rows($sql,$params); } catch(Throwable $e) { mj_frontend_runtime_error($e,'listing module public filtered'); }
    if (!$rows && !empty($filters['country_id'])) {
        try { $rows=all_rows('SELECT * FROM mj_listings WHERE country_id=? ORDER BY id DESC LIMIT '.$limit,[(int)$filters['country_id']]); } catch(Throwable $e) { mj_frontend_runtime_error($e,'listing module public country raw'); }
    }
    if (!$rows) {
        try { $rows=all_rows('SELECT * FROM mj_listings ORDER BY id DESC LIMIT '.$limit); } catch(Throwable $e) { mj_frontend_runtime_error($e,'listing module public all raw'); }
    }
    $rows = mj_public_normalize_listings($rows ?: []);
    $rows = array_values(array_filter($rows, 'mj_listing_is_public_visible'));
    usort($rows, function($a,$b) use($filters,$qstr){ return mj_listing_public_compare($a,$b,$filters,$qstr); });
    return array_slice($rows,0,$limit);
}
}
if (!function_exists('mj_public_fetch_companies')) {
function mj_public_fetch_companies(array $filters = [], int $limit = 60): array {
    $limit = max(1, min(120, $limit));
    $order = 'id DESC';
    try { $order = mj_public_simple_order('mj_companies'); } catch (Throwable $e) {}
    $qstr = trim((string)($filters['q'] ?? ''));
    $where=[]; $params=[];
    try { if (mj_has_col('mj_companies','status')) { $where[] = "(status IS NULL OR status='' OR status NOT IN ('rejected','suspended','deleted','archived'))"; } } catch (Throwable $e) {}
    try { if (!empty($filters['country_id']) && mj_has_col('mj_companies','country_id')) { $where[]='country_id=?'; $params[]=(int)$filters['country_id']; } } catch (Throwable $e) {}
    try { if (!empty($filters['state_id']) && empty($filters['soft_area']) && mj_has_col('mj_companies','state_id')) { $where[]='state_id=?'; $params[]=(int)$filters['state_id']; } } catch (Throwable $e) {}
    try { if (!empty($filters['city_id']) && empty($filters['soft_area']) && mj_has_col('mj_companies','city_id')) { $where[]='city_id=?'; $params[]=(int)$filters['city_id']; } } catch (Throwable $e) {}
    try { if (!empty($filters['category_id']) && mj_has_col('mj_companies','category_id')) { $where[]='category_id=?'; $params[]=(int)$filters['category_id']; } } catch (Throwable $e) {}
    try { if (!empty($filters['verified']) && mj_has_col('mj_companies','is_verified')) { $where[]='is_verified=1'; } } catch (Throwable $e) {}
    if ($qstr !== '') {
        try {
            $parts=[]; $like='%'.$qstr.'%';
            foreach(['name','description','address','phone','email','website','whatsapp'] as $col) { if (mj_has_col('mj_companies',$col)) { $parts[]="`{$col}` LIKE ?"; $params[]=$like; } }
            if ($parts) { $where[]='('.implode(' OR ', $parts).')'; }
        } catch (Throwable $e) {}
    }

    $rows=[];
    try { $rows = all_rows('SELECT * FROM mj_companies'.($where?' WHERE '.implode(' AND ',$where):'').' ORDER BY '.$order.' LIMIT '.$limit, $params); } catch (Throwable $e) { mj_frontend_runtime_error($e, 'mj_public_fetch_companies filtered'); }
    if (!$rows && !empty($filters['country_id'])) {
        try { $rows = all_rows('SELECT * FROM mj_companies WHERE country_id=? ORDER BY id DESC LIMIT '.$limit, [(int)$filters['country_id']]); } catch (Throwable $e) { mj_frontend_runtime_error($e, 'mj_public_fetch_companies country raw'); }
    }
    if (!$rows) {
        try { $rows = all_rows('SELECT * FROM mj_companies ORDER BY id DESC LIMIT '.$limit); } catch (Throwable $e) { mj_frontend_runtime_error($e, 'mj_public_fetch_companies all raw'); }
    }
    return mj_public_normalize_companies($rows ?: []);
}
}

/* ===== MJ Marketplace v2.6 Listing Module Pro helpers ===== */
if (!function_exists('mj_has_col')) {
function mj_has_col(string $table, string $column): bool { return mj_column_exists($table, $column); }
}
if (!function_exists('mj_ensure_listing_module_schema')) {
function mj_ensure_listing_module_schema(bool $write = true): void {
    static $done=false; if($done || !$write && !mj_is_installed()) return; $done=true;
    if (!mj_is_installed() || !table_exists('mj_listings')) return;
    $cols = [
        'expires_at'=>'DATETIME NULL','expired_at'=>'DATETIME NULL','approved_at'=>'DATETIME NULL','approved_by'=>'INT NULL',
        'rejected_at'=>'DATETIME NULL','rejected_by'=>'INT NULL','reject_reason'=>'TEXT NULL','inactive_at'=>'DATETIME NULL','inactive_by'=>'INT NULL','reactivated_at'=>'DATETIME NULL',
        'deleted_at'=>'DATETIME NULL','deleted_by'=>'INT NULL','is_deleted'=>'TINYINT(1) NOT NULL DEFAULT 0',
        'is_featured'=>'TINYINT(1) NOT NULL DEFAULT 0','featured_until'=>'DATETIME NULL','featured_priority'=>'INT NOT NULL DEFAULT 0',
        'is_sponsored'=>'TINYINT(1) NOT NULL DEFAULT 0','sponsored_until'=>'DATETIME NULL','sponsored_priority'=>'INT NOT NULL DEFAULT 0',
        'sponsor_keywords'=>'TEXT NULL','search_boost_keywords'=>'TEXT NULL','sort_priority'=>'INT NOT NULL DEFAULT 0','seo_title'=>'VARCHAR(255) NULL',
        'seo_description'=>'TEXT NULL','admin_note'=>'TEXT NULL','listing_quality_score'=>'INT NOT NULL DEFAULT 0'
    ];
    foreach($cols as $col=>$def){ if(!mj_column_exists('mj_listings',$col)){ try{ q("ALTER TABLE mj_listings ADD COLUMN `$col` $def"); }catch(Throwable $e){} } }
    try{ q("CREATE TABLE IF NOT EXISTS mj_listing_revisions (id INT AUTO_INCREMENT PRIMARY KEY, listing_id INT NOT NULL, user_id INT NULL, old_data_json LONGTEXT NULL, new_data_json LONGTEXT NULL, status VARCHAR(40) NOT NULL DEFAULT 'pending', admin_id INT NULL, admin_note TEXT NULL, reject_reason TEXT NULL, submitted_at DATETIME DEFAULT CURRENT_TIMESTAMP, reviewed_at DATETIME NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX(listing_id), INDEX(status), INDEX(user_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); }catch(Throwable $e){}
    try{ q("CREATE TABLE IF NOT EXISTS mj_listing_logs (id INT AUTO_INCREMENT PRIMARY KEY, listing_id INT NOT NULL, user_id INT NULL, admin_id INT NULL, action VARCHAR(100) NOT NULL, old_data_json LONGTEXT NULL, new_data_json LONGTEXT NULL, ip_address VARCHAR(80) NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX(listing_id), INDEX(action), INDEX(created_at)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); }catch(Throwable $e){}
    try{ q("CREATE TABLE IF NOT EXISTS mj_listing_expiry_logs (id INT AUTO_INCREMENT PRIMARY KEY, listing_id INT NOT NULL, old_expires_at DATETIME NULL, new_expires_at DATETIME NULL, action VARCHAR(80) NOT NULL, admin_id INT NULL, user_id INT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX(listing_id), INDEX(created_at)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); }catch(Throwable $e){}
}
}
if (!function_exists('mj_listing_log')) {
function mj_listing_log(int $listingId, string $action, array $old=[], array $new=[]): void {
    try { if(!table_exists('mj_listing_logs')) return; q('INSERT INTO mj_listing_logs(listing_id,user_id,admin_id,action,old_data_json,new_data_json,ip_address,created_at) VALUES(?,?,?,?,?,?,?,NOW())', [$listingId, $_SESSION['user_id'] ?? null, $_SESSION['admin_id'] ?? null, $action, mj_safe_json($old), mj_safe_json($new), $_SERVER['REMOTE_ADDR'] ?? '']); } catch(Throwable $e) {}
}
}
if (!function_exists('mj_listing_default_expiry')) {
function mj_listing_default_expiry(): string { $days=max(1,(int)setting('listing_default_expiry_days','30')); return date('Y-m-d H:i:s', strtotime('+'.$days.' days')); }
}
if (!function_exists('mj_listing_is_public_visible')) {
function mj_listing_is_public_visible(array $r): bool {
    if ((int)($r['is_deleted'] ?? 0) === 1 || !empty($r['deleted_at'])) return false;
    $status = strtolower(trim((string)($r['status'] ?? 'active')));
    if (in_array($status, ['pending','draft','rejected','inactive','expired','deleted','archived','suspended'], true)) return false;
    if (!empty($r['expires_at']) && strtotime((string)$r['expires_at']) < time()) return false;
    return true;
}
}
if (!function_exists('mj_listing_active_sponsored')) {
function mj_listing_active_sponsored(array $r): bool { return (int)($r['is_sponsored'] ?? 0) === 1 && (empty($r['sponsored_until']) || strtotime((string)$r['sponsored_until']) >= time()); }
}
if (!function_exists('mj_listing_active_featured')) {
function mj_listing_active_featured(array $r): bool { return (int)($r['is_featured'] ?? 0) === 1 && (empty($r['featured_until']) || strtotime((string)$r['featured_until']) >= time()); }
}
if (!function_exists('mj_listing_keyword_score')) {
function mj_listing_keyword_score(array $r, string $q): int {
    $q = strtolower(trim($q)); if($q==='') return 0;
    $hay = strtolower(implode(' ', [ $r['title'] ?? '', $r['description'] ?? '', $r['category'] ?? '', $r['sponsor_keywords'] ?? '', $r['search_boost_keywords'] ?? '' ]));
    $score = 0;
    foreach(preg_split('/\s+/', $q) as $w){ if($w !== '' && strpos($hay,$w) !== false) $score += 10; }
    if (strpos($hay,$q) !== false) $score += 30;
    return $score;
}
}
if (!function_exists('mj_listing_area_score')) {
function mj_listing_area_score(array $r, array $filters): int {
    $area = $filters['area'] ?? visitor_area(); $score = 0;
    if (!empty($area['city_id']) && (int)($r['city_id'] ?? 0)===(int)$area['city_id']) $score += 30;
    if (!empty($area['state_id']) && (int)($r['state_id'] ?? 0)===(int)$area['state_id']) $score += 20;
    if (!empty($area['country_id']) && (int)($r['country_id'] ?? 0)===(int)$area['country_id']) $score += 10;
    return $score;
}
}
if (!function_exists('mj_listing_public_rank')) {
function mj_listing_public_rank(array $r, array $filters=[], string $q=''): int {
    $rank = 0;
    if (mj_listing_active_sponsored($r)) $rank += 100000 + ((int)($r['sponsored_priority'] ?? 0) * 100);
    if (mj_listing_active_featured($r)) $rank += 50000 + ((int)($r['featured_priority'] ?? 0) * 80);
    $rank += (int)($r['sort_priority'] ?? 0) * 50;
    $rank += mj_listing_area_score($r,$filters) * 10;
    $rank += mj_listing_keyword_score($r,$q) * 5;
    $rank += min(500, (int)($r['views'] ?? 0));
    return $rank;
}
}
if (!function_exists('mj_listing_public_compare')) {
function mj_listing_public_compare(array $a, array $b, array $filters=[], string $q=''): int {
    $ra = mj_listing_public_rank($a,$filters,$q); $rb = mj_listing_public_rank($b,$filters,$q);
    if ($ra === $rb) return strtotime((string)($b['updated_at'] ?? $b['created_at'] ?? 'now')) <=> strtotime((string)($a['updated_at'] ?? $a['created_at'] ?? 'now'));
    return $rb <=> $ra;
}
}
if (!function_exists('mj_listing_status_badge')) {
function mj_listing_status_badge(string $status): string {
    $s=strtolower($status); $map=['active'=>'success','approved'=>'success','published'=>'success','pending'=>'warning','draft'=>'secondary','inactive'=>'dark','rejected'=>'danger','expired'=>'secondary','sold'=>'info','reserved'=>'primary','deleted'=>'danger'];
    return $map[$s] ?? 'light';
}
}
if (!function_exists('mj_listing_quality_score')) {
function mj_listing_quality_score(array $r): int {
    $score = 0;
    if (trim((string)($r['title'] ?? '')) !== '') $score += 20;
    if (strlen(trim((string)($r['description'] ?? ''))) >= 40) $score += 25;
    if ((float)($r['price'] ?? 0) > 0) $score += 15;
    if (!empty($r['category_id'])) $score += 10;
    if (!empty($r['country_id'])) $score += 10;
    if (!empty($r['image_path'])) $score += 20;
    return min(100,$score);
}
}


/* ===== MJ Marketplace v2.6.3 Listing Module Advanced helpers ===== */
if (!function_exists('mj263_schema')) {
function mj263_schema(): void {
    if (!mj_is_installed() || !table_exists('mj_listings')) return;
    $cols = [
        'views'=>'INT NOT NULL DEFAULT 0','unique_views'=>'INT NOT NULL DEFAULT 0','engagement_count'=>'INT NOT NULL DEFAULT 0','offer_count'=>'INT NOT NULL DEFAULT 0','chat_count'=>'INT NOT NULL DEFAULT 0','save_count'=>'INT NOT NULL DEFAULT 0','share_count'=>'INT NOT NULL DEFAULT 0','phone_click_count'=>'INT NOT NULL DEFAULT 0','whatsapp_click_count'=>'INT NOT NULL DEFAULT 0','last_viewed_at'=>'DATETIME NULL','last_engaged_at'=>'DATETIME NULL','old_price'=>'DECIMAL(12,2) NULL','min_offer_price'=>'DECIMAL(12,2) NULL','is_negotiable'=>'TINYINT(1) NOT NULL DEFAULT 1','correction_reason'=>'TEXT NULL','availability_status'=>'VARCHAR(80) NULL','delivery_options'=>'TEXT NULL','contact_options'=>'TEXT NULL','short_code'=>'VARCHAR(80) NULL'
    ];
    foreach($cols as $col=>$def){ try{ if(!mj_column_exists('mj_listings',$col)){ q("ALTER TABLE mj_listings ADD COLUMN `$col` $def"); } }catch(Throwable $e){} }
    try{ q("CREATE TABLE IF NOT EXISTS mj_listing_revisions (id INT AUTO_INCREMENT PRIMARY KEY, listing_id INT NOT NULL, user_id INT NULL, old_data_json LONGTEXT NULL, new_data_json LONGTEXT NULL, old_images_json LONGTEXT NULL, new_images_json LONGTEXT NULL, deleted_images_json LONGTEXT NULL, status VARCHAR(40) NOT NULL DEFAULT 'pending', admin_id INT NULL, admin_note TEXT NULL, reject_reason TEXT NULL, submitted_at DATETIME DEFAULT CURRENT_TIMESTAMP, reviewed_at DATETIME NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX(listing_id), INDEX(status), INDEX(user_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); }catch(Throwable $e){}
    try{ q("CREATE TABLE IF NOT EXISTS mj_listing_renewal_requests (id INT AUTO_INCREMENT PRIMARY KEY, listing_id INT NOT NULL, user_id INT NOT NULL, current_expires_at DATETIME NULL, requested_days INT NOT NULL DEFAULT 30, requested_expires_at DATETIME NULL, status VARCHAR(50) NOT NULL DEFAULT 'pending', payment_required TINYINT(1) NOT NULL DEFAULT 0, payment_amount DECIMAL(12,2) NULL, payment_currency VARCHAR(10) NULL, payment_method VARCHAR(80) NULL, payment_status VARCHAR(50) NULL, payment_proof_id INT NULL, admin_id INT NULL, admin_note TEXT NULL, reject_reason TEXT NULL, requested_at DATETIME DEFAULT CURRENT_TIMESTAMP, reviewed_at DATETIME NULL, INDEX(listing_id), INDEX(user_id), INDEX(status), INDEX(payment_status)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); }catch(Throwable $e){}
    try{ q("CREATE TABLE IF NOT EXISTS mj_listing_events (id INT AUTO_INCREMENT PRIMARY KEY, listing_id INT NOT NULL, user_id INT NULL, event_type VARCHAR(80) NOT NULL, event_value VARCHAR(191) NULL, ip_address VARCHAR(80) NULL, user_agent TEXT NULL, session_key VARCHAR(191) NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX(listing_id), INDEX(event_type), INDEX(created_at), INDEX(session_key)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); }catch(Throwable $e){}
    try{ q("CREATE TABLE IF NOT EXISTS mj_listing_reports (id INT AUTO_INCREMENT PRIMARY KEY, listing_id INT NOT NULL, user_id INT NULL, reason VARCHAR(120) NOT NULL, message TEXT NULL, status VARCHAR(40) NOT NULL DEFAULT 'new', admin_id INT NULL, admin_note TEXT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, reviewed_at DATETIME NULL, INDEX(listing_id), INDEX(status)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); }catch(Throwable $e){}
    try{ q("CREATE TABLE IF NOT EXISTS mj_listing_questions (id INT AUTO_INCREMENT PRIMARY KEY, listing_id INT NOT NULL, user_id INT NULL, question TEXT NOT NULL, answer TEXT NULL, status VARCHAR(40) NOT NULL DEFAULT 'pending', answered_by INT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, answered_at DATETIME NULL, INDEX(listing_id), INDEX(status)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); }catch(Throwable $e){}
    try{ q("CREATE TABLE IF NOT EXISTS mj_listing_faqs (id INT AUTO_INCREMENT PRIMARY KEY, listing_id INT NOT NULL, question VARCHAR(255) NOT NULL, answer TEXT NOT NULL, sort_order INT NOT NULL DEFAULT 0, is_active TINYINT(1) NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX(listing_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); }catch(Throwable $e){}
    try{ q("CREATE TABLE IF NOT EXISTS mj_payment_methods (id INT AUTO_INCREMENT PRIMARY KEY, method_key VARCHAR(80) NOT NULL UNIQUE, method_name VARCHAR(120) NOT NULL, is_enabled TINYINT(1) NOT NULL DEFAULT 0, details_json LONGTEXT NULL, instructions TEXT NULL, qr_image VARCHAR(500) NULL, country_id INT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); }catch(Throwable $e){}
    try{ q("CREATE TABLE IF NOT EXISTS mj_payment_proofs (id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NULL, related_type VARCHAR(80) NULL, related_id INT NULL, payment_method VARCHAR(80) NULL, amount DECIMAL(12,2) NULL, currency VARCHAR(10) NULL, proof_file VARCHAR(500) NULL, status VARCHAR(50) NOT NULL DEFAULT 'uploaded', admin_id INT NULL, admin_note TEXT NULL, uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP, reviewed_at DATETIME NULL, INDEX(user_id), INDEX(related_type, related_id), INDEX(status)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); }catch(Throwable $e){}
    try{ q("CREATE TABLE IF NOT EXISTS mj_notifications (id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NULL, admin_id INT NULL, type VARCHAR(80) NOT NULL, title VARCHAR(255) NOT NULL, message TEXT NULL, priority VARCHAR(40) NOT NULL DEFAULT 'normal', action_url VARCHAR(500) NULL, is_read TINYINT(1) NOT NULL DEFAULT 0, is_popup_shown TINYINT(1) NOT NULL DEFAULT 0, popup_shown_at DATETIME NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX(user_id), INDEX(admin_id), INDEX(is_read), INDEX(is_popup_shown), INDEX(created_at)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); }catch(Throwable $e){}
    try{ q("CREATE TABLE IF NOT EXISTS mj_email_queue (id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NULL, to_email VARCHAR(191) NOT NULL, subject VARCHAR(255) NOT NULL, body_html LONGTEXT NULL, body_text LONGTEXT NULL, template_key VARCHAR(100) NULL, status VARCHAR(40) NOT NULL DEFAULT 'pending', priority VARCHAR(40) NOT NULL DEFAULT 'normal', scheduled_at DATETIME NULL, sent_at DATETIME NULL, failed_at DATETIME NULL, error_message TEXT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX(user_id), INDEX(status), INDEX(template_key)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); }catch(Throwable $e){}
    try{ q("CREATE TABLE IF NOT EXISTS mj_notification_settings (id INT AUTO_INCREMENT PRIMARY KEY, setting_key VARCHAR(120) NOT NULL UNIQUE, setting_value TEXT NULL, updated_at DATETIME NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); }catch(Throwable $e){}
    try{ q("CREATE TABLE IF NOT EXISTS mj_user_notification_preferences (id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, notification_type VARCHAR(100) NOT NULL, in_app_enabled TINYINT(1) NOT NULL DEFAULT 1, email_enabled TINYINT(1) NOT NULL DEFAULT 1, popup_enabled TINYINT(1) NOT NULL DEFAULT 1, UNIQUE KEY uniq_user_type(user_id,notification_type)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); }catch(Throwable $e){}
}
}
if (!function_exists('mj263_setting')) {
function mj263_setting(string $key, string $default=''): string {
    try { if(table_exists('mj_notification_settings')){ $r=one('SELECT setting_value FROM mj_notification_settings WHERE setting_key=?',[$key]); if($r) return (string)$r['setting_value']; } } catch(Throwable $e) {}
    return setting($key, $default);
}
}
if (!function_exists('mj263_notify')) {
function mj263_notify(?int $userId, ?int $adminId, string $type, string $title, string $message='', string $url='', string $priority='normal', bool $email=false): void {
    mj263_schema();
    try {
        if (function_exists('mj429_notify')) {
            if ($adminId) { mj429_notify('admin', $adminId, $title, $message, $type, $url, ['priority'=>$priority]); }
            else { mj429_notify('user', $userId ?: null, $title, $message, $type, $url, ['priority'=>$priority]); }
        } elseif (table_exists('mj_notifications')) {
            $hash = sha1(($userId ?: 0).'|'.($adminId ?: 0).'|'.$type.'|'.$title.'|'.$message.'|'.$url);
            $exists = null;
            try { $exists = one('SELECT id FROM mj_notifications WHERE dedupe_hash=? AND created_at >= DATE_SUB(NOW(), INTERVAL 20 MINUTE) LIMIT 1', [$hash]); } catch (Throwable $ignore) {}
            if (!$exists) {
                if (mj_column_exists('mj_notifications','dedupe_hash')) { q('INSERT INTO mj_notifications(user_id,admin_id,type,title,message,priority,action_url,dedupe_hash,created_at) VALUES(?,?,?,?,?,?,?,?,NOW())',[$userId,$adminId,$type,$title,$message,$priority,$url,$hash]); }
                else { q('INSERT INTO mj_notifications(user_id,admin_id,type,title,message,priority,action_url,created_at) VALUES(?,?,?,?,?,?,?,NOW())',[$userId,$adminId,$type,$title,$message,$priority,$url]); }
            }
        }
    } catch(Throwable $e) {}
    if($email && $userId){
        try {
            $u=one('SELECT email,name FROM mj_users WHERE id=? LIMIT 1',[$userId]);
            if($u && filter_var($u['email'], FILTER_VALIDATE_EMAIL)){
                require_once __DIR__ . '/mj_email_helpers.php';
                $openUrl = $url ? base_url($url) : base_url('/');
                $html = '<div style="font-family:Arial,sans-serif;line-height:1.5"><h2 style="margin:0 0 12px">'.e($title).'</h2><p>'.nl2br(e($message)).'</p><p><a href="'.e($openUrl).'" style="display:inline-block;background:#2563eb;color:#fff;padding:10px 16px;border-radius:8px;text-decoration:none">Open</a></p></div>';
                if (mj_email_setting('email_queue_enabled','1') === '1') {
                    mj_queue_email((string)$u['email'], $title, $html, $message, $type, $userId, null);
                } else {
                    mj_send_email((string)$u['email'], $title, $html, $message, ['template_key'=>$type]);
                }
            }
        } catch(Throwable $e) {}
    }
}
}
if (!function_exists('mj263_listing_images')) {
function mj263_listing_images(int $listingId): array { try { return table_exists('mj_listing_images') ? all_rows('SELECT * FROM mj_listing_images WHERE listing_id=? ORDER BY sort_order,id',[$listingId]) : []; } catch(Throwable $e){ return []; } }
}
if (!function_exists('mj263_image_paths')) {
function mj263_image_paths(int $listingId): array { return array_values(array_filter(array_map(function($r){return $r['image_path']??'';}, mj263_listing_images($listingId)))); }
}
if (!function_exists('mj263_safe_delete_upload')) {
function mj263_safe_delete_upload(string $path): bool {
    $path = ltrim(str_replace('\\','/',$path),'/');
    if ($path === '' || strpos($path,'..') !== false || !str_starts_with($path,'assets/uploads/listings/')) return false;
    $full = realpath(__DIR__.'/../'.$path);
    $base = realpath(__DIR__.'/../assets/uploads/listings');
    if($full && $base && str_starts_with($full,$base) && is_file($full)) return @unlink($full);
    return false;
}
}
if (!function_exists('mj263_delete_listing_image')) {
function mj263_delete_listing_image(int $imageId, int $listingId=0): bool {
    try { if(!table_exists('mj_listing_images')) return false; $sql='SELECT * FROM mj_listing_images WHERE id=?'; $p=[$imageId]; if($listingId>0){$sql.=' AND listing_id=?'; $p[]=$listingId;} $im=one($sql.' LIMIT 1',$p); if(!$im) return false; q('DELETE FROM mj_listing_images WHERE id=?',[$imageId]); mj263_safe_delete_upload((string)$im['image_path']); return true; } catch(Throwable $e){ return false; }
}
}
if (!function_exists('mj263_listing_url')) {
function mj263_listing_url(array $l): string { return base_url('listing.php?id='.(int)($l['id']??0)); }
}
if (!function_exists('mj263_qr_url')) {
function mj263_qr_url(string $url, int $size=240): string { return 'https://api.qrserver.com/v1/create-qr-code/?size='.$size.'x'.$size.'&data='.rawurlencode($url); }
}
if (!function_exists('mj263_track_event')) {
function mj263_track_event(int $listingId, string $type, string $value=''): void {
    mj263_schema();
    try {
        $session = session_id() ?: ($_COOKIE[session_name()] ?? 'guest');
        $ip = $_SERVER['REMOTE_ADDR'] ?? '';
        if($type==='view'){
            $exists = one('SELECT id FROM mj_listing_events WHERE listing_id=? AND event_type="view" AND (session_key=? OR ip_address=?) AND created_at >= DATE_SUB(NOW(), INTERVAL 6 HOUR) LIMIT 1',[$listingId,$session,$ip]);
            if($exists) return;
        }
        q('INSERT INTO mj_listing_events(listing_id,user_id,event_type,event_value,ip_address,user_agent,session_key,created_at) VALUES(?,?,?,?,?,?,?,NOW())',[$listingId,$_SESSION['user_id']??null,$type,$value,$ip,$_SERVER['HTTP_USER_AGENT']??'',$session]);
        $map=['view'=>'views','offer_click'=>'offer_count','chat_click'=>'chat_count','save'=>'save_count','share'=>'share_count','phone_click'=>'phone_click_count','whatsapp_click'=>'whatsapp_click_count'];
        if(isset($map[$type]) && mj_column_exists('mj_listings',$map[$type])) q('UPDATE mj_listings SET `'.$map[$type].'`=`'.$map[$type].'`+1'.(mj_column_exists('mj_listings','last_engaged_at')&&$type!=='view'?', last_engaged_at=NOW()':'').($type==='view'&&mj_column_exists('mj_listings','last_viewed_at')?', last_viewed_at=NOW()':'').' WHERE id=?',[$listingId]);
        if($type!=='view' && mj_column_exists('mj_listings','engagement_count')) q('UPDATE mj_listings SET engagement_count=engagement_count+1,last_engaged_at=NOW() WHERE id=?',[$listingId]);
    } catch(Throwable $e) {}
}
}
if (!function_exists('mj263_status_public_mode')) {
function mj263_status_public_mode(string $status): string {
    $s=strtolower(trim($status));
    if(in_array($s,['sold','reserved'],true)) return $s;
    if(in_array($s,['active','approved','published',''],true)) return 'active';
    return 'hidden';
}
}
if (!function_exists('mj263_quality_details')) {
function mj263_quality_details(array $l): array {
    $score=0;$tips=[];
    if(strlen(trim((string)($l['title']??'')))>=8) $score+=20; else $tips[]='Use a clear title with at least 8 characters.';
    if(strlen(trim((string)($l['description']??'')))>=40) $score+=25; else $tips[]='Add more useful description.';
    if((float)($l['price']??0)>0) $score+=15; else $tips[]='Add a realistic price.';
    if(!empty($l['country_id'])) $score+=10; else $tips[]='Choose location/country.';
    if(!empty($l['category_id'])) $score+=10; else $tips[]='Choose the best category.';
    $imgs=!empty($l['id'])?count(mj263_listing_images((int)$l['id'])):0; if($imgs>=3)$score+=20; elseif($imgs>0)$score+=10; else $tips[]='Upload at least 3 clear photos.';
    return ['score'=>min(100,$score),'tips'=>$tips,'images'=>$imgs];
}
}
if (!function_exists('mj263_visible_reason')) {
function mj263_visible_reason(array $l): array {
    $status=strtolower((string)($l['status']??''));
    if((int)($l['is_deleted']??0)===1 || !empty($l['deleted_at'])) return [false,'Listing is deleted.'];
    if(in_array($status,['pending','draft','rejected','inactive','expired','deleted','archived','suspended','correction_required'],true)) return [false,'Status is '.$status.'.'];
    if(!empty($l['expires_at']) && strtotime((string)$l['expires_at'])<time()) return [false,'Listing is expired.'];
    return [true,'Listing is visible publicly.'];
}
}
if (!function_exists('mj263_pending_revision')) {
function mj263_pending_revision(int $listingId): ?array { try { return table_exists('mj_listing_revisions') ? one('SELECT * FROM mj_listing_revisions WHERE listing_id=? AND status="pending" ORDER BY id DESC LIMIT 1',[$listingId]) : null; } catch(Throwable $e){ return null; } }
}
if (!function_exists('mj263_pending_renewal')) {
function mj263_pending_renewal(int $listingId): ?array { try { return table_exists('mj_listing_renewal_requests') ? one('SELECT * FROM mj_listing_renewal_requests WHERE listing_id=? AND status IN ("pending","pending_payment","payment_uploaded") ORDER BY id DESC LIMIT 1',[$listingId]) : null; } catch(Throwable $e){ return null; } }
}
if (!function_exists('mj263_public_badges')) {
function mj263_public_badges(array $r): string {
    $html=''; $status=strtolower((string)($r['status']??'active'));
    if(function_exists('mj_listing_active_sponsored') && mj_listing_active_sponsored($r)) $html.='<span class="mj-badge mj-badge-sponsored"><i class="bi bi-megaphone-fill"></i> Sponsored</span>';
    elseif(function_exists('mj_listing_active_featured') && mj_listing_active_featured($r)) $html.='<span class="mj-badge warn"><i class="bi bi-lightning-charge-fill"></i> Featured</span>';
    if($status==='sold') $html.='<span class="mj-badge dark">Sold</span>';
    if($status==='reserved') $html.='<span class="mj-badge warn">Reserved</span>';
    if((int)($r['views']??0)>=100) $html.='<span class="mj-badge good">Popular</span>';
    if((int)($r['engagement_count']??0)>=20) $html.='<span class="mj-badge hot">Trending</span>';
    return $html;
}
}
@mj263_schema();


/* ===== v2.6.4 Company module advanced helpers ===== */
if (!function_exists('mj_column_exists_fresh')) {
function mj_column_exists_fresh(string $table, string $column): bool {
    $table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
    $column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
    if ($table === '' || $column === '') { return false; }
    try {
        $row = one('SELECT COUNT(*) AS c FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?', [$table, $column]);
        return ((int)($row['c'] ?? 0)) > 0;
    } catch (Throwable $e) {
        try { $row = one('SHOW COLUMNS FROM `'.$table.'` LIKE ?', [$column]); return (bool)$row; } catch (Throwable $e2) { return false; }
    }
}
}

if (!function_exists('mj_add_column_if_missing')) {
function mj_add_column_if_missing(string $table, string $column, string $definition): void {
    $table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
    $column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
    if ($table === '' || $column === '' || !table_exists($table)) { return; }
    if (!mj_column_exists_fresh($table, $column)) {
        q('ALTER TABLE `'.$table.'` ADD COLUMN `'.$column.'` '.$definition);
    }
}
}

if (!function_exists('mj_company_module_ensure_schema')) {
function mj_company_module_ensure_schema(): void {
    static $done = false;
    if ($done) { return; }
    $done = true;
    try {
        if (!mj_is_installed() || !table_exists('mj_companies')) { return; }
        mj_add_column_if_missing('mj_companies','admin_note','TEXT NULL AFTER views');
        mj_add_column_if_missing('mj_companies','rejection_reason','TEXT NULL AFTER admin_note');
        mj_add_column_if_missing('mj_companies','is_sponsored','TINYINT(1) NOT NULL DEFAULT 0 AFTER is_featured');
        mj_add_column_if_missing('mj_companies','sponsored_until','DATETIME NULL AFTER is_sponsored');
        mj_add_column_if_missing('mj_companies','verification_status',"VARCHAR(40) NOT NULL DEFAULT 'not_verified' AFTER is_verified");
        mj_add_column_if_missing('mj_companies','verification_until','DATE NULL AFTER verification_status');
        mj_add_column_if_missing('mj_companies','trust_score','INT NOT NULL DEFAULT 0 AFTER rating_count');
        mj_add_column_if_missing('mj_companies','profile_score','INT NOT NULL DEFAULT 0 AFTER trust_score');
        mj_add_column_if_missing('mj_companies','last_reviewed_at','DATETIME NULL AFTER updated_at');
        mj_add_column_if_missing('mj_companies','last_reviewed_by','INT NULL AFTER last_reviewed_at');
        mj_add_column_if_missing('mj_companies','submitted_at','DATETIME NULL AFTER last_reviewed_by');
        if (!table_exists('mj_company_timeline')) {
            q("CREATE TABLE IF NOT EXISTS mj_company_timeline (
                id BIGINT AUTO_INCREMENT PRIMARY KEY,
                company_id BIGINT NOT NULL,
                actor_type VARCHAR(30) NOT NULL DEFAULT 'system',
                actor_id BIGINT NULL,
                action VARCHAR(120) NOT NULL,
                note TEXT NULL,
                old_status VARCHAR(40) NULL,
                new_status VARCHAR(40) NULL,
                ip_address VARCHAR(80) NULL,
                created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
                INDEX idx_company_timeline_company(company_id),
                INDEX idx_company_timeline_action(action)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
        }
    } catch (Throwable $e) {
        error_log('[MJ Company Module Schema] '.$e->getMessage());
    }
}
}

if (!function_exists('mj_company_filter_columns_fresh')) {
function mj_company_filter_columns_fresh(string $table, array $data): array {
    $out = [];
    foreach ($data as $k => $v) {
        if (mj_column_exists_fresh($table, (string)$k)) { $out[$k] = $v; }
    }
    return $out;
}
}

if (!function_exists('mj_company_clean')) {
function mj_company_clean($value, int $max = 5000): string {
    $value = trim((string)$value);
    $value = preg_replace("/\r\n|\r/", "\n", $value);
    if ($max > 0 && function_exists('mb_substr')) { $value = mb_substr($value, 0, $max); }
    elseif ($max > 0) { $value = substr($value, 0, $max); }
    return $value;
}
}

if (!function_exists('mj_company_normalize_url')) {
function mj_company_normalize_url(string $url): string {
    $url = trim($url);
    if ($url === '') { return ''; }
    if (!preg_match('#^https?://#i', $url)) { $url = 'https://' . $url; }
    return $url;
}
}

if (!function_exists('mj_company_validate_data')) {
function mj_company_validate_data(array $data): array {
    $errors = [];
    $name = trim((string)($data['name'] ?? ''));
    if ($name === '') { $errors[] = 'Company name is required.'; }
    elseif (mb_strlen($name) < 2) { $errors[] = 'Company name is too short.'; }
    elseif (mb_strlen($name) > 220) { $errors[] = 'Company name must be 220 characters or less.'; }
    if ((int)($data['country_id'] ?? 0) <= 0) { $errors[] = 'Please choose a country.'; }
    if ((int)($data['category_id'] ?? 0) <= 0) { $errors[] = 'Please choose a business category.'; }
    $email = trim((string)($data['email'] ?? ''));
    if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = 'Please enter a valid company email address.'; }
    $website = mj_company_normalize_url((string)($data['website'] ?? ''));
    if ($website !== '' && !filter_var($website, FILTER_VALIDATE_URL)) { $errors[] = 'Please enter a valid website URL.'; }
    $phone = preg_replace('/\s+/', '', (string)($data['phone'] ?? ''));
    $wa = preg_replace('/\s+/', '', (string)($data['whatsapp'] ?? ''));
    if ($phone === '' && $wa === '' && $email === '') { $errors[] = 'Please add at least one contact option: phone, WhatsApp or email.'; }
    $address = trim((string)($data['address'] ?? ''));
    if ($address === '') { $errors[] = 'Business address is required.'; }
    $description = trim((string)($data['description'] ?? ''));
    if ($description === '') { $errors[] = 'Please add a short company description.'; }
    elseif (mb_strlen($description) < 20) { $errors[] = 'Company description should be at least 20 characters so customers understand your business.'; }
    return $errors;
}
}

if (!function_exists('mj_company_profile_score')) {
function mj_company_profile_score(array $co, int $serviceCount = 0): int {
    $score = 0;
    if (trim((string)($co['name'] ?? '')) !== '') $score += 10;
    if ((int)($co['category_id'] ?? 0) > 0) $score += 8;
    if ((int)($co['country_id'] ?? 0) > 0) $score += 8;
    if ((int)($co['city_id'] ?? 0) > 0 || trim((string)($co['address'] ?? '')) !== '') $score += 10;
    if (trim((string)($co['phone'] ?? '')) !== '' || trim((string)($co['whatsapp'] ?? '')) !== '') $score += 10;
    if (trim((string)($co['email'] ?? '')) !== '') $score += 8;
    if (trim((string)($co['website'] ?? '')) !== '') $score += 6;
    if (trim((string)($co['description'] ?? '')) !== '') $score += mb_strlen((string)$co['description']) >= 80 ? 15 : 8;
    if (trim((string)($co['logo'] ?? '')) !== '') $score += 10;
    if (trim((string)($co['cover_image'] ?? '')) !== '') $score += 7;
    if ($serviceCount > 0) $score += 8;
    return min(100, $score);
}
}

if (!function_exists('mj_company_trust_score')) {
function mj_company_trust_score(array $co, int $serviceCount = 0): int {
    $score = 20;
    if ((int)($co['is_verified'] ?? 0) === 1) $score += 25;
    if (in_array((string)($co['status'] ?? ''), ['active','approved','published'], true)) $score += 10;
    if (trim((string)($co['registration_no'] ?? '')) !== '') $score += 8;
    if (trim((string)($co['phone'] ?? '')) !== '') $score += 8;
    if (trim((string)($co['whatsapp'] ?? '')) !== '') $score += 6;
    if (trim((string)($co['email'] ?? '')) !== '') $score += 6;
    if (trim((string)($co['website'] ?? '')) !== '') $score += 6;
    if (trim((string)($co['address'] ?? '')) !== '') $score += 5;
    if (trim((string)($co['logo'] ?? '')) !== '') $score += 4;
    if ($serviceCount > 0) $score += 2;
    return min(100, $score);
}
}

if (!function_exists('mj_company_score_label')) {
function mj_company_score_label(int $score): string {
    if ($score >= 85) return 'Excellent';
    if ($score >= 65) return 'Good';
    if ($score >= 40) return 'Needs improvement';
    return 'Incomplete';
}
}

if (!function_exists('mj_company_status_badge_class')) {
function mj_company_status_badge_class(string $status): string {
    $status = strtolower($status);
    if (in_array($status, ['active','approved','published'], true)) return 'success';
    if (in_array($status, ['pending','under_review'], true)) return 'warning';
    if ($status === 'rejected') return 'danger';
    if ($status === 'suspended') return 'dark';
    return 'secondary';
}
}

if (!function_exists('mj_company_status_label')) {
function mj_company_status_label(string $status): string {
    $map = ['draft'=>'Draft','pending'=>'Pending Review','active'=>'Active','approved'=>'Active','published'=>'Active','rejected'=>'Needs Update','suspended'=>'Suspended'];
    $key = strtolower(trim($status));
    return $map[$key] ?? ucfirst(str_replace('_',' ', $key));
}
}

if (!function_exists('mj_company_public_url')) {
function mj_company_public_url(array $co): string {
    return base_url('company.php?id='.(int)($co['id'] ?? 0));
}
}



if (!function_exists('mj_mask_contact_value')) {
function mj_mask_contact_value($value, string $type = 'phone'): string {
    $value = trim((string)$value);
    if ($value === '') { return '-'; }
    $type = strtolower($type);
    if ($type === 'email') {
        if (strpos($value, '@') === false) { return 'XXXX'; }
        [$local, $domain] = explode('@', $value, 2);
        $local = trim($local);
        $tail = $local;
        if (function_exists('mb_substr')) { $tail = mb_substr($local, -3); }
        else { $tail = substr($local, -3); }
        if ($tail === '') { $tail = '***'; }
        return 'XXXX' . $tail . '@' . $domain;
    }
    $digits = preg_replace('/\D+/', '', $value);
    if ($digits === '') { return 'XXXX'; }
    $last = substr($digits, -4);
    return 'XXXX' . $last;
}
}

if (!function_exists('mj_contact_public_value')) {
function mj_contact_public_value($value, string $type = 'phone', bool $canViewFull = false): string {
    $value = trim((string)$value);
    if ($value === '') { return '-'; }
    return $canViewFull ? $value : mj_mask_contact_value($value, $type);
}
}

if (!function_exists('mj_company_contact_href')) {
function mj_company_contact_href($value, string $type = 'phone'): string {
    $value = trim((string)$value);
    if ($value === '') { return '#'; }
    $type = strtolower($type);
    if ($type === 'email') { return 'mailto:' . $value; }
    if ($type === 'whatsapp') {
        $digits = preg_replace('/\D+/', '', $value);
        return $digits ? 'https://wa.me/' . $digits : '#';
    }
    $tel = preg_replace('/\s+/', '', $value);
    return 'tel:' . $tel;
}
}

if (!function_exists('mj_company_contact_action_url')) {
function mj_company_contact_action_url($value, string $type, bool $canViewFull, string $loginUrl): string {
    if (!$canViewFull) { return $loginUrl; }
    return mj_company_contact_href($value, $type);
}
}

if (!function_exists('mj_company_timeline_add')) {
function mj_company_timeline_add(int $companyId, string $actorType, int $actorId, string $action, string $note = '', string $oldStatus = '', string $newStatus = ''): void {
    try {
        mj_company_module_ensure_schema();
        if (!table_exists('mj_company_timeline')) { return; }
        q('INSERT INTO mj_company_timeline(company_id,actor_type,actor_id,action,note,old_status,new_status,ip_address,created_at) VALUES(?,?,?,?,?,?,?,?,NOW())', [
            $companyId, $actorType, $actorId ?: null, $action, $note, $oldStatus ?: null, $newStatus ?: null, $_SERVER['REMOTE_ADDR'] ?? ''
        ]);
    } catch (Throwable $e) {}
}
}

if (!function_exists('mj_company_save_services_from_text')) {
function mj_company_save_services_from_text(int $companyId, string $servicesText): int {
    $servicesText = trim($servicesText);
    try { q('DELETE FROM mj_company_services WHERE company_id=?', [$companyId]); } catch (Throwable $e) { return 0; }
    if ($servicesText === '') { return 0; }
    $lines = preg_split('/\r\n|\r|\n|,/', $servicesText);
    $count = 0;
    foreach ($lines as $line) {
        $name = trim((string)$line);
        if ($name === '') { continue; }
        $name = mb_substr($name, 0, 180);
        try { q('INSERT INTO mj_company_services(company_id,name,description,is_bookable,created_at) VALUES(?,?,?,?,NOW())', [$companyId, $name, '', 0]); $count++; } catch (Throwable $e) {}
        if ($count >= 20) { break; }
    }
    return $count;
}
}

if (!function_exists('mj_company_get_services_text')) {
function mj_company_get_services_text(int $companyId): string {
    try {
        $rows = all_rows('SELECT name FROM mj_company_services WHERE company_id=? ORDER BY id', [$companyId]);
        return implode("\n", array_map(function($r){ return (string)($r['name'] ?? ''); }, $rows));
    } catch (Throwable $e) { return ''; }
}
}

if (!function_exists('mj_company_update_main_branch')) {
function mj_company_update_main_branch(int $companyId, array $data): void {
    try {
        $branch = one('SELECT id FROM mj_company_branches WHERE company_id=? ORDER BY is_main DESC,id LIMIT 1', [$companyId]);
        $hours = json_encode([
            'mon'=>['open'=>$data['weekday_open'] ?? '09:00','close'=>$data['weekday_close'] ?? '18:00'],
            'tue'=>['open'=>$data['weekday_open'] ?? '09:00','close'=>$data['weekday_close'] ?? '18:00'],
            'wed'=>['open'=>$data['weekday_open'] ?? '09:00','close'=>$data['weekday_close'] ?? '18:00'],
            'thu'=>['open'=>$data['weekday_open'] ?? '09:00','close'=>$data['weekday_close'] ?? '18:00'],
            'fri'=>['open'=>$data['weekday_open'] ?? '09:00','close'=>$data['weekday_close'] ?? '18:00'],
            'sat'=>!empty($data['saturday_closed']) ? ['closed'=>true] : ['open'=>$data['saturday_open'] ?? '09:00','close'=>$data['saturday_close'] ?? '13:00'],
            'sun'=>!empty($data['sunday_closed']) ? ['closed'=>true] : ['open'=>$data['sunday_open'] ?? '09:00','close'=>$data['sunday_close'] ?? '13:00'],
        ], JSON_UNESCAPED_SLASHES);
        if ($branch) {
            q('UPDATE mj_company_branches SET country_id=?,state_id=?,city_id=?,name=?,timezone=?,phone=?,whatsapp=?,email=?,address=?,opening_hours_json=?,is_main=1,is_active=1 WHERE id=?', [
                (int)$data['country_id'], !empty($data['state_id'])?(int)$data['state_id']:null, !empty($data['city_id'])?(int)$data['city_id']:null,
                ($data['name'] ?? 'Main Branch'), $data['timezone'] ?? 'UTC', $data['phone'] ?? '', $data['whatsapp'] ?? '', $data['email'] ?? '', $data['address'] ?? '', $hours, (int)$branch['id']
            ]);
        } else {
            q('INSERT INTO mj_company_branches(company_id,country_id,state_id,city_id,name,timezone,phone,whatsapp,email,address,opening_hours_json,is_main,is_active) VALUES(?,?,?,?,?,?,?,?,?,?,?,1,1)', [
                $companyId, (int)$data['country_id'], !empty($data['state_id'])?(int)$data['state_id']:null, !empty($data['city_id'])?(int)$data['city_id']:null,
                ($data['name'] ?? 'Main Branch'), $data['timezone'] ?? 'UTC', $data['phone'] ?? '', $data['whatsapp'] ?? '', $data['email'] ?? '', $data['address'] ?? '', $hours
            ]);
        }
    } catch (Throwable $e) {}
}
}

if (!function_exists('mj_company_update_scores')) {
function mj_company_update_scores(int $companyId): void {
    try {
        mj_company_module_ensure_schema();
        $co = one('SELECT * FROM mj_companies WHERE id=? LIMIT 1', [$companyId]);
        if (!$co) return;
        $serviceCount = safe_count('SELECT COUNT(*) c FROM mj_company_services WHERE company_id=?', [$companyId]);
        $profile = mj_company_profile_score($co, $serviceCount);
        $trust = mj_company_trust_score($co, $serviceCount);
        $data = mj_company_filter_columns_fresh('mj_companies', ['profile_score'=>$profile, 'trust_score'=>$trust]);
        if ($data) {
            $sets=[]; $params=[];
            foreach ($data as $k=>$v) { $sets[]='`'.$k.'`=?'; $params[]=$v; }
            $params[]=$companyId;
            q('UPDATE mj_companies SET '.implode(',', $sets).' WHERE id=?', $params);
        }
    } catch (Throwable $e) {}
}
}


if (!function_exists('mj_trust_bundle_ensure_schema')) {
function mj_trust_bundle_ensure_schema(): void {
    if (!mj_is_installed()) { return; }
    try {
        q("CREATE TABLE IF NOT EXISTS mj_saved_listings (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            user_id BIGINT NOT NULL,
            listing_id BIGINT NOT NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            UNIQUE KEY uniq_saved_listing(user_id,listing_id),
            KEY idx_listing_id(listing_id)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    } catch (Throwable $e) {}
    try {
        q("CREATE TABLE IF NOT EXISTS mj_saved_companies (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            user_id BIGINT NOT NULL,
            company_id BIGINT NOT NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            UNIQUE KEY uniq_saved_company(user_id,company_id),
            KEY idx_company_id(company_id)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    } catch (Throwable $e) {}
    try {
        if (table_exists('mj_companies') && !mj_column_exists('mj_companies', 'phone_click_count')) {
            q("ALTER TABLE mj_companies ADD COLUMN phone_click_count BIGINT NOT NULL DEFAULT 0 AFTER views");
        }
    } catch (Throwable $e) {}
    try {
        if (table_exists('mj_companies') && !mj_column_exists('mj_companies', 'whatsapp_click_count')) {
            q("ALTER TABLE mj_companies ADD COLUMN whatsapp_click_count BIGINT NOT NULL DEFAULT 0 AFTER phone_click_count");
        }
    } catch (Throwable $e) {}
    try {
        if (table_exists('mj_companies') && !mj_column_exists('mj_companies', 'email_click_count')) {
            q("ALTER TABLE mj_companies ADD COLUMN email_click_count BIGINT NOT NULL DEFAULT 0 AFTER whatsapp_click_count");
        }
    } catch (Throwable $e) {}
    try {
        if (table_exists('mj_companies') && !mj_column_exists('mj_companies', 'website_click_count')) {
            q("ALTER TABLE mj_companies ADD COLUMN website_click_count BIGINT NOT NULL DEFAULT 0 AFTER email_click_count");
        }
    } catch (Throwable $e) {}
    try {
        q("CREATE TABLE IF NOT EXISTS mj_company_events (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            company_id BIGINT NOT NULL,
            user_id BIGINT NULL,
            event_type VARCHAR(80) NOT NULL,
            event_value VARCHAR(191) NULL,
            ip_address VARCHAR(80) NULL,
            user_agent TEXT NULL,
            session_key VARCHAR(191) NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            KEY idx_company_id(company_id),
            KEY idx_event_type(event_type),
            KEY idx_created_at(created_at)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    } catch (Throwable $e) {}
}
}

if (!function_exists('mj_listing_company_fetch')) {
function mj_listing_company_fetch(array $listing): ?array {
    $companyId = (int)($listing['company_id'] ?? 0);
    if ($companyId <= 0 || !table_exists('mj_companies')) { return null; }
    try {
        return one('SELECT co.*, cat.name category_name, c.name country_name, c.flag_emoji, ci.name city_name
                    FROM mj_companies co
                    LEFT JOIN mj_categories cat ON cat.id=co.category_id
                    LEFT JOIN mj_countries c ON c.id=co.country_id
                    LEFT JOIN mj_cities ci ON ci.id=co.city_id
                    WHERE co.id=? AND co.status IN ("active","approved","published") LIMIT 1', [$companyId]);
    } catch (Throwable $e) {
        try {
            return one('SELECT co.*, cat.name category_name, c.name country_name, c.flag_emoji, ci.name city_name
                        FROM mj_companies co
                        LEFT JOIN mj_categories cat ON cat.id=co.category_id
                        LEFT JOIN mj_countries c ON c.id=co.country_id
                        LEFT JOIN mj_cities ci ON ci.id=co.city_id
                        WHERE co.id=? AND co.status="active" LIMIT 1', [$companyId]);
        } catch (Throwable $e2) { return null; }
    }
}
}

if (!function_exists('mj_company_short_description')) {
function mj_company_short_description(array $co, int $limit = 150): string {
    $desc = trim(strip_tags((string)($co['description'] ?? '')));
    if ($desc === '') { return 'View company profile, services, active listings and contact options.'; }
    if (function_exists('mb_strimwidth')) { return mb_strimwidth($desc, 0, $limit, '...'); }
    return strlen($desc) > $limit ? substr($desc, 0, $limit - 3).'...' : $desc;
}
}

if (!function_exists('mj_contact_lock_title')) {
function mj_contact_lock_title(string $type, bool $canViewFull): string {
    if ($canViewFull) { return 'Click to contact'; }
    if ($type === 'email') { return 'Login to view full email address.'; }
    if ($type === 'whatsapp') { return 'Login to view full mobile / WhatsApp number.'; }
    return 'Login to view full phone number.';
}
}

if (!function_exists('mj_company_event_track')) {
function mj_company_event_track(int $companyId, string $type, string $value=''): void {
    mj_trust_bundle_ensure_schema();
    if ($companyId <= 0) { return; }
    try {
        $session = session_id() ?: ($_COOKIE[session_name()] ?? 'guest');
        q('INSERT INTO mj_company_events(company_id,user_id,event_type,event_value,ip_address,user_agent,session_key,created_at) VALUES(?,?,?,?,?,?,?,NOW())',
          [$companyId, $_SESSION['user_id'] ?? null, $type, substr($value,0,190), $_SERVER['REMOTE_ADDR'] ?? '', $_SERVER['HTTP_USER_AGENT'] ?? '', $session]);
        $map = [
            'company_phone_click'=>'phone_click_count',
            'company_whatsapp_click'=>'whatsapp_click_count',
            'company_email_click'=>'email_click_count',
            'company_website_click'=>'website_click_count'
        ];
        if (isset($map[$type]) && mj_column_exists('mj_companies', $map[$type])) {
            q('UPDATE mj_companies SET `'.$map[$type].'`=COALESCE(`'.$map[$type].'`,0)+1 WHERE id=?', [$companyId]);
        }
    } catch (Throwable $e) {}
}
}

if (!function_exists('mj_is_saved_listing')) {
function mj_is_saved_listing(int $userId, int $listingId): bool {
    if ($userId <= 0 || $listingId <= 0) { return false; }
    mj_trust_bundle_ensure_schema();
    try { return (bool)one('SELECT id FROM mj_saved_listings WHERE user_id=? AND listing_id=? LIMIT 1', [$userId, $listingId]); }
    catch (Throwable $e) { return false; }
}
}

if (!function_exists('mj_is_saved_company')) {
function mj_is_saved_company(int $userId, int $companyId): bool {
    if ($userId <= 0 || $companyId <= 0) { return false; }
    mj_trust_bundle_ensure_schema();
    try { return (bool)one('SELECT id FROM mj_saved_companies WHERE user_id=? AND company_id=? LIMIT 1', [$userId, $companyId]); }
    catch (Throwable $e) { return false; }
}
}

if (!function_exists('mj_trust_score_label')) {
function mj_trust_score_label($score): string {
    $score = (int)$score;
    if ($score >= 80) { return 'High trust'; }
    if ($score >= 50) { return 'Good trust'; }
    if ($score > 0) { return 'Building trust'; }
    return 'Trust not rated yet';
}
}

if (!function_exists('mj_realtime_chat_ensure_schema')) {
function mj_realtime_chat_ensure_schema(): void {
    if (!mj_is_installed() || !table_exists('mj_chats')) { return; }
    $chatCols = [
        'buyer_archived' => 'TINYINT(1) NOT NULL DEFAULT 0',
        'seller_archived' => 'TINYINT(1) NOT NULL DEFAULT 0',
        'buyer_pinned' => 'TINYINT(1) NOT NULL DEFAULT 0',
        'seller_pinned' => 'TINYINT(1) NOT NULL DEFAULT 0',
        'buyer_muted_until' => 'DATETIME NULL',
        'seller_muted_until' => 'DATETIME NULL',
        'buyer_mark_unread' => 'TINYINT(1) NOT NULL DEFAULT 0',
        'seller_mark_unread' => 'TINYINT(1) NOT NULL DEFAULT 0',
        'reported_at' => 'DATETIME NULL',
        'last_read_buyer_at' => 'DATETIME NULL',
        'last_read_seller_at' => 'DATETIME NULL'
    ];
    foreach ($chatCols as $col=>$def) {
        try { if (!mj_column_exists('mj_chats', $col)) { q("ALTER TABLE mj_chats ADD COLUMN `$col` $def"); } } catch (Throwable $e) {}
    }
    if (table_exists('mj_chat_messages')) {
        try { if (!mj_column_exists('mj_chat_messages', 'read_at')) { q('ALTER TABLE mj_chat_messages ADD COLUMN read_at DATETIME NULL AFTER is_read'); } } catch (Throwable $e) {}
        try { if (!mj_column_exists('mj_chat_messages', 'is_deleted')) { q('ALTER TABLE mj_chat_messages ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0 AFTER read_at'); } } catch (Throwable $e) {}
    }
}
}

if (!function_exists('mj_chat_url')) {
function mj_chat_url(array $chat): string {
    $url = 'chat.php?listing_id='.(int)($chat['listing_id'] ?? 0).'&company_id='.(int)($chat['company_id'] ?? 0).'&seller_id='.(int)($chat['seller_id'] ?? 0).'&buyer_id='.(int)($chat['buyer_id'] ?? 0);
    return base_url($url);
}
}

if (!function_exists('mj_chat_user_role')) {
function mj_chat_user_role(array $chat, int $userId): string {
    if ((int)($chat['seller_id'] ?? 0) === $userId) { return 'seller'; }
    if ((int)($chat['buyer_id'] ?? 0) === $userId) { return 'buyer'; }
    return '';
}
}

if (!function_exists('mj_chat_assert_participant')) {
function mj_chat_assert_participant(array $chat, int $userId): string {
    $role = mj_chat_user_role($chat, $userId);
    if ($role === '') { throw new Exception('You cannot access this conversation.'); }
    return $role;
}
}

if (!function_exists('mj_chat_risk_score')) {
function mj_chat_risk_score(string $msg): int {
    $risk = 0;
    $words = ['otp'=>25,'password'=>25,'bank login'=>35,'singpass'=>35,'deposit outside'=>25,'outside payment'=>25,'transfer now'=>20,'crypto'=>20,'urgent deposit'=>25];
    foreach ($words as $w=>$pts) { if (stripos($msg, $w) !== false) { $risk += $pts; } }
    try {
        if (table_exists('mj_fraud_rules')) {
            foreach (all_rows('SELECT * FROM mj_fraud_rules WHERE is_active=1') as $rule) {
                if (!empty($rule['keyword']) && stripos($msg, (string)$rule['keyword']) !== false) { $risk += (int)($rule['risk_points'] ?? 0); }
            }
        }
    } catch (Throwable $e) {}
    return min(100, max(0, $risk));
}
}

if (!function_exists('mj_chat_unread_count')) {
function mj_chat_unread_count(int $userId): int {
    if ($userId <= 0 || !table_exists('mj_chat_messages') || !table_exists('mj_chats')) { return 0; }
    try {
        $r = one('SELECT COUNT(*) c FROM mj_chat_messages m INNER JOIN mj_chats ch ON ch.id=m.chat_id WHERE m.sender_id<>? AND IFNULL(m.is_read,0)=0 AND (ch.buyer_id=? OR ch.seller_id=?) AND IFNULL(m.is_deleted,0)=0', [$userId,$userId,$userId]);
        return (int)($r['c'] ?? 0);
    } catch (Throwable $e) { return 0; }
}
}

if (!function_exists('mj_chat_participant_name')) {
function mj_chat_participant_name(array $chat, int $userId): string {
    if ((int)($chat['seller_id'] ?? 0) === $userId) { return (string)($chat['buyer_name'] ?? 'Buyer'); }
    return (string)($chat['seller_name'] ?? 'Seller');
}
}

if (!function_exists('mj_chat_format_message')) {
function mj_chat_format_message(array $m, int $viewerId): array {
    $isMe = (int)($m['sender_id'] ?? 0) === $viewerId;
    return [
        'id' => (int)($m['id'] ?? 0),
        'sender_id' => (int)($m['sender_id'] ?? 0),
        'sender_name' => (string)($m['sender_name'] ?? ($isMe ? 'Me' : 'User')),
        'message' => (string)($m['message'] ?? ''),
        'message_html' => nl2br(e((string)($m['message'] ?? ''))),
        'risk_score' => (int)($m['risk_score'] ?? 0),
        'is_read' => (int)($m['is_read'] ?? 0),
        'read_at' => (string)($m['read_at'] ?? ''),
        'created_at' => (string)($m['created_at'] ?? ''),
        'time_label' => local_datetime((string)($m['created_at'] ?? ''), 'UTC'),
        'is_me' => $isMe
    ];
}
}

if (!function_exists('mj_quote_ensure_schema')) {
function mj_quote_ensure_schema(): void {
    if (!mj_is_installed()) { return; }
    try {
        q("CREATE TABLE IF NOT EXISTS mj_quote_requests (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            user_id BIGINT NULL,
            company_id BIGINT NULL,
            service_id BIGINT NULL,
            listing_id BIGINT NULL,
            country_id INT NULL,
            city_id BIGINT NULL,
            title VARCHAR(255) NULL,
            description TEXT NULL,
            urgency VARCHAR(80) NULL,
            requester_name VARCHAR(180) NULL,
            requester_email VARCHAR(190) NULL,
            requester_phone VARCHAR(80) NULL,
            budget VARCHAR(120) NULL,
            preferred_date DATE NULL,
            preferred_contact VARCHAR(40) NULL,
            status VARCHAR(60) DEFAULT 'new',
            quoted_amount DECIMAL(12,2) NULL,
            currency_code VARCHAR(10) NULL,
            quote_message TEXT NULL,
            valid_until DATE NULL,
            lead_score INT NOT NULL DEFAULT 0,
            assigned_to BIGINT NULL,
            internal_note TEXT NULL,
            source VARCHAR(80) NULL,
            ip_address VARCHAR(80) NULL,
            user_agent VARCHAR(255) NULL,
            replied_at DATETIME NULL,
            accepted_at DATETIME NULL,
            closed_at DATETIME NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            updated_at DATETIME NULL,
            INDEX(company_id,status),
            INDEX(user_id),
            INDEX(listing_id),
            INDEX(status),
            INDEX(created_at)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    } catch (Throwable $e) {}

    $cols = [
        'listing_id' => 'BIGINT NULL AFTER service_id',
        'requester_name' => 'VARCHAR(180) NULL AFTER urgency',
        'requester_email' => 'VARCHAR(190) NULL AFTER requester_name',
        'requester_phone' => 'VARCHAR(80) NULL AFTER requester_email',
        'budget' => 'VARCHAR(120) NULL AFTER requester_phone',
        'preferred_date' => 'DATE NULL AFTER budget',
        'preferred_contact' => 'VARCHAR(40) NULL AFTER preferred_date',
        'quoted_amount' => 'DECIMAL(12,2) NULL AFTER status',
        'currency_code' => 'VARCHAR(10) NULL AFTER quoted_amount',
        'quote_message' => 'TEXT NULL AFTER currency_code',
        'valid_until' => 'DATE NULL AFTER quote_message',
        'lead_score' => 'INT NOT NULL DEFAULT 0 AFTER valid_until',
        'assigned_to' => 'BIGINT NULL AFTER lead_score',
        'internal_note' => 'TEXT NULL AFTER assigned_to',
        'source' => 'VARCHAR(80) NULL AFTER internal_note',
        'ip_address' => 'VARCHAR(80) NULL AFTER source',
        'user_agent' => 'VARCHAR(255) NULL AFTER ip_address',
        'replied_at' => 'DATETIME NULL AFTER user_agent',
        'accepted_at' => 'DATETIME NULL AFTER replied_at',
        'closed_at' => 'DATETIME NULL AFTER accepted_at',
        'updated_at' => 'DATETIME NULL AFTER created_at'
    ];
    foreach ($cols as $col => $def) {
        try { if (table_exists('mj_quote_requests') && !mj_column_exists('mj_quote_requests', $col)) { q("ALTER TABLE mj_quote_requests ADD COLUMN `$col` $def"); } } catch (Throwable $e) {}
    }

    try {
        q("CREATE TABLE IF NOT EXISTS mj_quote_replies (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            quote_id BIGINT NOT NULL,
            sender_type VARCHAR(30) NOT NULL DEFAULT 'company',
            sender_user_id BIGINT NULL,
            message TEXT NOT NULL,
            amount DECIMAL(12,2) NULL,
            currency_code VARCHAR(10) NULL,
            valid_until DATE NULL,
            attachment_path VARCHAR(255) NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            INDEX(quote_id),
            INDEX(sender_user_id),
            INDEX(created_at)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    } catch (Throwable $e) {}

    try {
        q("CREATE TABLE IF NOT EXISTS mj_quote_timeline (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            quote_id BIGINT NOT NULL,
            actor_type VARCHAR(30) NOT NULL DEFAULT 'system',
            actor_id BIGINT NULL,
            action VARCHAR(80) NOT NULL,
            note TEXT NULL,
            old_status VARCHAR(60) NULL,
            new_status VARCHAR(60) NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            INDEX(quote_id),
            INDEX(action),
            INDEX(created_at)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    } catch (Throwable $e) {}

    try {
        q("CREATE TABLE IF NOT EXISTS mj_quote_attachments (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            quote_id BIGINT NOT NULL,
            user_id BIGINT NULL,
            file_path VARCHAR(255) NOT NULL,
            original_name VARCHAR(255) NULL,
            mime_type VARCHAR(120) NULL,
            file_size BIGINT NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            INDEX(quote_id),
            INDEX(user_id)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    } catch (Throwable $e) {}
}
}

if (!function_exists('mj_quote_statuses')) {
function mj_quote_statuses(): array {
    return [
        'new'=>'New',
        'reviewed'=>'Reviewed',
        'quoted'=>'Quotation Sent',
        'follow_up'=>'Follow Up',
        'accepted'=>'Accepted',
        'rejected'=>'Rejected',
        'won'=>'Won',
        'lost'=>'Lost',
        'spam'=>'Spam',
        'closed'=>'Closed'
    ];
}
}

if (!function_exists('mj_quote_status_label')) {
function mj_quote_status_label(string $status): string { $all = mj_quote_statuses(); return $all[$status] ?? ucwords(str_replace('_',' ', $status)); }
}

if (!function_exists('mj_quote_status_class')) {
function mj_quote_status_class(string $status): string {
    return [
        'new'=>'primary','reviewed'=>'info','quoted'=>'success','follow_up'=>'warning','accepted'=>'success','rejected'=>'danger','won'=>'success','lost'=>'secondary','spam'=>'dark','closed'=>'secondary'
    ][$status] ?? 'light';
}
}

if (!function_exists('mj_quote_urgency_label')) {
function mj_quote_urgency_label(string $urgency): string {
    return ['normal'=>'Normal','urgent'=>'Urgent','this_week'=>'This week','flexible'=>'Flexible'][$urgency] ?? ucwords(str_replace('_',' ', $urgency));
}
}

if (!function_exists('mj_quote_lead_score')) {
function mj_quote_lead_score(array $data): int {
    $score = 10;
    if (!empty($data['requester_phone'])) { $score += 20; }
    if (!empty($data['requester_email']) && filter_var((string)$data['requester_email'], FILTER_VALIDATE_EMAIL)) { $score += 15; }
    if (!empty($data['budget'])) { $score += 15; }
    if (!empty($data['preferred_date'])) { $score += 10; }
    if (($data['urgency'] ?? '') === 'urgent') { $score += 20; }
    $desc = trim((string)($data['description'] ?? ''));
    $len = function_exists('mb_strlen') ? mb_strlen($desc) : strlen($desc);
    if ($len >= 50) { $score += 15; }
    if ($len >= 150) { $score += 10; }
    return max(0, min(100, $score));
}
}

if (!function_exists('mj_quote_score_label')) {
function mj_quote_score_label(int $score): string {
    if ($score >= 80) { return 'Hot Lead'; }
    if ($score >= 55) { return 'Warm Lead'; }
    if ($score >= 30) { return 'Good Lead'; }
    return 'Needs Details';
}
}

if (!function_exists('mj_quote_timeline_add')) {
function mj_quote_timeline_add(int $quoteId, string $actorType, int $actorId, string $action, string $note='', string $oldStatus='', string $newStatus=''): void {
    try {
        mj_quote_ensure_schema();
        q('INSERT INTO mj_quote_timeline(quote_id,actor_type,actor_id,action,note,old_status,new_status,created_at) VALUES(?,?,?,?,?,?,?,NOW())', [$quoteId,$actorType,$actorId ?: null,$action,$note,$oldStatus ?: null,$newStatus ?: null]);
    } catch (Throwable $e) {}
}
}


if (!function_exists('mj_quote_safe_notify_user')) {
function mj_quote_safe_notify_user(int $userId, string $type, string $title, string $message='', string $url='', string $priority='normal'): void {
    if ($userId <= 0) { return; }
    try {
        if (!table_exists('mj_notifications')) { return; }

        $hasType = mj_column_exists('mj_notifications', 'type');
        $hasMessage = mj_column_exists('mj_notifications', 'message');
        $hasBody = mj_column_exists('mj_notifications', 'body');
        $hasActionUrl = mj_column_exists('mj_notifications', 'action_url');
        $hasUrl = mj_column_exists('mj_notifications', 'url');
        $hasPriority = mj_column_exists('mj_notifications', 'priority');
        $hasAdminId = mj_column_exists('mj_notifications', 'admin_id');

        if ($hasType && $hasMessage && $hasActionUrl) {
            $cols = ['user_id'];
            $vals = [$userId];
            if ($hasAdminId) { $cols[] = 'admin_id'; $vals[] = null; }
            $cols[] = 'type'; $vals[] = $type;
            $cols[] = 'title'; $vals[] = $title;
            $cols[] = 'message'; $vals[] = $message;
            if ($hasPriority) { $cols[] = 'priority'; $vals[] = $priority; }
            $cols[] = 'action_url'; $vals[] = $url;
            $sql = 'INSERT INTO mj_notifications(`'.implode('`,`', $cols).'`,created_at) VALUES('.implode(',', array_fill(0, count($cols), '?')).',NOW())';
            q($sql, $vals);
            return;
        }

        if ($hasBody && $hasUrl) {
            q('INSERT INTO mj_notifications(user_id,title,body,url,is_read,created_at) VALUES(?,?,?,?,0,NOW())', [$userId, $title, $message, $url]);
            return;
        }

        if ($hasMessage) {
            $cols = ['user_id','title','message'];
            $vals = [$userId,$title,$message];
            if ($hasUrl) { $cols[] = 'url'; $vals[] = $url; }
            $sql = 'INSERT INTO mj_notifications(`'.implode('`,`', $cols).'`,created_at) VALUES('.implode(',', array_fill(0, count($cols), '?')).',NOW())';
            q($sql, $vals);
        }
    } catch (Throwable $e) {
        error_log('[MJ Quote Notification] '.$e->getMessage());
    }
}
}

if (!function_exists('mj_quote_fetch')) {
function mj_quote_fetch(int $quoteId): ?array {
    mj_quote_ensure_schema();
    try {
        return one('SELECT qr.*, co.name company_name, co.user_id company_user_id, co.logo company_logo, co.is_verified, co.trust_score, co.profile_score, co.email company_email, co.phone company_phone, co.whatsapp company_whatsapp, l.title listing_title, l.price listing_price, c.currency_code, cu.name country_name, ci.name city_name, u.name user_name
            FROM mj_quote_requests qr
            LEFT JOIN mj_companies co ON co.id=qr.company_id
            LEFT JOIN mj_listings l ON l.id=qr.listing_id
            LEFT JOIN mj_countries c ON c.id=qr.country_id
            LEFT JOIN mj_countries cu ON cu.id=qr.country_id
            LEFT JOIN mj_cities ci ON ci.id=qr.city_id
            LEFT JOIN mj_users u ON u.id=qr.user_id
            WHERE qr.id=? LIMIT 1', [$quoteId]);
    } catch (Throwable $e) { return null; }
}
}

if (!function_exists('mj_quote_can_view')) {
function mj_quote_can_view(array $quote, ?array $user = null, ?array $admin = null): bool {
    if ($admin) { return true; }
    if (!$user) { return false; }
    $uid = (int)($user['id'] ?? 0);
    if ($uid <= 0) { return false; }
    if ((int)($quote['user_id'] ?? 0) === $uid) { return true; }
    if ((int)($quote['company_user_id'] ?? 0) === $uid) { return true; }
    return false;
}
}

if (!function_exists('mj_quote_upload_attachment')) {
function mj_quote_upload_attachment(int $quoteId, int $userId, string $field = 'attachment'): ?string {
    if (empty($_FILES[$field]) || !is_array($_FILES[$field]) || (int)($_FILES[$field]['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) { return null; }
    $f = $_FILES[$field];
    if ((int)$f['error'] !== UPLOAD_ERR_OK) { throw new Exception('Attachment upload failed. Please try again.'); }
    $max = 8 * 1024 * 1024;
    if ((int)$f['size'] > $max) { throw new Exception('Attachment too large. Maximum allowed size is 8MB.'); }
    $name = (string)($f['name'] ?? 'attachment');
    $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
    $allowed = ['jpg','jpeg','png','webp','pdf'];
    if (!in_array($ext, $allowed, true)) { throw new Exception('Only JPG, PNG, WEBP and PDF attachments are allowed.'); }
    $dir = __DIR__ . '/../uploads/quote-attachments';
    if (!is_dir($dir)) { @mkdir($dir, 0775, true); }
    if (is_dir($dir)) {
        @chmod($dir, 0775);
        if (!file_exists($dir . '/index.html')) { @file_put_contents($dir . '/index.html', ''); }
        if (!file_exists($dir . '/.htaccess')) { @file_put_contents($dir . '/.htaccess', "Options -Indexes\n"); }
    }
    if (!is_dir($dir) || !is_writable($dir)) {
        throw new Exception('Unable to save attachment. Please set uploads/quote-attachments folder permission to 775 or 755 depending on your server owner.');
    }
    $safe = 'quote-'.$quoteId.'-'.date('YmdHis').'-'.bin2hex(random_bytes(4)).'.'.$ext;
    $dest = $dir . '/' . $safe;
    if (!move_uploaded_file((string)$f['tmp_name'], $dest)) { throw new Exception('Unable to save attachment. Please check uploads/quote-attachments folder permission.'); }
    @chmod($dest, 0644);
    $rel = 'uploads/quote-attachments/'.$safe;
    try {
        q('INSERT INTO mj_quote_attachments(quote_id,user_id,file_path,original_name,mime_type,file_size,created_at) VALUES(?,?,?,?,?,?,NOW())', [$quoteId,$userId ?: null,$rel,$name,(string)($f['type'] ?? ''),(int)($f['size'] ?? 0)]);
    } catch (Throwable $e) {}
    return $rel;
}
}

if (!function_exists('mj_quote_create_or_open_chat')) {
function mj_quote_create_or_open_chat(array $quote): int {
    if (empty($quote['user_id']) || empty($quote['company_user_id'])) { return 0; }
    try {
        if (function_exists('mj_realtime_chat_ensure_schema')) { mj_realtime_chat_ensure_schema(); }
        if (!table_exists('mj_chats')) { return 0; }
        $listingId = (int)($quote['listing_id'] ?? 0) ?: null;
        $companyId = (int)($quote['company_id'] ?? 0) ?: null;
        $buyerId = (int)$quote['user_id'];
        $sellerId = (int)$quote['company_user_id'];
        $chat = one('SELECT * FROM mj_chats WHERE IFNULL(listing_id,0)=? AND IFNULL(company_id,0)=? AND buyer_id=? AND seller_id=? LIMIT 1', [(int)$listingId,(int)$companyId,$buyerId,$sellerId]);
        if (!$chat) {
            q('INSERT INTO mj_chats(listing_id,company_id,buyer_id,seller_id,last_message,last_message_at,created_at) VALUES(?,?,?,?,?,NOW(),NOW())', [$listingId,$companyId,$buyerId,$sellerId,'Quotation request submitted']);
            $chatId = (int)db()->lastInsertId();
        } else { $chatId = (int)$chat['id']; }
        if ($chatId > 0 && table_exists('mj_chat_messages')) {
            $exists = one('SELECT id FROM mj_chat_messages WHERE chat_id=? AND message LIKE ? LIMIT 1', [$chatId, '%Quote Request #'.(int)$quote['id'].'%']);
            if (!$exists) {
                $msg = 'Quote Request #'.(int)$quote['id'].': '.(string)($quote['title'] ?? 'Quotation request')."\n".(string)($quote['description'] ?? '');
                q('INSERT INTO mj_chat_messages(chat_id,sender_id,message,risk_score,created_at) VALUES(?,?,?,?,NOW())', [$chatId,$buyerId,$msg,0]);
                q('UPDATE mj_chats SET last_message=?, last_message_at=NOW() WHERE id=?', ['Quote request submitted', $chatId]);
            }
        }
        return $chatId;
    } catch (Throwable $e) { return 0; }
}
}

if (!function_exists('mj_quote_excerpt')) {
function mj_quote_excerpt($text, int $limit = 120): string {
    $text = trim(strip_tags((string)$text));
    if ($text === '') { return ''; }
    if (function_exists('mb_strimwidth')) { return mb_strimwidth($text, 0, $limit, '...'); }
    return strlen($text) > $limit ? substr($text, 0, max(0, $limit-3)).'...' : $text;
}
}


/* MJ Marketplace v2.7.4 Offer Management Helpers */
if (!function_exists('mj_offer_ensure_schema')) {
function mj_offer_ensure_schema(): void {
    if (!mj_is_installed()) { return; }
    try {
        q("CREATE TABLE IF NOT EXISTS mj_offers (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            listing_id BIGINT NOT NULL,
            buyer_id BIGINT NOT NULL,
            seller_id BIGINT NOT NULL,
            amount DECIMAL(12,2) NOT NULL,
            currency_code VARCHAR(8) DEFAULT 'USD',
            status ENUM('pending','accepted','rejected','countered','cancelled','expired') DEFAULT 'pending',
            expires_at DATETIME NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            INDEX(listing_id),
            INDEX(buyer_id),
            INDEX(seller_id),
            INDEX(status),
            INDEX(expires_at)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    } catch (Throwable $e) {}

    $cols = [
        'message' => 'TEXT NULL',
        'counter_amount' => 'DECIMAL(12,2) NULL',
        'counter_by' => 'BIGINT NULL',
        'accepted_at' => 'DATETIME NULL',
        'rejected_at' => 'DATETIME NULL',
        'cancelled_at' => 'DATETIME NULL',
        'last_action_by' => 'BIGINT NULL',
        'last_action_at' => 'DATETIME NULL'
    ];
    foreach ($cols as $col=>$def) {
        try { if (!mj_column_exists('mj_offers', $col)) { q("ALTER TABLE mj_offers ADD COLUMN `$col` $def"); } } catch (Throwable $e) {}
    }

    try {
        q("CREATE TABLE IF NOT EXISTS mj_offer_timeline (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            offer_id BIGINT NOT NULL,
            actor_id BIGINT NULL,
            action VARCHAR(80) NOT NULL,
            note TEXT NULL,
            old_status VARCHAR(40) NULL,
            new_status VARCHAR(40) NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            INDEX(offer_id),
            INDEX(action),
            INDEX(created_at)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    } catch (Throwable $e) {}
}
}

if (!function_exists('mj_offer_statuses')) {
function mj_offer_statuses(): array {
    return [
        'pending'=>'Pending',
        'countered'=>'Countered',
        'accepted'=>'Accepted',
        'rejected'=>'Rejected',
        'cancelled'=>'Cancelled',
        'expired'=>'Expired'
    ];
}
}

if (!function_exists('mj_offer_status_label')) {
function mj_offer_status_label(string $status): string {
    $all = mj_offer_statuses();
    return $all[$status] ?? ucwords(str_replace('_',' ', $status));
}
}

if (!function_exists('mj_offer_status_class')) {
function mj_offer_status_class(string $status): string {
    return [
        'pending'=>'warning',
        'countered'=>'info',
        'accepted'=>'success',
        'rejected'=>'danger',
        'cancelled'=>'secondary',
        'expired'=>'dark'
    ][$status] ?? 'light';
}
}

if (!function_exists('mj_offer_timeline_add')) {
function mj_offer_timeline_add(int $offerId, int $actorId, string $action, string $note='', string $oldStatus='', string $newStatus=''): void {
    try {
        mj_offer_ensure_schema();
        q('INSERT INTO mj_offer_timeline(offer_id,actor_id,action,note,old_status,new_status,created_at) VALUES(?,?,?,?,?,?,NOW())', [
            $offerId, $actorId ?: null, $action, $note, $oldStatus ?: null, $newStatus ?: null
        ]);
    } catch (Throwable $e) {}
}
}

if (!function_exists('mj_offer_safe_notify_user')) {
function mj_offer_safe_notify_user(int $userId, string $type, string $title, string $message='', string $url='', string $priority='normal'): void {
    if ($userId <= 0) { return; }
    if (function_exists('mj_quote_safe_notify_user')) {
        mj_quote_safe_notify_user($userId, $type, $title, $message, $url, $priority);
        return;
    }
    try {
        if (function_exists('mj263_notify')) { mj263_notify($userId, null, $type, $title, $message, $url, $priority); return; }
        if (!table_exists('mj_notifications')) { return; }
        if (mj_column_exists('mj_notifications','type') && mj_column_exists('mj_notifications','message') && mj_column_exists('mj_notifications','action_url')) {
            if (function_exists('mj429_notify')) { mj429_notify('user',$userId,$title,$message,$type,$url,['priority'=>$priority]); } else { q('INSERT INTO mj_notifications(user_id,type,title,message,priority,action_url,created_at) VALUES(?,?,?,?,?,?,NOW())', [$userId,$type,$title,$message,$priority,$url]); }
        } elseif (mj_column_exists('mj_notifications','body') && mj_column_exists('mj_notifications','url')) {
            q('INSERT INTO mj_notifications(user_id,title,body,url,is_read,created_at) VALUES(?,?,?,?,0,NOW())', [$userId,$title,$message,$url]);
        }
    } catch (Throwable $e) { error_log('[MJ Offer Notification] '.$e->getMessage()); }
}
}

if (!function_exists('mj_offer_fetch')) {
function mj_offer_fetch(int $offerId): ?array {
    mj_offer_ensure_schema();
    try {
        return one('SELECT o.*, l.title listing_title, l.price listing_price, l.currency_code listing_currency, l.company_id, l.status listing_status, l.availability_status, l.country_id, l.city_id,
            (SELECT li.image_path FROM mj_listing_images li WHERE li.listing_id=l.id ORDER BY li.sort_order,li.id LIMIT 1) listing_image,
            b.name buyer_name, b.email buyer_email,
            s.name seller_name, s.email seller_email,
            co.name company_name, co.logo company_logo, co.is_verified company_verified
            FROM mj_offers o
            LEFT JOIN mj_listings l ON l.id=o.listing_id
            LEFT JOIN mj_users b ON b.id=o.buyer_id
            LEFT JOIN mj_users s ON s.id=o.seller_id
            LEFT JOIN mj_companies co ON co.id=l.company_id
            WHERE o.id=? LIMIT 1', [$offerId]);
    } catch (Throwable $e) { return null; }
}
}

if (!function_exists('mj_offer_can_view')) {
function mj_offer_can_view(array $offer, int $userId): bool {
    return $userId > 0 && ((int)($offer['buyer_id'] ?? 0) === $userId || (int)($offer['seller_id'] ?? 0) === $userId || current_admin());
}
}

if (!function_exists('mj_offer_chat_url')) {
function mj_offer_chat_url(array $offer): string {
    return base_url('chat.php?listing_id='.(int)($offer['listing_id'] ?? 0).'&company_id='.(int)($offer['company_id'] ?? 0).'&seller_id='.(int)($offer['seller_id'] ?? 0).'&buyer_id='.(int)($offer['buyer_id'] ?? 0));
}
}

if (!function_exists('mj_offer_create_or_open_chat')) {
function mj_offer_create_or_open_chat(array $offer, string $eventText=''): int {
    try {
        if (function_exists('mj_realtime_chat_ensure_schema')) { mj_realtime_chat_ensure_schema(); }
        if (!table_exists('mj_chats') || empty($offer['listing_id']) || empty($offer['buyer_id']) || empty($offer['seller_id'])) { return 0; }
        $listingId = (int)$offer['listing_id'];
        $companyId = (int)($offer['company_id'] ?? 0);
        $buyerId = (int)$offer['buyer_id'];
        $sellerId = (int)$offer['seller_id'];
        $chat = one('SELECT * FROM mj_chats WHERE IFNULL(listing_id,0)=? AND IFNULL(company_id,0)=? AND buyer_id=? AND seller_id=? LIMIT 1', [$listingId,$companyId,$buyerId,$sellerId]);
        if (!$chat) {
            q('INSERT INTO mj_chats(listing_id,company_id,buyer_id,seller_id,last_message,last_message_at,created_at) VALUES(?,?,?,?,?,NOW(),NOW())', [$listingId,$companyId ?: null,$buyerId,$sellerId,$eventText ?: 'Offer conversation started']);
            $chat = one('SELECT * FROM mj_chats WHERE id=? LIMIT 1', [db()->lastInsertId()]);
        }
        if ($eventText !== '' && table_exists('mj_chat_messages')) {
            $sender = (int)($offer['last_action_by'] ?? $offer['buyer_id'] ?? 0);
            q('INSERT INTO mj_chat_messages(chat_id,sender_id,message,risk_score,is_read,created_at) VALUES(?,?,?,?,0,NOW())', [(int)$chat['id'],$sender,$eventText,0]);
            q('UPDATE mj_chats SET last_message=?, last_message_at=NOW() WHERE id=?', [$eventText,(int)$chat['id']]);
        }
        return (int)($chat['id'] ?? 0);
    } catch (Throwable $e) { return 0; }
}
}

if (!function_exists('mj_offer_expire_old')) {
function mj_offer_expire_old(): void {
    try {
        if (table_exists('mj_offers')) {
            q('UPDATE mj_offers SET status="expired", updated_at=NOW() WHERE status IN ("pending","countered") AND expires_at IS NOT NULL AND expires_at<NOW()');
        }
    } catch (Throwable $e) {}
}
}


/* MJ Marketplace v2.7.5 Order Management Helpers */
if (!function_exists('mj_order_ensure_schema')) {
function mj_order_ensure_schema(): void {
    if (!mj_is_installed()) { return; }
    try {
        q("CREATE TABLE IF NOT EXISTS mj_orders (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            order_number VARCHAR(80) NULL,
            listing_id BIGINT NULL,
            company_id BIGINT NULL,
            offer_id BIGINT NULL,
            quote_id BIGINT NULL,
            source_type VARCHAR(40) NULL,
            source_id BIGINT NULL,
            buyer_id BIGINT NOT NULL,
            seller_id BIGINT NOT NULL,
            country_id INT NULL,
            amount DECIMAL(12,2) NOT NULL DEFAULT 0,
            tax_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
            total_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
            currency_code VARCHAR(8) DEFAULT 'USD',
            status VARCHAR(40) DEFAULT 'pending_payment',
            payment_status VARCHAR(40) DEFAULT 'payment_disabled',
            payment_method VARCHAR(80) NULL,
            payment_reference VARCHAR(160) NULL,
            payment_proof_path VARCHAR(255) NULL,
            payment_proof_note TEXT NULL,
            payment_submitted_at DATETIME NULL,
            payment_verified_at DATETIME NULL,
            payment_verified_by BIGINT NULL,
            payment_rejected_at DATETIME NULL,
            payment_rejection_reason TEXT NULL,
            delivery_method VARCHAR(80) NULL,
            buyer_note TEXT NULL,
            seller_note TEXT NULL,
            admin_note TEXT NULL,
            completed_at DATETIME NULL,
            cancelled_at DATETIME NULL,
            disputed_at DATETIME NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            UNIQUE KEY uniq_order_number(order_number),
            INDEX(buyer_id), INDEX(seller_id), INDEX(status), INDEX(payment_status), INDEX(source_type,source_id)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    } catch (Throwable $e) {}

    try { if (mj_column_exists('mj_orders','status')) { q("ALTER TABLE mj_orders MODIFY COLUMN status VARCHAR(40) DEFAULT 'pending_payment'"); } } catch (Throwable $e) {}
    try { if (mj_column_exists('mj_orders','payment_status')) { q("ALTER TABLE mj_orders MODIFY COLUMN payment_status VARCHAR(40) DEFAULT 'payment_disabled'"); } } catch (Throwable $e) {}

    $cols = [
        'order_number'=>'VARCHAR(80) NULL',
        'quote_id'=>'BIGINT NULL',
        'source_type'=>'VARCHAR(40) NULL',
        'source_id'=>'BIGINT NULL',
        'total_amount'=>'DECIMAL(12,2) NOT NULL DEFAULT 0',
        'payment_status'=>'VARCHAR(40) DEFAULT \'payment_disabled\'',
        'payment_method'=>'VARCHAR(80) NULL',
        'payment_reference'=>'VARCHAR(160) NULL',
        'payment_proof_path'=>'VARCHAR(255) NULL',
        'payment_proof_note'=>'TEXT NULL',
        'payment_submitted_at'=>'DATETIME NULL',
        'payment_verified_at'=>'DATETIME NULL',
        'payment_verified_by'=>'BIGINT NULL',
        'payment_rejected_at'=>'DATETIME NULL',
        'payment_rejection_reason'=>'TEXT NULL',
        'buyer_note'=>'TEXT NULL',
        'seller_note'=>'TEXT NULL',
        'admin_note'=>'TEXT NULL',
        'completed_at'=>'DATETIME NULL',
        'cancelled_at'=>'DATETIME NULL',
        'disputed_at'=>'DATETIME NULL'
    ];
    foreach($cols as $col=>$def){ try{ if(!mj_column_exists('mj_orders',$col)){ q("ALTER TABLE mj_orders ADD COLUMN `$col` $def"); } }catch(Throwable $e){} }

    try {
        q("CREATE TABLE IF NOT EXISTS mj_order_timeline (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            order_id BIGINT NOT NULL,
            actor_type VARCHAR(40) NOT NULL DEFAULT 'system',
            actor_id BIGINT NULL,
            action VARCHAR(80) NOT NULL,
            note TEXT NULL,
            old_status VARCHAR(60) NULL,
            new_status VARCHAR(60) NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            INDEX(order_id), INDEX(action), INDEX(created_at)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    } catch(Throwable $e) {}

    try {
        q("CREATE TABLE IF NOT EXISTS mj_order_payment_proofs (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            order_id BIGINT NOT NULL,
            user_id BIGINT NULL,
            amount DECIMAL(12,2) NULL,
            currency_code VARCHAR(8) NULL,
            reference_no VARCHAR(160) NULL,
            proof_path VARCHAR(255) NOT NULL,
            original_name VARCHAR(255) NULL,
            mime_type VARCHAR(120) NULL,
            file_size BIGINT NULL,
            status VARCHAR(40) DEFAULT 'submitted',
            admin_note TEXT NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            INDEX(order_id), INDEX(user_id), INDEX(status)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    } catch(Throwable $e) {}
}
}

if (!function_exists('mj_setting_set')) {
function mj_setting_set(string $key, string $value): void {
    try {
        q('INSERT INTO mj_settings(setting_key,setting_value,updated_at) VALUES(?,?,NOW()) ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value),updated_at=NOW()', [$key,$value]);
        if (!isset($GLOBALS['mj_setting_cache']) || !is_array($GLOBALS['mj_setting_cache'])) { $GLOBALS['mj_setting_cache'] = []; }
        $GLOBALS['mj_setting_cache'][$key] = $value;
    } catch(Throwable $e) {}
}
}

if (!function_exists('mj_customer_to_user_payments_disabled')) {
function mj_customer_to_user_payments_disabled(): bool {
    // v2.9.15 business rule: MJ Marketplace is not a customer-to-seller payment processor.
    // Customers may contact, enquire, offer, chat, request quotes or book outside-platform arrangements,
    // but they must not pay sellers/users through this website.
    return true;
}
}

if (!function_exists('mj_payment_scope_notice')) {
function mj_payment_scope_notice(): string {
    return 'Customer-to-seller payment is disabled across MJ Marketplace. The website shows listings, deals, enquiries, offers, quotes and contact options only. System billing is only for user-to-admin charges such as subscriptions, listing renewals, sponsored/featured placement, extra listing slots, extra company slots, wallet top-up and admin payment requests.';
}
}

if (!function_exists('mj_order_payment_settings')) {
function mj_order_payment_settings(): array {
    // Hard locked by v2.9.15: ignore old order payment settings even if an older DB has them enabled.
    return [
        'online_enabled' => false,
        'manual_enabled' => false,
        'proof_enabled' => false,
        'instructions_visible' => false,
        'admin_verify_only' => 'disabled',
        'bank_instructions' => '',
        'paynow_instructions' => '',
        'paypal_enabled' => false,
        'stripe_enabled' => false,
        'pay_at_shop_enabled' => false,
        'scope_locked' => true,
        'scope_notice' => mj_payment_scope_notice(),
    ];
}
}

if (!function_exists('mj_order_payment_available')) {
function mj_order_payment_available(): bool {
    return false;
}
}

if (!function_exists('mj_order_default_payment_status')) {
function mj_order_default_payment_status(): string {
    return 'payment_disabled';
}
}

if (!function_exists('mj_order_number_generate')) {
function mj_order_number_generate(int $id): string { return 'MJ-ORD-'.date('Y').'-'.str_pad((string)$id,6,'0',STR_PAD_LEFT); }
}

if (!function_exists('mj_order_statuses')) {
function mj_order_statuses(): array {
    return [
        'pending_payment'=>'Enquiry Created',
        'payment_submitted'=>'Enquiry Submitted',
        'paid'=>'Closed',
        'processing'=>'In Discussion',
        'ready'=>'Ready for Collection',
        'shipped'=>'Shipped',
        'completed'=>'Completed',
        'cancelled'=>'Cancelled',
        'disputed'=>'Disputed',
        'refunded'=>'Refunded'
    ];
}
}
if (!function_exists('mj_order_status_label')) { function mj_order_status_label(string $s): string { $a=mj_order_statuses(); return $a[$s] ?? ucwords(str_replace('_',' ',$s)); } }
if (!function_exists('mj_order_status_class')) { function mj_order_status_class(string $s): string { return ['pending_payment'=>'warning','payment_submitted'=>'info','paid'=>'success','processing'=>'primary','ready'=>'info','shipped'=>'primary','completed'=>'success','cancelled'=>'secondary','disputed'=>'danger','refunded'=>'dark'][$s] ?? 'light'; } }

if (!function_exists('mj_order_payment_status_label')) {
function mj_order_payment_status_label(string $s): string { return ['payment_disabled'=>'No Customer Payment','pending'=>'No Customer Payment','proof_uploaded'=>'Customer Payment Disabled','verified'=>'Customer Payment Disabled','payment_verified'=>'Customer Payment Disabled','rejected'=>'Customer Payment Disabled','refunded'=>'Customer Payment Disabled'][$s] ?? ucwords(str_replace('_',' ',$s)); }
}
if (!function_exists('mj_order_payment_status_class')) { function mj_order_payment_status_class(string $s): string { return ['payment_disabled'=>'secondary','pending'=>'secondary','proof_uploaded'=>'secondary','verified'=>'secondary','payment_verified'=>'secondary','rejected'=>'secondary','refunded'=>'secondary'][$s] ?? 'light'; } }

if (!function_exists('mj_order_timeline_add')) {
function mj_order_timeline_add(int $orderId, string $actorType, int $actorId, string $action, string $note='', string $oldStatus='', string $newStatus=''): void {
    try { mj_order_ensure_schema(); q('INSERT INTO mj_order_timeline(order_id,actor_type,actor_id,action,note,old_status,new_status,created_at) VALUES(?,?,?,?,?,?,?,NOW())', [$orderId,$actorType,$actorId?:null,$action,$note,$oldStatus?:null,$newStatus?:null]); } catch(Throwable $e) {}
}
}

if (!function_exists('mj_order_safe_notify_user')) {
function mj_order_safe_notify_user(int $userId, string $type, string $title, string $message='', string $url='', string $priority='normal'): void {
    if (function_exists('mj_offer_safe_notify_user')) { mj_offer_safe_notify_user($userId,$type,$title,$message,$url,$priority); return; }
    if (function_exists('mj_quote_safe_notify_user')) { mj_quote_safe_notify_user($userId,$type,$title,$message,$url,$priority); return; }
    try { if (function_exists('mj263_notify')) mj263_notify($userId,null,$type,$title,$message,$url,$priority); } catch(Throwable $e) {}
}
}

if (!function_exists('mj_order_fetch')) {
function mj_order_fetch(int $id): ?array {
    mj_order_ensure_schema();
    try { return one('SELECT o.*, l.title listing_title, l.price listing_price, l.company_id listing_company_id, (SELECT li.image_path FROM mj_listing_images li WHERE li.listing_id=l.id ORDER BY li.sort_order,li.id LIMIT 1) listing_image, b.name buyer_name, b.email buyer_email, s.name seller_name, s.email seller_email, co.name company_name, co.logo company_logo, co.is_verified company_verified FROM mj_orders o LEFT JOIN mj_listings l ON l.id=o.listing_id LEFT JOIN mj_users b ON b.id=o.buyer_id LEFT JOIN mj_users s ON s.id=o.seller_id LEFT JOIN mj_companies co ON co.id=COALESCE(o.company_id,l.company_id) WHERE o.id=? LIMIT 1', [$id]); } catch(Throwable $e) { return null; }
}
}

if (!function_exists('mj_order_can_view')) { function mj_order_can_view(array $o, int $uid): bool { return $uid>0 && ((int)($o['buyer_id']??0)===$uid || (int)($o['seller_id']??0)===$uid || current_admin()); } }

if (!function_exists('mj_order_chat_url')) {
function mj_order_chat_url(array $o): string { return base_url('chat.php?listing_id='.(int)($o['listing_id']??0).'&company_id='.(int)($o['company_id']??0).'&seller_id='.(int)($o['seller_id']??0).'&buyer_id='.(int)($o['buyer_id']??0)); }
}

if (!function_exists('mj_order_create_chat_event')) {
function mj_order_create_chat_event(array $o, string $text): void {
    try {
        if (function_exists('mj_realtime_chat_ensure_schema')) mj_realtime_chat_ensure_schema();
        if (!table_exists('mj_chats') || !table_exists('mj_chat_messages')) return;
        $listingId=(int)($o['listing_id']??0); $companyId=(int)($o['company_id']??0); $buyerId=(int)($o['buyer_id']??0); $sellerId=(int)($o['seller_id']??0);
        if(!$buyerId || !$sellerId) return;
        $chat=one('SELECT * FROM mj_chats WHERE IFNULL(listing_id,0)=? AND IFNULL(company_id,0)=? AND buyer_id=? AND seller_id=? LIMIT 1',[$listingId,$companyId,$buyerId,$sellerId]);
        if(!$chat){ q('INSERT INTO mj_chats(listing_id,company_id,buyer_id,seller_id,last_message,last_message_at,created_at) VALUES(?,?,?,?,?,NOW(),NOW())',[$listingId?:null,$companyId?:null,$buyerId,$sellerId,$text]); $chat=one('SELECT * FROM mj_chats WHERE id=?',[db()->lastInsertId()]); }
        q('INSERT INTO mj_chat_messages(chat_id,sender_id,message,risk_score,is_read,created_at) VALUES(?,?,?,?,0,NOW())',[(int)$chat['id'],(int)($o['last_action_by']??$buyerId),$text,0]);
        q('UPDATE mj_chats SET last_message=?,last_message_at=NOW() WHERE id=?',[$text,(int)$chat['id']]);
    } catch(Throwable $e) {}
}
}

if (!function_exists('mj_order_create_from_offer')) {
function mj_order_create_from_offer(array $offer): int {
    mj_order_ensure_schema();
    $offerId=(int)($offer['id']??0); if($offerId<=0) return 0;
    try { $existing=one('SELECT id FROM mj_orders WHERE source_type="offer" AND source_id=? LIMIT 1',[$offerId]); if($existing) return (int)$existing['id']; } catch(Throwable $e) {}
    $listingId=(int)($offer['listing_id']??0); $listing=$listingId?one('SELECT * FROM mj_listings WHERE id=? LIMIT 1',[$listingId]):null;
    $amount=(float)($offer['amount']??0); $currency=(string)($offer['currency_code']??($listing['currency_code']??'SGD'));
    $payStatus=mj_order_default_payment_status();
    q('INSERT INTO mj_orders(listing_id,company_id,offer_id,source_type,source_id,buyer_id,seller_id,country_id,amount,tax_amount,total_amount,currency_code,status,payment_status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(),NOW())',[$listingId?:null,(int)($listing['company_id']??0)?:null,$offerId,'offer',$offerId,(int)$offer['buyer_id'],(int)$offer['seller_id'],(int)($listing['country_id']??0)?:null,$amount,0,$amount,$currency,'processing',$payStatus]);
    $id=(int)db()->lastInsertId(); $num=mj_order_number_generate($id); q('UPDATE mj_orders SET order_number=? WHERE id=?',[$num,$id]);
    mj_order_timeline_add($id,'system',0,'created','Enquiry record created from accepted offer. Customer-to-seller payment is disabled on MJ Marketplace.','','processing');
    $o=mj_order_fetch($id); if($o){ mj_order_create_chat_event($o,'Offer accepted. Enquiry record '.$num.' created. MJ Marketplace does not collect customer-to-seller payments.'); }
    mj_order_safe_notify_user((int)$offer['buyer_id'],'order_created','Offer accepted',$num,'order-view.php?id='.$id,'high');
    mj_order_safe_notify_user((int)$offer['seller_id'],'order_created','Offer accepted',$num,'order-view.php?id='.$id,'high');
    return $id;
}
}

if (!function_exists('mj_order_create_from_quote')) {
function mj_order_create_from_quote(array $quote): int {
    mj_order_ensure_schema();
    $quoteId=(int)($quote['id']??0); if($quoteId<=0) return 0;
    try { $existing=one('SELECT id FROM mj_orders WHERE source_type="quote" AND source_id=? LIMIT 1',[$quoteId]); if($existing) return (int)$existing['id']; } catch(Throwable $e) {}
    $amount=(float)($quote['quoted_amount']??$quote['budget']??0); $currency=(string)($quote['currency_code']??'SGD'); $payStatus=mj_order_default_payment_status();
    q('INSERT INTO mj_orders(listing_id,company_id,quote_id,source_type,source_id,buyer_id,seller_id,country_id,amount,tax_amount,total_amount,currency_code,status,payment_status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(),NOW())',[(int)($quote['listing_id']??0)?:null,(int)($quote['company_id']??0)?:null,$quoteId,'quote',$quoteId,(int)$quote['user_id'],(int)$quote['company_user_id'],(int)($quote['country_id']??0)?:null,$amount,0,$amount,$currency,'processing',$payStatus]);
    $id=(int)db()->lastInsertId(); $num=mj_order_number_generate($id); q('UPDATE mj_orders SET order_number=? WHERE id=?',[$num,$id]);
    mj_order_timeline_add($id,'system',0,'created','Enquiry record created from accepted quotation. Customer-to-seller payment is disabled on MJ Marketplace.','','processing');
    $o=mj_order_fetch($id); if($o){ mj_order_create_chat_event($o,'Quotation accepted. Enquiry record '.$num.' created. MJ Marketplace does not collect customer-to-seller payments.'); }
    mj_order_safe_notify_user((int)$quote['user_id'],'order_created','Quotation accepted',$num,'order-view.php?id='.$id,'high');
    mj_order_safe_notify_user((int)$quote['company_user_id'],'order_created','Quotation accepted',$num,'order-view.php?id='.$id,'high');
    return $id;
}
}

if (!function_exists('mj_order_upload_payment_proof')) {
function mj_order_upload_payment_proof(int $orderId, int $userId, string $field='payment_proof'): ?string {
    throw new Exception('Customer-to-seller payment proof upload is disabled across MJ Marketplace. Use Billing & Payments only for user-to-admin fees.');
    if (empty($_FILES[$field]) || !is_array($_FILES[$field]) || (int)($_FILES[$field]['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) return null;
    $f=$_FILES[$field]; if((int)$f['error']!==UPLOAD_ERR_OK) throw new Exception('Payment proof upload failed. Please try again.');
    if((int)$f['size'] > 8*1024*1024) throw new Exception('Payment proof too large. Maximum allowed size is 8MB.');
    $name=(string)($f['name']??'proof'); $ext=strtolower(pathinfo($name,PATHINFO_EXTENSION)); $allowed=['jpg','jpeg','png','webp','pdf']; if(!in_array($ext,$allowed,true)) throw new Exception('Only JPG, PNG, WEBP and PDF payment proof files are allowed.');
    $dir=__DIR__.'/../uploads/order-payment-proofs'; if(!is_dir($dir)) @mkdir($dir,0775,true); if(is_dir($dir)){ @chmod($dir,0775); if(!file_exists($dir.'/index.html')) @file_put_contents($dir.'/index.html',''); if(!file_exists($dir.'/.htaccess')) @file_put_contents($dir.'/.htaccess',"Options -Indexes\n"); }
    if(!is_dir($dir) || !is_writable($dir)) throw new Exception('Unable to save payment proof. Please set uploads/order-payment-proofs folder permission to 775 or 755.');
    $safe='order-'.$orderId.'-'.date('YmdHis').'-'.bin2hex(random_bytes(4)).'.'.$ext; $dest=$dir.'/'.$safe;
    if(!move_uploaded_file((string)$f['tmp_name'],$dest)) throw new Exception('Unable to save payment proof. Please check upload folder permission.'); @chmod($dest,0644);
    return 'uploads/order-payment-proofs/'.$safe;
}
}


/* MJ Marketplace v2.7.6 Listing Paid Upgrade Requests */
if (!function_exists('mj_listing_paid_upgrade_ensure_schema')) {
function mj_listing_paid_upgrade_ensure_schema(): void {
    if (!mj_is_installed()) { return; }

    try {
        q("CREATE TABLE IF NOT EXISTS mj_listing_upgrade_requests (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            listing_id BIGINT NOT NULL,
            user_id BIGINT NOT NULL,
            upgrade_type VARCHAR(40) NOT NULL,
            requested_days INT NOT NULL DEFAULT 30,
            charge_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
            currency_code VARCHAR(10) NOT NULL DEFAULT 'SGD',
            payment_required TINYINT(1) NOT NULL DEFAULT 0,
            payment_status VARCHAR(50) NULL,
            status VARCHAR(50) NOT NULL DEFAULT 'pending',
            user_note TEXT NULL,
            admin_note TEXT NULL,
            reject_reason TEXT NULL,
            reviewed_by BIGINT NULL,
            requested_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            reviewed_at DATETIME NULL,
            approved_until DATETIME NULL,
            INDEX(listing_id),
            INDEX(user_id),
            INDEX(upgrade_type),
            INDEX(status),
            INDEX(requested_at)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    } catch (Throwable $e) {}

    if (table_exists('mj_listing_renewal_requests')) {
        $cols = [
            'payment_required' => 'TINYINT(1) NOT NULL DEFAULT 0',
            'payment_amount' => 'DECIMAL(12,2) NULL',
            'payment_currency' => 'VARCHAR(10) NULL',
            'payment_status' => 'VARCHAR(50) NULL',
            'user_note' => 'TEXT NULL'
        ];
        foreach ($cols as $col=>$def) {
            try { if (!mj_column_exists('mj_listing_renewal_requests', $col)) { q("ALTER TABLE mj_listing_renewal_requests ADD COLUMN `$col` $def"); } } catch (Throwable $e) {}
        }
    }

    $defaults = [
        'listing_renew_charge_enabled' => '1',
        'listing_renew_charge_amount' => '10.00',
        'listing_feature_charge_enabled' => '1',
        'listing_feature_charge_amount' => '10.00',
        'listing_sponsor_charge_enabled' => '1',
        'listing_sponsor_charge_amount' => '10.00',
        'listing_upgrade_currency' => 'SGD',
        'listing_upgrade_days' => '30',
        'listing_upgrade_payment_note' => 'Payment is not collected online. Admin will review your interest and contact you with payment instructions if payment is required.'
    ];
    foreach ($defaults as $k=>$v) {
        try {
            if (setting($k, '') === '') {
                q('INSERT INTO mj_settings(setting_key,setting_value) VALUES(?,?) ON DUPLICATE KEY UPDATE setting_value=setting_value', [$k,$v]);
            }
        } catch (Throwable $e) {}
    }
}
}

if (!function_exists('mj_listing_upgrade_settings')) {
function mj_listing_upgrade_settings(): array {
    mj_listing_paid_upgrade_ensure_schema();
    return [
        'currency' => setting('listing_upgrade_currency','SGD') ?: 'SGD',
        'days' => max(1, (int)setting('listing_upgrade_days','30')),
        'renew_enabled' => setting('listing_renew_charge_enabled','1') === '1',
        'renew_amount' => (float)setting('listing_renew_charge_amount','10.00'),
        'feature_enabled' => setting('listing_feature_charge_enabled','1') === '1',
        'feature_amount' => (float)setting('listing_feature_charge_amount','10.00'),
        'sponsor_enabled' => setting('listing_sponsor_charge_enabled','1') === '1',
        'sponsor_amount' => (float)setting('listing_sponsor_charge_amount','10.00'),
        'payment_note' => setting('listing_upgrade_payment_note','Payment is not collected online. Admin will review your interest and contact you with payment instructions if payment is required.')
    ];
}
}

if (!function_exists('mj_listing_upgrade_type_label')) {
function mj_listing_upgrade_type_label(string $type): string {
    return [
        'renew' => 'Renew Listing',
        'feature' => 'Feature Listing',
        'sponsor' => 'Sponsor Listing'
    ][$type] ?? ucwords(str_replace('_',' ', $type));
}
}

if (!function_exists('mj_listing_upgrade_charge')) {
function mj_listing_upgrade_charge(string $type): array {
    $s = mj_listing_upgrade_settings();
    if ($type === 'renew') { return ['enabled'=>$s['renew_enabled'], 'amount'=>$s['renew_amount'], 'currency'=>$s['currency'], 'days'=>$s['days']]; }
    if ($type === 'feature') { return ['enabled'=>$s['feature_enabled'], 'amount'=>$s['feature_amount'], 'currency'=>$s['currency'], 'days'=>$s['days']]; }
    if ($type === 'sponsor') { return ['enabled'=>$s['sponsor_enabled'], 'amount'=>$s['sponsor_amount'], 'currency'=>$s['currency'], 'days'=>$s['days']]; }
    return ['enabled'=>false, 'amount'=>0, 'currency'=>$s['currency'], 'days'=>$s['days']];
}
}

if (!function_exists('mj_listing_pending_upgrade')) {
function mj_listing_pending_upgrade(int $listingId, string $type): ?array {
    try {
        mj_listing_paid_upgrade_ensure_schema();
        if ($type === 'renew' && table_exists('mj_listing_renewal_requests')) {
            return one('SELECT * FROM mj_listing_renewal_requests WHERE listing_id=? AND status IN ("pending","pending_payment","payment_uploaded") ORDER BY id DESC LIMIT 1', [$listingId]);
        }
        return one('SELECT * FROM mj_listing_upgrade_requests WHERE listing_id=? AND upgrade_type=? AND status IN ("pending","pending_payment","payment_uploaded") ORDER BY id DESC LIMIT 1', [$listingId,$type]);
    } catch (Throwable $e) { return null; }
}
}

if (!function_exists('mj_listing_upgrade_status_badge')) {
function mj_listing_upgrade_status_badge(string $status): string {
    return [
        'pending'=>'warning',
        'pending_payment'=>'info',
        'payment_uploaded'=>'primary',
        'approved'=>'success',
        'rejected'=>'danger',
        'cancelled'=>'secondary'
    ][$status] ?? 'secondary';
}
}


/* MJ Marketplace v2.7.7 User Limits + Listing Expiry Notifications */
if (!function_exists('mj_user_limits_ensure_schema')) {
function mj_user_limits_ensure_schema(): void {
    if (!mj_is_installed()) { return; }
    try {
        q("CREATE TABLE IF NOT EXISTS mj_user_limits (
            user_id BIGINT NOT NULL PRIMARY KEY,
            max_listings INT NULL,
            max_companies INT NULL,
            note TEXT NULL,
            updated_by BIGINT NULL,
            updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    } catch (Throwable $e) {}

    $defaults = [
        'default_max_listings_per_user' => '5',
        'default_max_companies_per_user' => '1',
        'listing_expiry_notify_enabled' => '1',
        'listing_expiry_email_enabled' => '1',
        'listing_expiry_notify_days_before' => '7,3,1,0',
        'listing_expiry_auto_renew_link_enabled' => '1'
    ];
    foreach ($defaults as $k=>$v) {
        try {
            if (setting($k, '') === '') {
                q('INSERT INTO mj_settings(setting_key,setting_value) VALUES(?,?) ON DUPLICATE KEY UPDATE setting_value=setting_value', [$k,$v]);
            }
        } catch (Throwable $e) {}
    }
}
}

if (!function_exists('mj_user_limits_get')) {
function mj_user_limits_get(int $userId): array {
    mj_user_limits_ensure_schema();
    $defaultListings = max(0, (int)setting('default_max_listings_per_user','5'));
    $defaultCompanies = max(0, (int)setting('default_max_companies_per_user','1'));
    $row = null;
    try { $row = one('SELECT * FROM mj_user_limits WHERE user_id=? LIMIT 1', [$userId]); } catch (Throwable $e) {}
    return [
        'max_listings' => isset($row['max_listings']) && $row['max_listings'] !== null && $row['max_listings'] !== '' ? (int)$row['max_listings'] : $defaultListings,
        'max_companies' => isset($row['max_companies']) && $row['max_companies'] !== null && $row['max_companies'] !== '' ? (int)$row['max_companies'] : $defaultCompanies,
        'note' => (string)($row['note'] ?? '')
    ];
}
}

if (!function_exists('mj_user_active_listing_count')) {
function mj_user_active_listing_count(int $userId): int {
    try {
        return safe_count('SELECT COUNT(*) c FROM mj_listings WHERE user_id=? AND IFNULL(is_deleted,0)=0 AND status NOT IN ("deleted","rejected")', [$userId]);
    } catch (Throwable $e) { return 0; }
}
}

if (!function_exists('mj_user_company_count')) {
function mj_user_company_count(int $userId): int {
    try {
        return safe_count('SELECT COUNT(*) c FROM mj_companies WHERE user_id=? AND status NOT IN ("deleted","rejected")', [$userId]);
    } catch (Throwable $e) { return 0; }
}
}

if (!function_exists('mj_user_can_add_listing')) {
function mj_user_can_add_listing(int $userId): array {
    $l = mj_user_limits_get($userId);
    $used = mj_user_active_listing_count($userId);
    $max = (int)$l['max_listings'];
    return [
        'allowed' => ($max <= 0 || $used < $max),
        'used' => $used,
        'max' => $max,
        'message' => $max > 0 ? 'You have used '.$used.' of '.$max.' listing slots.' : 'Unlimited listing slots.'
    ];
}
}

if (!function_exists('mj_user_can_add_company')) {
function mj_user_can_add_company(int $userId): array {
    $l = mj_user_limits_get($userId);
    $used = mj_user_company_count($userId);
    $max = (int)$l['max_companies'];
    return [
        'allowed' => ($max <= 0 || $used < $max),
        'used' => $used,
        'max' => $max,
        'message' => $max > 0 ? 'You have used '.$used.' of '.$max.' company slots.' : 'Unlimited company slots.'
    ];
}
}

if (!function_exists('mj_listing_expiry_notify_days')) {
function mj_listing_expiry_notify_days(): array {
    $raw = (string)setting('listing_expiry_notify_days_before','7,3,1,0');
    $days = [];
    foreach (explode(',', $raw) as $d) {
        $n = (int)trim($d);
        if ($n >= 0) { $days[] = $n; }
    }
    $days = array_values(array_unique($days));
    sort($days);
    return $days ?: [7,3,1,0];
}
}

if (!function_exists('mj_listing_send_expiry_notification')) {
function mj_listing_send_expiry_notification(array $listing, int $daysLeft, bool $expired=false): void {
    if (setting('listing_expiry_notify_enabled','1') !== '1') { return; }
    $userId = (int)($listing['user_id'] ?? 0);
    if ($userId <= 0) { return; }
    $id = (int)($listing['id'] ?? 0);
    $title = (string)($listing['title'] ?? 'Your listing');
    $expiry = (string)($listing['expires_at'] ?? '');
    $key = $expired ? 'listing_expired_user' : 'listing_expiring_user_'.$daysLeft;
    $subject = $expired ? 'Your listing has expired' : ('Your listing expires '.($daysLeft === 0 ? 'today' : 'in '.$daysLeft.' day'.($daysLeft===1?'':'s')));
    $message = $expired
        ? 'Your listing "'.$title.'" has expired. You can request renewal from My Listings. Expiry date will change only after admin approval.'
        : 'Your listing "'.$title.'" will expire '.($daysLeft === 0 ? 'today' : 'in '.$daysLeft.' day'.($daysLeft===1?'':'s')).'. You can request renewal from My Listings.';
    $url = 'my-listings.php';

    $email = setting('listing_expiry_email_enabled','1') === '1';
    mj263_notify($userId, null, $key, $subject, $message, $url, $expired ? 'high' : 'normal', $email);

    try {
        mj_create_admin_alert([
            'alert_key' => ($expired ? 'user_listing_expired_' : 'user_listing_expiring_').$id.'_d'.$daysLeft.'_'.date('Ymd'),
            'alert_type' => $expired ? 'listing_expired' : 'listing_expiring',
            'module_name' => 'listings',
            'priority' => $expired ? 'high' : 'normal',
            'title' => $subject,
            'message' => $message,
            'action_url' => 'listing-edit.php?id='.$id,
            'action_label' => 'Review',
            'related_type' => 'listing',
            'related_id' => $id
        ]);
    } catch (Throwable $e) {}
}
}


/* MJ Marketplace v2.7.8 Upgrade Payment Methods + Extra Slot Requests */
if (!function_exists('mj_upgrade_payment_ensure_schema')) {
function mj_upgrade_payment_ensure_schema(): void {
    if (!mj_is_installed()) { return; }

    try {
        q("CREATE TABLE IF NOT EXISTS mj_upgrade_payment_proofs (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            request_type VARCHAR(50) NOT NULL,
            request_id BIGINT NOT NULL,
            user_id BIGINT NOT NULL,
            amount DECIMAL(12,2) NOT NULL DEFAULT 0,
            currency_code VARCHAR(10) NOT NULL DEFAULT 'SGD',
            payment_method VARCHAR(80) NULL,
            reference_no VARCHAR(190) NULL,
            proof_path VARCHAR(500) NULL,
            original_name VARCHAR(255) NULL,
            mime_type VARCHAR(120) NULL,
            file_size BIGINT NULL,
            note TEXT NULL,
            status VARCHAR(50) NOT NULL DEFAULT 'submitted',
            admin_note TEXT NULL,
            reviewed_by BIGINT NULL,
            reviewed_at DATETIME NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            INDEX(request_type, request_id),
            INDEX(user_id),
            INDEX(status)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    } catch (Throwable $e) {}

    try {
        q("CREATE TABLE IF NOT EXISTS mj_user_limit_upgrade_requests (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            user_id BIGINT NOT NULL,
            request_type VARCHAR(40) NOT NULL,
            requested_slots INT NOT NULL DEFAULT 1,
            current_limit INT NULL,
            requested_limit INT NULL,
            charge_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
            currency_code VARCHAR(10) NOT NULL DEFAULT 'SGD',
            payment_required TINYINT(1) NOT NULL DEFAULT 0,
            payment_status VARCHAR(50) NULL,
            status VARCHAR(50) NOT NULL DEFAULT 'pending_payment',
            user_note TEXT NULL,
            admin_note TEXT NULL,
            reject_reason TEXT NULL,
            reviewed_by BIGINT NULL,
            requested_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            reviewed_at DATETIME NULL,
            INDEX(user_id),
            INDEX(request_type),
            INDEX(status)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    } catch (Throwable $e) {}

    $defaults = [
        'upgrade_payment_bank_enabled' => '0',
        'upgrade_payment_bank_label' => 'Bank Transfer',
        'upgrade_payment_bank_details' => '',
        'upgrade_payment_paynow_enabled' => '0',
        'upgrade_payment_paynow_label' => 'PayNow / QR',
        'upgrade_payment_paynow_details' => '',
        'upgrade_payment_paypal_enabled' => '0',
        'upgrade_payment_paypal_label' => 'PayPal',
        'upgrade_payment_paypal_details' => '',
        'upgrade_payment_cash_enabled' => '0',
        'upgrade_payment_cash_label' => 'Pay at Shop / Cash',
        'upgrade_payment_cash_details' => '',
        'upgrade_payment_proof_upload_enabled' => '1',
        'extra_listing_slot_charge_amount' => '5.00',
        'extra_company_slot_charge_amount' => '20.00',
        'extra_slot_currency' => 'SGD'
    ];
    foreach ($defaults as $k=>$v) {
        try {
            if (setting($k, '') === '') {
                q('INSERT INTO mj_settings(setting_key,setting_value) VALUES(?,?) ON DUPLICATE KEY UPDATE setting_value=setting_value', [$k,$v]);
            }
        } catch (Throwable $e) {}
    }
}
}

if (!function_exists('mj_upgrade_payment_methods')) {
function mj_upgrade_payment_methods(): array {
    mj_upgrade_payment_ensure_schema();
    $methods = [];
    foreach ([
        'bank' => ['enabled'=>'upgrade_payment_bank_enabled','label'=>'upgrade_payment_bank_label','details'=>'upgrade_payment_bank_details','icon'=>'bi-bank'],
        'paynow' => ['enabled'=>'upgrade_payment_paynow_enabled','label'=>'upgrade_payment_paynow_label','details'=>'upgrade_payment_paynow_details','icon'=>'bi-qr-code'],
        'paypal' => ['enabled'=>'upgrade_payment_paypal_enabled','label'=>'upgrade_payment_paypal_label','details'=>'upgrade_payment_paypal_details','icon'=>'bi-paypal'],
        'cash' => ['enabled'=>'upgrade_payment_cash_enabled','label'=>'upgrade_payment_cash_label','details'=>'upgrade_payment_cash_details','icon'=>'bi-cash-coin'],
    ] as $key=>$cfg) {
        if (setting($cfg['enabled'],'0') === '1') {
            $methods[$key] = [
                'key'=>$key,
                'label'=>setting($cfg['label'], ucwords($key)),
                'details'=>setting($cfg['details'], ''),
                'icon'=>$cfg['icon']
            ];
        }
    }
    return $methods;
}
}

if (!function_exists('mj_upgrade_payment_methods_html')) {
function mj_upgrade_payment_methods_html(): string {
    $methods = mj_upgrade_payment_methods();
    if (!$methods) {
        return '<div class="alert alert-warning rounded-4 mb-0"><b>No payment method active yet.</b><br>Admin has not activated payment instructions for upgrades. Please wait for admin confirmation.</div>';
    }
    $html = '<div class="mj-upgrade-pay-methods">';
    foreach ($methods as $m) {
        $html .= '<div class="mj-upgrade-pay-method"><div class="fw-bold"><i class="bi '.e($m['icon']).' me-1"></i>'.e($m['label']).'</div>';
        if (trim((string)$m['details']) !== '') { $html .= '<div class="small text-muted mt-1">'.nl2br(e($m['details'])).'</div>'; }
        $html .= '</div>';
    }
    $html .= '</div>';
    return $html;
}
}

if (!function_exists('mj_upgrade_request_amount')) {
function mj_upgrade_request_amount(string $requestType, int $requestId): array {
    mj_upgrade_payment_ensure_schema();
    try {
        if ($requestType === 'renewal') {
            $r = one('SELECT payment_amount amount, payment_currency currency, user_id, payment_status FROM mj_listing_renewal_requests WHERE id=? LIMIT 1', [$requestId]);
            return ['amount'=>(float)($r['amount'] ?? 0), 'currency'=>($r['currency'] ?: setting('listing_upgrade_currency','SGD')), 'user_id'=>(int)($r['user_id'] ?? 0), 'payment_status'=>(string)($r['payment_status'] ?? '')];
        }
        if ($requestType === 'listing_upgrade') {
            $r = one('SELECT charge_amount amount, currency_code currency, user_id, payment_status FROM mj_listing_upgrade_requests WHERE id=? LIMIT 1', [$requestId]);
            return ['amount'=>(float)($r['amount'] ?? 0), 'currency'=>($r['currency'] ?: setting('listing_upgrade_currency','SGD')), 'user_id'=>(int)($r['user_id'] ?? 0), 'payment_status'=>(string)($r['payment_status'] ?? '')];
        }
        if ($requestType === 'limit_upgrade') {
            $r = one('SELECT charge_amount amount, currency_code currency, user_id, payment_status FROM mj_user_limit_upgrade_requests WHERE id=? LIMIT 1', [$requestId]);
            return ['amount'=>(float)($r['amount'] ?? 0), 'currency'=>($r['currency'] ?: setting('extra_slot_currency','SGD')), 'user_id'=>(int)($r['user_id'] ?? 0), 'payment_status'=>(string)($r['payment_status'] ?? '')];
        }
    } catch (Throwable $e) {}
    return ['amount'=>0, 'currency'=>'SGD', 'user_id'=>0, 'payment_status'=>''];
}
}

if (!function_exists('mj_upgrade_upload_payment_proof')) {
function mj_upgrade_upload_payment_proof(string $requestType, int $requestId, int $userId, string $field='payment_proof'): ?string {
    if (empty($_FILES[$field]) || (int)($_FILES[$field]['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) { return null; }
    $f = $_FILES[$field];
    if ((int)$f['error'] !== UPLOAD_ERR_OK) { throw new Exception('Payment proof upload failed. Please try again.'); }
    if ((int)$f['size'] > 8*1024*1024) { throw new Exception('Payment proof too large. Maximum allowed size is 8MB.'); }
    $ext = strtolower(pathinfo((string)$f['name'], PATHINFO_EXTENSION));
    if (!in_array($ext, ['jpg','jpeg','png','webp','pdf'], true)) { throw new Exception('Allowed proof files: JPG, PNG, WEBP or PDF only.'); }
    $dir = __DIR__.'/../uploads/upgrade-payment-proofs';
    if (!is_dir($dir)) { @mkdir($dir, 0775, true); }
    if (!is_dir($dir) || !is_writable($dir)) { throw new Exception('Unable to save payment proof. Please check uploads/upgrade-payment-proofs permission.'); }
    if (!file_exists($dir.'/index.html')) { @file_put_contents($dir.'/index.html', ''); }
    if (!file_exists($dir.'/.htaccess')) { @file_put_contents($dir.'/.htaccess', "Options -Indexes\n"); }
    $safe = $requestType.'-'.$requestId.'-'.$userId.'-'.date('YmdHis').'-'.bin2hex(random_bytes(4)).'.'.$ext;
    $dest = $dir.'/'.$safe;
    if (!move_uploaded_file((string)$f['tmp_name'], $dest)) { throw new Exception('Unable to save payment proof file.'); }
    @chmod($dest, 0644);
    return 'uploads/upgrade-payment-proofs/'.$safe;
}
}

if (!function_exists('mj_upgrade_payment_submit')) {
function mj_upgrade_payment_submit(string $requestType, int $requestId, int $userId): void {
    mj_upgrade_payment_ensure_schema();
    if (setting('upgrade_payment_proof_upload_enabled','1') !== '1') { throw new Exception('Payment proof upload is disabled by admin.'); }
    if (!mj_user_feature_enabled('payment_proof_upload', $userId)) { throw new Exception('Payment proof upload is not enabled for your account. Please contact admin.'); }
    $info = mj_upgrade_request_amount($requestType, $requestId);
    if ((int)$info['user_id'] !== $userId) { throw new Exception('Payment request not found or access denied.'); }
    $method = trim((string)($_POST['payment_method'] ?? ''));
    $ref = trim((string)($_POST['payment_reference'] ?? ''));
    $note = trim((string)($_POST['payment_note'] ?? ''));
    if ($method === '') { throw new Exception('Please select payment method.'); }
    if ($ref === '') { throw new Exception('Please enter payment/reference number.'); }
    $proof = mj_upgrade_upload_payment_proof($requestType, $requestId, $userId, 'payment_proof');
    if (!$proof) { throw new Exception('Please upload payment proof.'); }

    q('INSERT INTO mj_upgrade_payment_proofs(request_type,request_id,user_id,amount,currency_code,payment_method,reference_no,proof_path,original_name,mime_type,file_size,note,status,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,"submitted",NOW())', [
        $requestType, $requestId, $userId, (float)$info['amount'], $info['currency'], $method, $ref, $proof,
        $_FILES['payment_proof']['name'] ?? '', $_FILES['payment_proof']['type'] ?? '', (int)($_FILES['payment_proof']['size'] ?? 0), $note
    ]);

    if ($requestType === 'renewal') {
        q('UPDATE mj_listing_renewal_requests SET payment_status="payment_uploaded", status=IF(status="pending_payment","payment_uploaded",status) WHERE id=? AND user_id=?', [$requestId,$userId]);
    } elseif ($requestType === 'listing_upgrade') {
        q('UPDATE mj_listing_upgrade_requests SET payment_status="payment_uploaded", status=IF(status="pending_payment","payment_uploaded",status) WHERE id=? AND user_id=?', [$requestId,$userId]);
    } elseif ($requestType === 'limit_upgrade') {
        q('UPDATE mj_user_limit_upgrade_requests SET payment_status="payment_uploaded", status="payment_uploaded" WHERE id=? AND user_id=?', [$requestId,$userId]);
    }
    try {
        mj_create_admin_alert([
            'alert_key'=>'upgrade_payment_'.$requestType.'_'.$requestId.'_'.date('YmdHis'),
            'alert_type'=>'upgrade_payment',
            'module_name'=>'payments',
            'priority'=>'high',
            'title'=>'Upgrade payment proof uploaded',
            'message'=>'User uploaded payment proof for '.$requestType.' request #'.$requestId,
            'action_url'=>'payment-center.php?tab=review',
            'action_label'=>'Review Payment',
            'related_type'=>$requestType,
            'related_id'=>$requestId
        ]);
    } catch (Throwable $e) {}
}
}


/* MJ Marketplace v2.7.9 Automation Center + Invoice + Frontend UX Helpers */
if (!function_exists('mj_automation_ensure_schema')) {
function mj_automation_ensure_schema(): void {
    if (!mj_is_installed()) { return; }
    try {
        q("CREATE TABLE IF NOT EXISTS mj_automation_settings (
            automation_key VARCHAR(120) NOT NULL PRIMARY KEY,
            automation_group VARCHAR(80) NOT NULL DEFAULT 'general',
            title VARCHAR(190) NOT NULL,
            description TEXT NULL,
            is_enabled TINYINT(1) NOT NULL DEFAULT 1,
            notify_user TINYINT(1) NOT NULL DEFAULT 1,
            notify_admin TINYINT(1) NOT NULL DEFAULT 1,
            email_user TINYINT(1) NOT NULL DEFAULT 0,
            run_interval_minutes INT NOT NULL DEFAULT 1440,
            repeat_limit_per_day INT NOT NULL DEFAULT 1,
            config_json TEXT NULL,
            last_run_at DATETIME NULL,
            updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    } catch (Throwable $e) {}

    try {
        q("CREATE TABLE IF NOT EXISTS mj_automation_logs (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            automation_key VARCHAR(120) NOT NULL,
            automation_group VARCHAR(80) NULL,
            target_type VARCHAR(80) NULL,
            target_id BIGINT NULL,
            user_id BIGINT NULL,
            company_id BIGINT NULL,
            listing_id BIGINT NULL,
            action_taken VARCHAR(190) NULL,
            message TEXT NULL,
            status VARCHAR(40) NOT NULL DEFAULT 'success',
            error_message TEXT NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            INDEX(automation_key),
            INDEX(automation_group),
            INDEX(target_type,target_id),
            INDEX(user_id),
            INDEX(company_id),
            INDEX(listing_id),
            INDEX(created_at)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    } catch (Throwable $e) {}

    $defs = [
        ['listing_expiry','listing','Listing expiry reminders','Notify users/admin before listing expiry and after expiry.',1,1,1,1,1440,'{"days":"7,3,1,0"}'],
        ['listing_auto_expire','listing','Auto mark expired listings','Mark expired listings based on expires_at.',1,1,1,1,60,'{}'],
        ['listing_quality_reminder','listing','Listing quality reminders','Remind owners to add photo, price, location and better description.',1,1,0,0,1440,'{"min_description":40}'],
        ['listing_upgrade_suggestion','listing','Listing upgrade suggestions','Suggest feature/sponsor when listings get strong activity.',1,1,0,0,1440,'{"views":50,"offers":3,"engagement":10}'],
        ['featured_sponsor_expiry','listing_upgrade','Featured/sponsored expiry','Notify and remove featured/sponsored badges after expiry.',1,1,1,1,1440,'{"days":"7,3,1,0"}'],
        ['company_profile_score','company','Company profile score update','Recalculate company profile/trust score.',1,0,0,0,1440,'{}'],
        ['company_profile_reminder','company','Company profile completion reminders','Remind companies to add logo, contact, services and listings.',1,1,0,0,1440,'{"min_score":70}'],
        ['company_verification_expiry','company','Company verification expiry','Notify and update company verification expiry.',1,1,1,1,1440,'{"days":"14,7,1,0"}'],
        ['company_unanswered_leads','company','Company unanswered lead reminders','Remind company owners for unanswered quotes/chats.',1,1,1,0,120,'{"hours":24}'],
        ['quote_unanswered','quote','Unanswered quote reminders','Remind company when a quote request is unanswered.',1,1,1,0,120,'{"hours":24}'],
        ['offer_response_reminder','offer','Offer response reminders','Remind seller/buyer on pending offers and counter offers.',1,1,0,0,360,'{"hours":24}'],
        ['offer_auto_expire','offer','Offer auto expiry','Mark old pending/countered offers expired.',1,1,0,0,360,'{}'],
        ['order_status_reminder','order','Order action reminders','Remind parties when orders need action.',1,1,1,0,1440,'{"hours":48}'],
        ['upgrade_payment_reminder','payment','Upgrade payment reminders','Remind users/admin for pending upgrade payment proof and review.',1,1,1,1,1440,'{"hours":24}'],
        ['user_limit_monitor','limits','User limit monitor','Alert admin when users reach listing/company limits.',1,1,1,0,1440,'{}'],
        ['admin_daily_digest','admin','Admin daily digest','Create daily admin summary for pending marketplace actions.',1,0,1,1,1440,'{}'],
        ['safety_duplicate_listing','safety','Duplicate listing check','Alert admin for possible duplicate listings.',1,0,1,0,1440,'{}'],
        ['safety_suspicious_keywords','safety','Suspicious keyword check','Alert admin for risky words in listings/quotes/chats.',1,0,1,0,1440,'{"words":"otp,password,bank login,crypto,pay outside,urgent transfer"}']
    ];
    foreach($defs as $d){
        try {
            q('INSERT INTO mj_automation_settings(automation_key,automation_group,title,description,is_enabled,notify_user,notify_admin,email_user,run_interval_minutes,config_json,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,NOW()) ON DUPLICATE KEY UPDATE title=VALUES(title),description=VALUES(description)', $d);
        } catch(Throwable $e) {}
    }

    $invoiceDefaults = [
        'invoice_prefix'=>'MJ-INV',
        'invoice_seller_name'=>setting('site_name','MJ Marketplace'),
        'invoice_seller_address'=>'',
        'invoice_seller_email'=>'',
        'invoice_tax_label'=>'Tax',
        'invoice_tax_rate'=>'0',
        'invoice_footer_note'=>'Thank you for using MJ Marketplace.',
        'invoice_show_payment_note'=>'1'
    ];
    foreach($invoiceDefaults as $k=>$v){
        try { if(setting($k,'')===''){ q('INSERT INTO mj_settings(setting_key,setting_value) VALUES(?,?) ON DUPLICATE KEY UPDATE setting_value=setting_value',[$k,$v]); } } catch(Throwable $e){}
    }
}
}

if (!function_exists('mj_automation_parse_json')) {
function mj_automation_parse_json($raw): array {
    $j = json_decode((string)$raw, true);
    return is_array($j) ? $j : [];
}
}
if (!function_exists('mj_automation_days')) {
function mj_automation_days(array $cfg, array $fallback=[7,3,1,0]): array {
    $raw = (string)($cfg['days'] ?? implode(',', $fallback));
    $days=[]; foreach(explode(',',$raw) as $d){ $n=(int)trim($d); if($n>=0){$days[]=$n;} }
    $days=array_values(array_unique($days)); sort($days); return $days ?: $fallback;
}
}
if (!function_exists('mj_automation_log')) {
function mj_automation_log(string $key, string $group, string $targetType, int $targetId, string $action, string $message='', string $status='success', string $error='', array $extra=[]): void {
    try {
        mj_automation_ensure_schema();
        q('INSERT INTO mj_automation_logs(automation_key,automation_group,target_type,target_id,user_id,company_id,listing_id,action_taken,message,status,error_message,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,NOW())', [
            $key,$group,$targetType,$targetId,
            $extra['user_id'] ?? null,
            $extra['company_id'] ?? null,
            $extra['listing_id'] ?? null,
            $action,$message,$status,$error
        ]);
    } catch(Throwable $e) {}
}
}
if (!function_exists('mj_automation_already_done')) {
function mj_automation_already_done(string $key, string $targetType, int $targetId, string $action, int $hours=24): bool {
    try {
        $r=one('SELECT id FROM mj_automation_logs WHERE automation_key=? AND target_type=? AND target_id=? AND action_taken=? AND created_at>=DATE_SUB(NOW(), INTERVAL ? HOUR) LIMIT 1',[$key,$targetType,$targetId,$action,$hours]);
        return !empty($r);
    } catch(Throwable $e){ return false; }
}
}
if (!function_exists('mj_automation_notify_user')) {
function mj_automation_notify_user(array $s, int $userId, string $type, string $title, string $message, string $url=''): void {
    if($userId<=0){return;}
    if((int)($s['notify_user']??1)===1 || (int)($s['email_user']??0)===1){
        mj263_notify($userId,null,$type,$title,$message,$url,'normal',(int)($s['email_user']??0)===1);
    }
}
}
if (!function_exists('mj_automation_admin_alert')) {
function mj_automation_admin_alert(array $s, string $key, string $title, string $message, string $url='', string $targetType='', int $targetId=0, string $priority='normal'): void {
    if((int)($s['notify_admin']??1)!==1){return;}
    try {
        mj_create_admin_alert([
            'alert_key'=>$key.'_'.($targetId?:abs(crc32($title.$message))).'_'.date('Ymd'),
            'alert_type'=>$key,
            'module_name'=>'automation',
            'priority'=>$priority,
            'title'=>$title,
            'message'=>$message,
            'action_url'=>$url,
            'action_label'=>'Open',
            'related_type'=>$targetType,
            'related_id'=>$targetId
        ]);
    } catch(Throwable $e){}
}
}
if (!function_exists('mj_automation_should_run')) {
function mj_automation_should_run(array $s, bool $force=false): bool {
    if($force){return true;}
    if((int)($s['is_enabled']??0)!==1){return false;}
    if(empty($s['last_run_at'])){return true;}
    return strtotime((string)$s['last_run_at']) <= time() - (max(1,(int)($s['run_interval_minutes']??1440))*60);
}
}
if (!function_exists('mj_automation_finish_run')) {
function mj_automation_finish_run(string $key): void {
    try { q('UPDATE mj_automation_settings SET last_run_at=NOW() WHERE automation_key=?',[$key]); } catch(Throwable $e){}
}
}

if (!function_exists('mj_automation_run_one')) {
function mj_automation_run_one(array $s, bool $force=false): array {
    $key=(string)$s['automation_key']; $group=(string)$s['automation_group']; $cfg=mj_automation_parse_json($s['config_json']??'');
    $result=['key'=>$key,'actions'=>0,'errors'=>0];
    if(!mj_automation_should_run($s,$force)){ return $result; }

    try {
        if($key==='listing_auto_expire'){
            $rows=safe_all_rows_public("SELECT * FROM mj_listings WHERE expires_at IS NOT NULL AND expires_at<NOW() AND status NOT IN ('expired','deleted') LIMIT 500");
            foreach($rows as $r){
                q('UPDATE mj_listings SET status="expired", expired_at=NOW() WHERE id=?',[(int)$r['id']]);
                mj_listing_log((int)$r['id'],'automation_expired_listing',$r,[]);
                mj_automation_notify_user($s,(int)$r['user_id'],'listing_expired','Listing expired','Your listing "'.($r['title']??'Listing').'" has expired. You may request renewal.','my-listings.php');
                mj_automation_admin_alert($s,'listing_expired','Listing expired','Listing expired: '.($r['title']??''),'listing-edit.php?id='.(int)$r['id'],'listing',(int)$r['id']);
                mj_automation_log($key,$group,'listing',(int)$r['id'],'expired','Listing marked expired','success','',['user_id'=>(int)$r['user_id'],'listing_id'=>(int)$r['id']]);
                $result['actions']++;
            }
        } elseif($key==='listing_expiry'){
            foreach(mj_automation_days($cfg) as $days){
                if($days===0){ continue; }
                $rows=safe_all_rows_public("SELECT * FROM mj_listings WHERE expires_at IS NOT NULL AND DATE(expires_at)=DATE(DATE_ADD(NOW(), INTERVAL ? DAY)) AND status IN ('active','approved','published') LIMIT 500",[$days]);
                foreach($rows as $r){
                    $action='expiry_reminder_'.$days;
                    if(mj_automation_already_done($key,'listing',(int)$r['id'],$action,22)){continue;}
                    $msg='Your listing "'.($r['title']??'Listing').'" expires in '.$days.' day'.($days===1?'':'s').'. You can request renewal from My Listings.';
                    mj_automation_notify_user($s,(int)$r['user_id'],'listing_expiry_reminder','Listing expiring soon',$msg,'my-listings.php');
                    mj_automation_admin_alert($s,'listing_expiry','Listing expiring soon',$msg,'listing-edit.php?id='.(int)$r['id'],'listing',(int)$r['id']);
                    mj_automation_log($key,$group,'listing',(int)$r['id'],$action,$msg,'success','',['user_id'=>(int)$r['user_id'],'listing_id'=>(int)$r['id']]);
                    $result['actions']++;
                }
            }
        } elseif($key==='listing_quality_reminder'){
            $min=(int)($cfg['min_description']??40);
            $rows=safe_all_rows_public("SELECT l.*, (SELECT COUNT(*) FROM mj_listing_images im WHERE im.listing_id=l.id) image_count FROM mj_listings l WHERE l.status IN ('active','approved','published','pending') ORDER BY l.id DESC LIMIT 500");
            foreach($rows as $r){
                $missing=[];
                if((int)($r['image_count']??0)===0){$missing[]='photo';}
                if((float)($r['price']??0)<=0){$missing[]='price';}
                if(empty($r['city_id'])){$missing[]='city/location';}
                if(strlen(strip_tags((string)($r['description']??'')))<$min){$missing[]='description';}
                if($missing && !mj_automation_already_done($key,'listing',(int)$r['id'],'quality_reminder',48)){
                    $msg='Improve your listing "'.($r['title']??'Listing').'" by adding: '.implode(', ',$missing).'.';
                    mj_automation_notify_user($s,(int)$r['user_id'],'listing_quality','Improve your listing',$msg,'edit-listing.php?id='.(int)$r['id']);
                    mj_automation_log($key,$group,'listing',(int)$r['id'],'quality_reminder',$msg,'success','',['user_id'=>(int)$r['user_id'],'listing_id'=>(int)$r['id']]);
                    $result['actions']++;
                }
            }
        } elseif($key==='listing_upgrade_suggestion'){
            $views=(int)($cfg['views']??50); $offers=(int)($cfg['offers']??3); $eng=(int)($cfg['engagement']??10);
            $rows=safe_all_rows_public("SELECT * FROM mj_listings WHERE status IN ('active','approved','published') AND (COALESCE(views,0)>=? OR COALESCE(offer_count,0)>=? OR COALESCE(engagement_count,0)>=?) LIMIT 500",[$views,$offers,$eng]);
            foreach($rows as $r){
                if(mj_automation_already_done($key,'listing',(int)$r['id'],'upgrade_suggestion',72)){continue;}
                $msg='Your listing "'.($r['title']??'Listing').'" is getting activity. You can request Featured or Sponsored upgrade from My Listings.';
                mj_automation_notify_user($s,(int)$r['user_id'],'listing_upgrade_suggestion','Promote your listing',$msg,'my-listings.php');
                mj_automation_log($key,$group,'listing',(int)$r['id'],'upgrade_suggestion',$msg,'success','',['user_id'=>(int)$r['user_id'],'listing_id'=>(int)$r['id']]);
                $result['actions']++;
            }
        } elseif($key==='featured_sponsor_expiry'){
            foreach(['featured'=>'featured_until','sponsored'=>'sponsored_until'] as $type=>$col){
                $rows=safe_all_rows_public("SELECT * FROM mj_listings WHERE $col IS NOT NULL AND $col<NOW() AND is_$type=1 LIMIT 500");
                foreach($rows as $r){
                    q("UPDATE mj_listings SET is_$type=0, {$type}_priority=0 WHERE id=?",[(int)$r['id']]);
                    $msg=ucfirst($type).' listing expired: '.($r['title']??'Listing');
                    mj_automation_notify_user($s,(int)$r['user_id'],'listing_'.$type.'_expired',ucfirst($type).' expired',$msg,'my-listings.php');
                    mj_automation_admin_alert($s,'listing_'.$type.'_expired',ucfirst($type).' expired',$msg,'listing-edit.php?id='.(int)$r['id'],'listing',(int)$r['id']);
                    mj_automation_log($key,$group,'listing',(int)$r['id'],$type.'_expired',$msg,'success','',['user_id'=>(int)$r['user_id'],'listing_id'=>(int)$r['id']]);
                    $result['actions']++;
                }
            }
        } elseif($key==='company_profile_score'){
            $rows=safe_all_rows_public("SELECT id FROM mj_companies ORDER BY id DESC LIMIT 500");
            foreach($rows as $r){ if(function_exists('mj_company_update_scores')){ mj_company_update_scores((int)$r['id']); $result['actions']++; } }
            mj_automation_log($key,$group,'company',0,'score_update','Company scores recalculated: '.$result['actions']);
        } elseif($key==='company_profile_reminder'){
            $min=(int)($cfg['min_score']??70);
            $rows=safe_all_rows_public("SELECT * FROM mj_companies WHERE status IN ('active','pending') AND COALESCE(profile_score,0)<? ORDER BY id DESC LIMIT 500",[$min]);
            foreach($rows as $r){
                if(mj_automation_already_done($key,'company',(int)$r['id'],'profile_reminder',72)){continue;}
                $msg='Complete your company profile "'.($r['name']??'Company').'" to receive more leads. Add logo, contact, services and active listings.';
                mj_automation_notify_user($s,(int)$r['user_id'],'company_profile_reminder','Complete your company profile',$msg,'company-edit.php?id='.(int)$r['id']);
                mj_automation_log($key,$group,'company',(int)$r['id'],'profile_reminder',$msg,'success','',['user_id'=>(int)$r['user_id'],'company_id'=>(int)$r['id']]);
                $result['actions']++;
            }
        } elseif($key==='company_verification_expiry'){
            $rows=safe_all_rows_public("SELECT * FROM mj_companies WHERE verification_until IS NOT NULL AND verification_until<CURDATE() AND (is_verified=1 OR verification_status='verified') LIMIT 500");
            foreach($rows as $r){
                q("UPDATE mj_companies SET is_verified=0, verification_status='expired' WHERE id=?",[(int)$r['id']]);
                $msg='Company verification expired: '.($r['name']??'Company');
                mj_automation_notify_user($s,(int)$r['user_id'],'company_verification_expired','Company verification expired',$msg,'company-edit.php?id='.(int)$r['id']);
                mj_automation_admin_alert($s,'company_verification_expired','Company verification expired',$msg,'companies.php','company',(int)$r['id']);
                mj_automation_log($key,$group,'company',(int)$r['id'],'verification_expired',$msg,'success','',['user_id'=>(int)$r['user_id'],'company_id'=>(int)$r['id']]);
                $result['actions']++;
            }
        } elseif($key==='quote_unanswered' || $key==='company_unanswered_leads'){
            $hours=(int)($cfg['hours'] ?? $cfg['quote_hours'] ?? 24);
            if(table_exists('mj_quote_requests')){
                $rows=safe_all_rows_public("SELECT qr.*, co.user_id company_user_id, co.name company_name FROM mj_quote_requests qr LEFT JOIN mj_companies co ON co.id=qr.company_id WHERE qr.status IN ('new','reviewed') AND qr.created_at<DATE_SUB(NOW(), INTERVAL ? HOUR) LIMIT 500",[$hours]);
                foreach($rows as $r){
                    if(mj_automation_already_done($key,'quote',(int)$r['id'],'unanswered_quote',24)){continue;}
                    $msg='Quote request waiting for reply: '.($r['title']??'Quote').' for '.($r['company_name']??'Company');
                    mj_automation_notify_user($s,(int)($r['company_user_id']??0),'quote_unanswered','Quote waiting for reply',$msg,'company-quotes.php');
                    mj_automation_admin_alert($s,'quote_unanswered','Quote waiting for reply',$msg,'quote-requests.php','quote',(int)$r['id'],'warning');
                    mj_automation_log($key,$group,'quote',(int)$r['id'],'unanswered_quote',$msg,'success','',['user_id'=>(int)($r['company_user_id']??0),'company_id'=>(int)($r['company_id']??0)]);
                    $result['actions']++;
                }
            }
        } elseif($key==='offer_response_reminder'){
            if(table_exists('mj_offers')){
                $hours=(int)($cfg['hours']??24);
                $rows=safe_all_rows_public("SELECT o.*, l.title listing_title FROM mj_offers o LEFT JOIN mj_listings l ON l.id=o.listing_id WHERE o.status IN ('pending','countered') AND o.updated_at<DATE_SUB(NOW(), INTERVAL ? HOUR) LIMIT 500",[$hours]);
                foreach($rows as $r){
                    if(mj_automation_already_done($key,'offer',(int)$r['id'],'offer_reminder',24)){continue;}
                    $target=(string)$r['status']==='countered' ? (int)$r['buyer_id'] : (int)$r['seller_id'];
                    $msg='Offer waiting for action: '.($r['listing_title']??'Listing');
                    mj_automation_notify_user($s,$target,'offer_reminder','Offer waiting for action',$msg,'offer-view.php?id='.(int)$r['id']);
                    mj_automation_log($key,$group,'offer',(int)$r['id'],'offer_reminder',$msg,'success','',['user_id'=>$target,'listing_id'=>(int)$r['listing_id']]);
                    $result['actions']++;
                }
            }
        } elseif($key==='offer_auto_expire'){
            if(table_exists('mj_offers')){
                $rows=safe_all_rows_public("SELECT * FROM mj_offers WHERE status IN ('pending','countered') AND expires_at IS NOT NULL AND expires_at<NOW() LIMIT 500");
                foreach($rows as $r){
                    q("UPDATE mj_offers SET status='expired', updated_at=NOW() WHERE id=?",[(int)$r['id']]);
                    mj_automation_notify_user($s,(int)$r['buyer_id'],'offer_expired','Offer expired','Your offer has expired.','my-offers.php');
                    mj_automation_notify_user($s,(int)$r['seller_id'],'offer_expired','Offer expired','An offer has expired.','my-offers.php');
                    mj_automation_log($key,$group,'offer',(int)$r['id'],'offer_expired','Offer expired','success','',['user_id'=>(int)$r['buyer_id'],'listing_id'=>(int)$r['listing_id']]);
                    $result['actions']++;
                }
            }
        } elseif($key==='order_status_reminder'){
            if(table_exists('mj_orders')){
                $hours=(int)($cfg['hours']??48);
                $rows=safe_all_rows_public("SELECT * FROM mj_orders WHERE status IN ('pending_payment','payment_submitted','processing','ready') AND updated_at<DATE_SUB(NOW(), INTERVAL ? HOUR) LIMIT 500",[$hours]);
                foreach($rows as $r){
                    if(mj_automation_already_done($key,'order',(int)$r['id'],'order_reminder',24)){continue;}
                    $msg='Order needs action: '.($r['order_number']??('#'.$r['id']));
                    mj_automation_notify_user($s,(int)$r['buyer_id'],'order_reminder','Order needs action',$msg,'order-view.php?id='.(int)$r['id']);
                    mj_automation_notify_user($s,(int)$r['seller_id'],'order_reminder','Order needs action',$msg,'order-view.php?id='.(int)$r['id']);
                    mj_automation_admin_alert($s,'order_needs_action','Order needs action',$msg,'orders.php','order',(int)$r['id'],'warning');
                    mj_automation_log($key,$group,'order',(int)$r['id'],'order_reminder',$msg,'success','',['user_id'=>(int)$r['buyer_id']]);
                    $result['actions']++;
                }
            }
        } elseif($key==='upgrade_payment_reminder'){
            if(table_exists('mj_upgrade_payment_proofs')){
                $rows=safe_all_rows_public("SELECT * FROM mj_upgrade_payment_proofs WHERE status='submitted' AND created_at<DATE_SUB(NOW(), INTERVAL ? HOUR) LIMIT 500",[(int)($cfg['hours']??24)]);
                foreach($rows as $r){
                    if(mj_automation_already_done($key,'payment',(int)$r['id'],'payment_review_reminder',24)){continue;}
                    $msg='Upgrade payment proof waiting for admin review: #'.(int)$r['id'];
                    mj_automation_admin_alert($s,'upgrade_payment_review','Payment proof waiting review',$msg,'payment-center.php?tab=review','payment',(int)$r['id'],'high');
                    mj_automation_log($key,$group,'payment',(int)$r['id'],'payment_review_reminder',$msg);
                    $result['actions']++;
                }
            }
        } elseif($key==='user_limit_monitor'){
            if(function_exists('mj_user_limits_get')){
                $rows=safe_all_rows_public("SELECT id,name,email FROM mj_users WHERE is_active=1 ORDER BY id DESC LIMIT 500");
                foreach($rows as $u){
                    $li=mj_user_can_add_listing((int)$u['id']); $co=mj_user_can_add_company((int)$u['id']);
                    if((!$li['allowed'] || !$co['allowed']) && !mj_automation_already_done($key,'user',(int)$u['id'],'limit_alert',24)){
                        $msg='User reached limit: '.($u['email']??'User').' | '.$li['message'].' | '.$co['message'];
                        mj_automation_admin_alert($s,'user_limit_reached','User reached limit',$msg,'user-limits.php','user',(int)$u['id'],'normal');
                        mj_automation_log($key,$group,'user',(int)$u['id'],'limit_alert',$msg,'success','',['user_id'=>(int)$u['id']]);
                        $result['actions']++;
                    }
                }
            }
        } elseif($key==='admin_daily_digest'){
            if(!mj_automation_already_done($key,'admin',1,'daily_digest',22)){
                $pendingListings=safe_count("SELECT COUNT(*) c FROM mj_listings WHERE status='pending'");
                $pendingCompanies=safe_count("SELECT COUNT(*) c FROM mj_companies WHERE status='pending'");
                $renewals=table_exists('mj_listing_renewal_requests')?safe_count("SELECT COUNT(*) c FROM mj_listing_renewal_requests WHERE status IN ('pending','pending_payment','payment_uploaded')"):0;
                $proofs=table_exists('mj_upgrade_payment_proofs')?safe_count("SELECT COUNT(*) c FROM mj_upgrade_payment_proofs WHERE status='submitted'"):0;
                $msg="Pending listings: $pendingListings | Pending companies: $pendingCompanies | Renewal/upgrade requests: $renewals | Payment proofs: $proofs";
                mj_automation_admin_alert($s,'admin_daily_digest','Daily marketplace digest',$msg,'dashboard.php','admin',1,'normal');
                mj_automation_log($key,$group,'admin',1,'daily_digest',$msg);
                $result['actions']++;
            }
        } elseif($key==='safety_duplicate_listing'){
            $rows=safe_all_rows_public("SELECT a.id,a.user_id,a.title,a.created_at FROM mj_listings a INNER JOIN mj_listings b ON b.id<>a.id AND b.user_id=a.user_id AND b.title=a.title WHERE a.created_at>=DATE_SUB(NOW(), INTERVAL 14 DAY) GROUP BY a.id LIMIT 200");
            foreach($rows as $r){
                if(mj_automation_already_done($key,'listing',(int)$r['id'],'duplicate_alert',168)){continue;}
                $msg='Possible duplicate listing: '.($r['title']??'Listing');
                mj_automation_admin_alert($s,'duplicate_listing','Possible duplicate listing',$msg,'listings.php?q='.rawurlencode((string)$r['title']),'listing',(int)$r['id'],'warning');
                mj_automation_log($key,$group,'listing',(int)$r['id'],'duplicate_alert',$msg,'success','',['user_id'=>(int)$r['user_id'],'listing_id'=>(int)$r['id']]);
                $result['actions']++;
            }
        } elseif($key==='safety_suspicious_keywords'){
            $words=array_filter(array_map('trim', explode(',', (string)($cfg['words']??'otp,password,bank login,crypto,pay outside,urgent transfer'))));
            if($words){
                $likeSql=[]; $params=[];
                foreach($words as $w){ $likeSql[]='description LIKE ?'; $params[]='%'.$w.'%'; }
                $rows=safe_all_rows_public("SELECT id,user_id,title FROM mj_listings WHERE status NOT IN ('deleted','rejected') AND (".implode(' OR ',$likeSql).") ORDER BY id DESC LIMIT 200",$params);
                foreach($rows as $r){
                    if(mj_automation_already_done($key,'listing',(int)$r['id'],'keyword_alert',72)){continue;}
                    $msg='Suspicious keyword found in listing: '.($r['title']??'Listing');
                    mj_automation_admin_alert($s,'suspicious_keyword','Suspicious listing content',$msg,'listing-edit.php?id='.(int)$r['id'],'listing',(int)$r['id'],'warning');
                    mj_automation_log($key,$group,'listing',(int)$r['id'],'keyword_alert',$msg,'success','',['user_id'=>(int)$r['user_id'],'listing_id'=>(int)$r['id']]);
                    $result['actions']++;
                }
            }
        }
        q('UPDATE mj_automation_settings SET last_run_at=NOW() WHERE automation_key=?',[$key]);
    } catch(Throwable $e){
        $result['errors']++;
        mj_automation_log($key,$group,'system',0,'error','Automation error','error',$e->getMessage());
    }
    return $result;
}
}

if (!function_exists('mj_automation_run_all')) {
function mj_automation_run_all(bool $force=false): array {
    mj_automation_ensure_schema();
    $summary=['actions'=>0,'errors'=>0,'items'=>[]];
    $rows=all_rows('SELECT * FROM mj_automation_settings WHERE is_enabled=1 ORDER BY automation_group,automation_key');
    foreach($rows as $s){
        $r=mj_automation_run_one($s,$force);
        $summary['actions'] += (int)$r['actions'];
        $summary['errors'] += (int)$r['errors'];
        $summary['items'][] = $r;
    }
    if (function_exists('mj_crm_run_automation')) {
        $crm = mj_crm_run_automation($force);
        $summary['actions'] += (int)$crm['actions'];
        $summary['errors'] += (int)$crm['errors'];
        $summary['items'][] = ['key'=>'crm_automation','actions'=>$crm['actions'],'errors'=>$crm['errors']];
    }
    return $summary;
}
}

if (!function_exists('mj_payment_logo_strip')) {
function mj_payment_logo_strip(string $mode='inactive'): string {
    $items = ['Bank Transfer','PayNow','PayPal','Stripe','Visa','Mastercard','Cash'];
    $html = '<div class="mj-payment-logo-strip '.$mode.'">';
    foreach($items as $i){ $html .= '<span>'.e($i).'</span>'; }
    $html .= '</div>';
    return $html;
}
}

if (!function_exists('mj_invoice_number')) {
function mj_invoice_number(array $order): string {
    $prefix = setting('invoice_prefix','MJ-INV') ?: 'MJ-INV';
    $id = (int)($order['id'] ?? 0);
    $year = !empty($order['created_at']) ? date('Y', strtotime((string)$order['created_at'])) : date('Y');
    return $prefix.'-'.$year.'-'.str_pad((string)$id, 6, '0', STR_PAD_LEFT);
}
}

if (!function_exists('mj_invoice_totals')) {
function mj_invoice_totals(array $order): array {
    $subtotal = (float)($order['total_amount'] ?? $order['amount'] ?? 0);
    $rate = max(0, (float)setting('invoice_tax_rate','0'));
    $tax = round($subtotal * ($rate/100), 2);
    return ['subtotal'=>$subtotal,'tax_rate'=>$rate,'tax'=>$tax,'total'=>$subtotal+$tax];
}
}


/* MJ Marketplace v2.8.0 Smart CRM + Customer Management */
if (!function_exists('mj_crm_ensure_schema')) {
function mj_crm_ensure_schema(): void {
    if (!mj_is_installed()) { return; }
    try {
        q("CREATE TABLE IF NOT EXISTS mj_customers (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            owner_user_id BIGINT NOT NULL,
            company_id BIGINT NULL,
            customer_user_id BIGINT NULL,
            name VARCHAR(190) NULL,
            email VARCHAR(190) NULL,
            phone VARCHAR(80) NULL,
            whatsapp VARCHAR(80) NULL,
            country_id BIGINT NULL,
            city_id BIGINT NULL,
            source_type VARCHAR(80) NULL,
            source_id BIGINT NULL,
            source_label VARCHAR(190) NULL,
            status VARCHAR(40) NOT NULL DEFAULT 'new',
            lead_score INT NOT NULL DEFAULT 0,
            tags VARCHAR(500) NULL,
            last_activity_at DATETIME NULL,
            next_followup_at DATETIME NULL,
            is_blocked TINYINT(1) NOT NULL DEFAULT 0,
            private_note TEXT NULL,
            admin_note TEXT NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            UNIQUE KEY uniq_owner_email_company (owner_user_id, company_id, email),
            INDEX(owner_user_id),
            INDEX(company_id),
            INDEX(customer_user_id),
            INDEX(email),
            INDEX(phone),
            INDEX(status),
            INDEX(next_followup_at),
            INDEX(last_activity_at)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    } catch(Throwable $e) {}

    try {
        q("CREATE TABLE IF NOT EXISTS mj_customer_timeline (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            customer_id BIGINT NOT NULL,
            actor_user_id BIGINT NULL,
            event_type VARCHAR(80) NOT NULL,
            event_title VARCHAR(190) NULL,
            event_note TEXT NULL,
            related_type VARCHAR(80) NULL,
            related_id BIGINT NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            INDEX(customer_id),
            INDEX(event_type),
            INDEX(related_type, related_id),
            INDEX(created_at)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    } catch(Throwable $e) {}

    $defs = [
        'crm_followup_notify_enabled'=>'1',
        'crm_followup_email_enabled'=>'0',
        'crm_no_response_days'=>'3',
        'crm_hot_lead_score'=>'70',
        'crm_auto_customer_from_quote'=>'1',
        'crm_auto_customer_from_offer'=>'1',
        'crm_auto_customer_from_order'=>'1',
        'crm_auto_customer_from_chat'=>'1',
    ];
    foreach($defs as $k=>$v){
        try { if(setting($k,'')===''){ q('INSERT INTO mj_settings(setting_key,setting_value) VALUES(?,?) ON DUPLICATE KEY UPDATE setting_value=setting_value',[$k,$v]); } } catch(Throwable $e){}
    }

    if (function_exists('mj_automation_ensure_schema')) {
        try {
            q('INSERT INTO mj_automation_settings(automation_key,automation_group,title,description,is_enabled,notify_user,notify_admin,email_user,run_interval_minutes,config_json,updated_at) VALUES("crm_followup_reminder","crm","Customer follow-up reminders","Remind owners when customer follow-up is due.",1,1,0,0,60,"{}",NOW()) ON DUPLICATE KEY UPDATE title=VALUES(title),description=VALUES(description)');
            q('INSERT INTO mj_automation_settings(automation_key,automation_group,title,description,is_enabled,notify_user,notify_admin,email_user,run_interval_minutes,config_json,updated_at) VALUES("crm_no_response_reminder","crm","Customer no-response reminders","Remind owners about customers with no recent activity.",1,1,0,0,1440,"{}",NOW()) ON DUPLICATE KEY UPDATE title=VALUES(title),description=VALUES(description)');
            q('INSERT INTO mj_automation_settings(automation_key,automation_group,title,description,is_enabled,notify_user,notify_admin,email_user,run_interval_minutes,config_json,updated_at) VALUES("crm_duplicate_check","crm","Customer duplicate check","Alert admin about possible duplicate customer contacts.",1,0,1,0,1440,"{}",NOW()) ON DUPLICATE KEY UPDATE title=VALUES(title),description=VALUES(description)');
        } catch(Throwable $e){}
    }
}
}

if (!function_exists('mj_crm_statuses')) {
function mj_crm_statuses(): array {
    return [
        'new'=>'New Lead',
        'contacted'=>'Contacted',
        'interested'=>'Interested',
        'quoted'=>'Quoted',
        'negotiating'=>'Negotiating',
        'converted'=>'Converted',
        'lost'=>'Lost',
        'no_response'=>'No Response',
        'blocked'=>'Blocked'
    ];
}
}

if (!function_exists('mj_crm_status_label')) {
function mj_crm_status_label(string $s): string { $all=mj_crm_statuses(); return $all[$s] ?? ucwords(str_replace('_',' ',$s)); }
}

if (!function_exists('mj_crm_status_class')) {
function mj_crm_status_class(string $s): string {
    return [
        'new'=>'primary','contacted'=>'info','interested'=>'warning','quoted'=>'secondary','negotiating'=>'warning',
        'converted'=>'success','lost'=>'dark','no_response'=>'danger','blocked'=>'danger'
    ][$s] ?? 'secondary';
}
}

if (!function_exists('mj_crm_mask_contact')) {
function mj_crm_mask_contact(?string $v): string {
    $v=(string)$v; if($v==='') return '-';
    if(strpos($v,'@')!==false){ [$a,$d]=array_pad(explode('@',$v,2),2,''); return substr($a,0,2).'***@'.$d; }
    return strlen($v)>4 ? str_repeat('X', max(0, strlen($v)-4)).substr($v,-4) : $v;
}
}

if (!function_exists('mj_crm_timeline_add')) {
function mj_crm_timeline_add(int $customerId, string $type, string $title, string $note='', string $relatedType='', int $relatedId=0, int $actorId=0): void {
    try {
        mj_crm_ensure_schema();
        q('INSERT INTO mj_customer_timeline(customer_id,actor_user_id,event_type,event_title,event_note,related_type,related_id,created_at) VALUES(?,?,?,?,?,?,?,NOW())',[
            $customerId, $actorId ?: null, $type, $title, $note, $relatedType ?: null, $relatedId ?: null
        ]);
        q('UPDATE mj_customers SET last_activity_at=NOW(), updated_at=NOW() WHERE id=?',[$customerId]);
    } catch(Throwable $e){}
}
}

if (!function_exists('mj_crm_calc_score')) {
function mj_crm_calc_score(array $data): int {
    $score=10;
    if(!empty($data['email'])) $score+=15;
    if(!empty($data['phone']) || !empty($data['whatsapp'])) $score+=15;
    if(!empty($data['source_type'])) $score+=10;
    if(($data['source_type']??'')==='quote') $score+=25;
    if(($data['source_type']??'')==='offer') $score+=20;
    if(($data['source_type']??'')==='order') $score+=35;
    if(!empty($data['customer_user_id'])) $score+=10;
    return min(100,$score);
}
}

if (!function_exists('mj_crm_find_or_create_customer')) {
function mj_crm_find_or_create_customer(array $data): int {
    mj_crm_ensure_schema();
    $owner=(int)($data['owner_user_id'] ?? 0); if($owner<=0) return 0;
    $companyId=!empty($data['company_id']) ? (int)$data['company_id'] : null;
    $email=trim((string)($data['email'] ?? ''));
    $phone=trim((string)($data['phone'] ?? ''));
    $customerUserId=!empty($data['customer_user_id']) ? (int)$data['customer_user_id'] : null;
    $existing=null;
    try {
        if($email!==''){ $existing=one('SELECT * FROM mj_customers WHERE owner_user_id=? AND IFNULL(company_id,0)=IFNULL(?,0) AND email=? LIMIT 1',[$owner,$companyId,$email]); }
        if(!$existing && $phone!==''){ $existing=one('SELECT * FROM mj_customers WHERE owner_user_id=? AND IFNULL(company_id,0)=IFNULL(?,0) AND phone=? LIMIT 1',[$owner,$companyId,$phone]); }
        if(!$existing && $customerUserId){ $existing=one('SELECT * FROM mj_customers WHERE owner_user_id=? AND IFNULL(company_id,0)=IFNULL(?,0) AND customer_user_id=? LIMIT 1',[$owner,$companyId,$customerUserId]); }
    } catch(Throwable $e){}
    $score=mj_crm_calc_score($data);
    if($existing){
        $id=(int)$existing['id'];
        try {
            q('UPDATE mj_customers SET name=COALESCE(NULLIF(?, ""), name), email=COALESCE(NULLIF(?, ""), email), phone=COALESCE(NULLIF(?, ""), phone), whatsapp=COALESCE(NULLIF(?, ""), whatsapp), customer_user_id=COALESCE(?, customer_user_id), source_type=COALESCE(NULLIF(?, ""), source_type), source_id=COALESCE(?, source_id), source_label=COALESCE(NULLIF(?, ""), source_label), lead_score=GREATEST(lead_score, ?), last_activity_at=NOW(), updated_at=NOW() WHERE id=?',[
                (string)($data['name']??''),$email,$phone,(string)($data['whatsapp']??''),$customerUserId,(string)($data['source_type']??''),!empty($data['source_id'])?(int)$data['source_id']:null,(string)($data['source_label']??''),$score,$id
            ]);
        } catch(Throwable $e){}
        return $id;
    }
    try {
        q('INSERT INTO mj_customers(owner_user_id,company_id,customer_user_id,name,email,phone,whatsapp,country_id,city_id,source_type,source_id,source_label,status,lead_score,last_activity_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(),NOW(),NOW())',[
            $owner,$companyId,$customerUserId,(string)($data['name']??''),$email,$phone,(string)($data['whatsapp']??''),!empty($data['country_id'])?(int)$data['country_id']:null,!empty($data['city_id'])?(int)$data['city_id']:null,(string)($data['source_type']??''),!empty($data['source_id'])?(int)$data['source_id']:null,(string)($data['source_label']??''),(string)($data['status']??'new'),$score
        ]);
        $id=(int)db()->lastInsertId();
        mj_crm_timeline_add($id,'created','Customer created','Created from '.($data['source_type']??'manual'),(string)($data['source_type']??''),!empty($data['source_id'])?(int)$data['source_id']:0,(int)($data['actor_user_id']??0));
        return $id;
    } catch(Throwable $e){ return 0; }
}
}

if (!function_exists('mj_crm_sync_from_marketplace')) {
function mj_crm_sync_from_marketplace(): array {
    mj_crm_ensure_schema();
    $out=['created'=>0,'updated'=>0];

    if(setting('crm_auto_customer_from_quote','1')==='1' && table_exists('mj_quote_requests')){
        $rows=safe_all_rows_public('SELECT qr.*, co.user_id owner_user_id, co.id coid FROM mj_quote_requests qr LEFT JOIN mj_companies co ON co.id=qr.company_id ORDER BY qr.id DESC LIMIT 500');
        foreach($rows as $r){
            $id=mj_crm_find_or_create_customer([
                'owner_user_id'=>(int)($r['owner_user_id']??0),'company_id'=>(int)($r['company_id']??0),'customer_user_id'=>(int)($r['user_id']??0),
                'name'=>$r['customer_name']??($r['name']??''),'email'=>$r['customer_email']??($r['email']??''),'phone'=>$r['customer_phone']??($r['phone']??''),'whatsapp'=>$r['customer_whatsapp']??'',
                'country_id'=>$r['country_id']??null,'city_id'=>$r['city_id']??null,'source_type'=>'quote','source_id'=>(int)$r['id'],'source_label'=>$r['title']??'Quote request','status'=>'quoted'
            ]);
            if($id){$out['updated']++;}
        }
    }
    if(setting('crm_auto_customer_from_offer','1')==='1' && table_exists('mj_offers')){
        $rows=safe_all_rows_public('SELECT o.*, b.name buyer_name, b.email buyer_email, b.mobile buyer_mobile, l.title listing_title, l.company_id FROM mj_offers o LEFT JOIN mj_users b ON b.id=o.buyer_id LEFT JOIN mj_listings l ON l.id=o.listing_id ORDER BY o.id DESC LIMIT 500');
        foreach($rows as $r){
            $id=mj_crm_find_or_create_customer([
                'owner_user_id'=>(int)$r['seller_id'],'company_id'=>(int)($r['company_id']??0),'customer_user_id'=>(int)$r['buyer_id'],
                'name'=>$r['buyer_name']??'','email'=>$r['buyer_email']??'','phone'=>$r['buyer_mobile']??'',
                'source_type'=>'offer','source_id'=>(int)$r['id'],'source_label'=>$r['listing_title']??'Offer','status'=>'negotiating'
            ]);
            if($id){$out['updated']++;}
        }
    }
    if(setting('crm_auto_customer_from_order','1')==='1' && table_exists('mj_orders')){
        $rows=safe_all_rows_public('SELECT o.*, b.name buyer_name, b.email buyer_email, b.mobile buyer_mobile, l.title listing_title FROM mj_orders o LEFT JOIN mj_users b ON b.id=o.buyer_id LEFT JOIN mj_listings l ON l.id=o.listing_id ORDER BY o.id DESC LIMIT 500');
        foreach($rows as $r){
            $id=mj_crm_find_or_create_customer([
                'owner_user_id'=>(int)$r['seller_id'],'company_id'=>(int)($r['company_id']??0),'customer_user_id'=>(int)$r['buyer_id'],
                'name'=>$r['buyer_name']??'','email'=>$r['buyer_email']??'','phone'=>$r['buyer_mobile']??'',
                'source_type'=>'order','source_id'=>(int)$r['id'],'source_label'=>$r['listing_title']??($r['order_number']??'Order'),'status'=>($r['status']??'')==='completed'?'converted':'interested'
            ]);
            if($id){$out['updated']++;}
        }
    }
    return $out;
}
}

if (!function_exists('mj_crm_customer_can_view')) {
function mj_crm_customer_can_view(array $c, int $userId): bool {
    return current_admin() || ((int)($c['owner_user_id']??0)===$userId);
}
}


if (!function_exists('mj_crm_run_automation')) {
function mj_crm_run_automation(bool $force=false): array {
    mj_crm_ensure_schema();
    $out=['actions'=>0,'errors'=>0];
    try {
        $sync=mj_crm_sync_from_marketplace();
        $out['actions'] += (int)$sync['updated'];

        $due=safe_all_rows_public('SELECT * FROM mj_customers WHERE next_followup_at IS NOT NULL AND next_followup_at<=NOW() LIMIT 300');
        foreach($due as $c){
            if(function_exists('mj_automation_already_done') && mj_automation_already_done('crm_followup_reminder','customer',(int)$c['id'],'followup_due',12)){ continue; }
            mj263_notify((int)$c['owner_user_id'],null,'crm_followup','Customer follow-up due','Follow up with '.($c['name'] ?: 'Customer #'.$c['id']),'customer-view.php?id='.(int)$c['id'],'normal',setting('crm_followup_email_enabled','0')==='1');
            if(function_exists('mj_automation_log')){ mj_automation_log('crm_followup_reminder','crm','customer',(int)$c['id'],'followup_due','Customer follow-up due','success','',['user_id'=>(int)$c['owner_user_id']]); }
            $out['actions']++;
        }

        $days=max(1,(int)setting('crm_no_response_days','3'));
        $old=safe_all_rows_public('SELECT * FROM mj_customers WHERE status IN ("new","contacted","interested","quoted","negotiating") AND COALESCE(last_activity_at,created_at)<DATE_SUB(NOW(), INTERVAL ? DAY) LIMIT 300',[$days]);
        foreach($old as $c){
            if(function_exists('mj_automation_already_done') && mj_automation_already_done('crm_no_response_reminder','customer',(int)$c['id'],'no_response',24)){ continue; }
            mj263_notify((int)$c['owner_user_id'],null,'crm_no_response','Customer waiting for follow-up','No recent activity from '.($c['name'] ?: 'Customer #'.$c['id']),'customer-view.php?id='.(int)$c['id'],'normal',false);
            if(function_exists('mj_automation_log')){ mj_automation_log('crm_no_response_reminder','crm','customer',(int)$c['id'],'no_response','No recent customer activity','success','',['user_id'=>(int)$c['owner_user_id']]); }
            $out['actions']++;
        }

        $dups=safe_all_rows_public('SELECT email, owner_user_id, COUNT(*) c FROM mj_customers WHERE email IS NOT NULL AND email<>"" GROUP BY email,owner_user_id HAVING c>1 LIMIT 100');
        foreach($dups as $d){
            if(function_exists('mj_automation_admin_alert')){
                mj_automation_admin_alert(['notify_admin'=>1],'crm_duplicate_check','Possible duplicate customers','Duplicate customer email: '.$d['email'],'customers.php?q='.rawurlencode($d['email']),'customer',0,'warning');
            }
            if(function_exists('mj_automation_log')){ mj_automation_log('crm_duplicate_check','crm','customer',0,'duplicate_email','Duplicate customer email: '.$d['email']); }
            $out['actions']++;
        }
    } catch(Throwable $e){
        $out['errors']++;
        if(function_exists('mj_automation_log')){ mj_automation_log('crm_automation','crm','system',0,'error','CRM automation error','error',$e->getMessage()); }
    }
    return $out;
}
}

/* MJ Marketplace v2.8.1 Account Center + CRM/Billing/Trust/Support helpers */
if (!function_exists('mj_v281_ensure_schema')) {
function mj_v281_ensure_schema(): void {
    if (!mj_is_installed()) { return; }
    $sqls = [
        "CREATE TABLE IF NOT EXISTS mj_user_account_preferences (
            user_id BIGINT NOT NULL PRIMARY KEY,
            preferred_contact_method VARCHAR(40) DEFAULT 'whatsapp',
            profile_visibility VARCHAR(40) DEFAULT 'public',
            contact_visibility VARCHAR(40) DEFAULT 'login_required',
            language_preference VARCHAR(20) DEFAULT 'en',
            currency_code VARCHAR(8) DEFAULT 'SGD',
            timezone VARCHAR(80) DEFAULT 'Asia/Singapore',
            away_mode TINYINT(1) DEFAULT 0,
            away_message TEXT NULL,
            auto_reply_enabled TINYINT(1) DEFAULT 0,
            auto_reply_message TEXT NULL,
            notify_inapp TINYINT(1) DEFAULT 1,
            notify_email TINYINT(1) DEFAULT 1,
            notify_sms TINYINT(1) DEFAULT 0,
            notify_whatsapp TINYINT(1) DEFAULT 0,
            do_not_disturb TINYINT(1) DEFAULT 0,
            daily_digest TINYINT(1) DEFAULT 0,
            important_alerts_only TINYINT(1) DEFAULT 0,
            security_login_alert TINYINT(1) DEFAULT 1,
            updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
        "CREATE TABLE IF NOT EXISTS mj_saved_searches (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            user_id BIGINT NOT NULL,
            title VARCHAR(190) NOT NULL,
            search_url VARCHAR(500) NULL,
            filters_json TEXT NULL,
            alert_enabled TINYINT(1) DEFAULT 1,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            INDEX(user_id), INDEX(alert_enabled)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
        "CREATE TABLE IF NOT EXISTS mj_blocked_users (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            user_id BIGINT NOT NULL,
            blocked_user_id BIGINT NULL,
            blocked_email VARCHAR(190) NULL,
            reason VARCHAR(255) NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            UNIQUE KEY uniq_block_user (user_id, blocked_user_id),
            INDEX(user_id), INDEX(blocked_email)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
        "CREATE TABLE IF NOT EXISTS mj_user_activity_logs (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            user_id BIGINT NOT NULL,
            action VARCHAR(120) NOT NULL,
            description TEXT NULL,
            ip_address VARCHAR(80) NULL,
            user_agent VARCHAR(255) NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            INDEX(user_id), INDEX(action), INDEX(created_at)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
        "CREATE TABLE IF NOT EXISTS mj_payment_requests (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            user_id BIGINT NOT NULL,
            company_id BIGINT NULL,
            request_type VARCHAR(80) DEFAULT 'manual',
            reference_type VARCHAR(80) NULL,
            reference_id BIGINT NULL,
            title VARCHAR(190) NOT NULL,
            description TEXT NULL,
            amount DECIMAL(12,2) NOT NULL DEFAULT 0,
            tax_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
            total_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
            currency_code VARCHAR(8) DEFAULT 'SGD',
            status VARCHAR(40) DEFAULT 'pending',
            due_date DATE NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            INDEX(user_id), INDEX(status), INDEX(request_type)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
        "CREATE TABLE IF NOT EXISTS mj_invoices (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            user_id BIGINT NOT NULL,
            company_id BIGINT NULL,
            invoice_no VARCHAR(80) UNIQUE,
            invoice_type VARCHAR(40) DEFAULT 'invoice',
            title VARCHAR(190) NULL,
            amount DECIMAL(12,2) NOT NULL DEFAULT 0,
            tax_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
            total_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
            currency_code VARCHAR(8) DEFAULT 'SGD',
            status VARCHAR(40) DEFAULT 'draft',
            due_date DATE NULL,
            paid_at DATETIME NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            INDEX(user_id), INDEX(status), INDEX(invoice_no)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
        "CREATE TABLE IF NOT EXISTS mj_refund_requests (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            user_id BIGINT NOT NULL,
            invoice_id BIGINT NULL,
            payment_request_id BIGINT NULL,
            reason TEXT NULL,
            amount DECIMAL(12,2) DEFAULT 0,
            currency_code VARCHAR(8) DEFAULT 'SGD',
            status VARCHAR(40) DEFAULT 'pending',
            admin_note TEXT NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            INDEX(user_id), INDEX(status)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
        "CREATE TABLE IF NOT EXISTS mj_verification_requests (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            user_id BIGINT NOT NULL,
            company_id BIGINT NULL,
            verification_type VARCHAR(80) NOT NULL,
            document_path VARCHAR(255) NULL,
            note TEXT NULL,
            status VARCHAR(40) DEFAULT 'pending',
            admin_note TEXT NULL,
            expires_at DATE NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            reviewed_at DATETIME NULL,
            INDEX(user_id), INDEX(company_id), INDEX(status), INDEX(verification_type)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
        "CREATE TABLE IF NOT EXISTS mj_support_ticket_messages (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            ticket_id BIGINT NOT NULL,
            sender_type VARCHAR(20) DEFAULT 'user',
            sender_user_id BIGINT NULL,
            sender_admin_id BIGINT NULL,
            message TEXT NOT NULL,
            attachment_path VARCHAR(255) NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            INDEX(ticket_id), INDEX(sender_type)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
        "CREATE TABLE IF NOT EXISTS mj_quick_reply_templates (
            id BIGINT AUTO_INCREMENT PRIMARY KEY,
            user_id BIGINT NOT NULL,
            title VARCHAR(190) NOT NULL,
            message TEXT NOT NULL,
            is_active TINYINT(1) DEFAULT 1,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            INDEX(user_id), INDEX(is_active)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
    ];
    foreach ($sqls as $sql) { try { q($sql); } catch (Throwable $e) {} }

    $settings = [
        'account_center_enabled'=>'1','crm_leads_enabled'=>'1','billing_payments_enabled'=>'1','trust_verification_enabled'=>'1','support_center_enabled'=>'1',
        'account_center_profile_score_enabled'=>'1','crm_missed_lead_alert_hours'=>'24','crm_auto_reply_enabled'=>'1','support_ticket_upload_enabled'=>'1',
        'billing_tax_enabled'=>'0','billing_tax_label'=>'GST/VAT','billing_default_tax_rate'=>'0','privacy_data_download_enabled'=>'1','privacy_delete_request_enabled'=>'1'
    ];
    foreach ($settings as $k=>$v) {
        try { q('INSERT INTO mj_settings(setting_key,setting_value) VALUES(?,?) ON DUPLICATE KEY UPDATE setting_value=setting_value', [$k,$v]); } catch (Throwable $e) {}
    }
    $modules = [
        ['account_center','Account Center','Profile, company, wallet, security, privacy and user preferences'],
        ['crm_leads','CRM / Leads','Lead inbox, follow-up reminders, customer timeline and CRM tools'],
        ['billing_payments','Billing & Payments','Wallet, invoices, payment requests, receipts, refunds and tax settings'],
        ['trust_verification','Trust & Verification','Verification, trust badges, safety, reports and device security'],
        ['support_center','Help & Support','Tickets, FAQ, guides, attachments and admin replies'],
        ['saved_search_alerts','Saved Search Alerts','Notify users when matching listings are added'],
        ['seller_automation','Seller Automation','Auto reply, away mode, low performance alerts and renew reminders'],
        ['company_team','Company Team','Staff users, roles, branches and lead assignment'],
        ['marketing_tools','Marketing Tools','Share poster, QR poster, SEO preview and AI listing helper']
    ];
    foreach ($modules as $m) {
        try { q('INSERT INTO mj_site_modules(module_code,module_name,description,is_enabled) VALUES(?,?,?,1) ON DUPLICATE KEY UPDATE module_name=VALUES(module_name), description=VALUES(description)', $m); } catch (Throwable $e) {}
    }
}
}

if (!function_exists('mj_v281_user_preferences')) {
function mj_v281_user_preferences(int $userId): array {
    mj_v281_ensure_schema();
    $defaults = [
        'preferred_contact_method'=>'whatsapp','profile_visibility'=>'public','contact_visibility'=>'login_required','language_preference'=>'en','currency_code'=>'SGD','timezone'=>'Asia/Singapore',
        'away_mode'=>0,'away_message'=>'I am currently away and will reply soon.','auto_reply_enabled'=>0,'auto_reply_message'=>'Thanks for your enquiry. I will reply as soon as possible.',
        'notify_inapp'=>1,'notify_email'=>1,'notify_sms'=>0,'notify_whatsapp'=>0,'do_not_disturb'=>0,'daily_digest'=>0,'important_alerts_only'=>0,'security_login_alert'=>1
    ];
    try { $row = one('SELECT * FROM mj_user_account_preferences WHERE user_id=? LIMIT 1', [$userId]); return array_merge($defaults, $row ?: []); } catch (Throwable $e) { return $defaults; }
}
}

if (!function_exists('mj_v281_save_user_preferences')) {
function mj_v281_save_user_preferences(int $userId, array $data): void {
    mj_v281_ensure_schema();
    $allowed = ['preferred_contact_method','profile_visibility','contact_visibility','language_preference','currency_code','timezone','away_mode','away_message','auto_reply_enabled','auto_reply_message','notify_inapp','notify_email','notify_sms','notify_whatsapp','do_not_disturb','daily_digest','important_alerts_only','security_login_alert'];
    $current = mj_v281_user_preferences($userId);
    $clean = [];
    foreach ($allowed as $k) { $clean[$k] = array_key_exists($k, $data) ? $data[$k] : ($current[$k] ?? ''); }
    foreach (['away_mode','auto_reply_enabled','notify_inapp','notify_email','notify_sms','notify_whatsapp','do_not_disturb','daily_digest','important_alerts_only','security_login_alert'] as $b) {
        // Checkboxes not submitted on a tab should keep their current value unless their section posts the key name.
        $clean[$b] = array_key_exists($b, $data) ? (!empty($data[$b]) ? 1 : 0) : (int)($current[$b] ?? 0);
    }
    foreach (['preferred_contact_method','profile_visibility','contact_visibility','language_preference','currency_code','timezone'] as $s) { $clean[$s] = substr(trim((string)($clean[$s] ?? '')),0,80); }
    $clean['away_message'] = trim((string)($clean['away_message'] ?? ''));
    $clean['auto_reply_message'] = trim((string)($clean['auto_reply_message'] ?? ''));
    $cols = array_keys($clean);
    $placeholders = implode(',', array_fill(0, count($cols), '?'));
    $updates = implode(',', array_map(fn($c)=>$c.'=VALUES('.$c.')', $cols));
    q('INSERT INTO mj_user_account_preferences(user_id,'.implode(',', $cols).') VALUES(?,'.$placeholders.') ON DUPLICATE KEY UPDATE '.$updates, array_merge([$userId], array_values($clean)));
    mj_v281_log_activity($userId, 'preferences_updated', 'Updated account preferences.');
}
}

if (!function_exists('mj_v281_log_activity')) {
function mj_v281_log_activity(int $userId, string $action, string $description=''): void {
    if ($userId <= 0) { return; }
    mj_v281_ensure_schema();
    try { q('INSERT INTO mj_user_activity_logs(user_id,action,description,ip_address,user_agent,created_at) VALUES(?,?,?,?,?,NOW())', [$userId, substr($action,0,120), $description, $_SERVER['REMOTE_ADDR'] ?? '', substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''),0,255)]); } catch(Throwable $e) {}
}
}

if (!function_exists('mj_v281_completion_score')) {
function mj_v281_completion_score(array $user): array {
    $userId = (int)($user['id'] ?? 0);
    $checks = [
        'Name' => !empty($user['name']),
        'Email' => !empty($user['email']),
        'Mobile' => !empty($user['mobile']),
        'Country' => !empty($user['country_id']),
        'Timezone' => !empty($user['timezone']),
        'Company' => $userId ? mj_user_company_count($userId) > 0 : false,
        'Listing' => $userId ? mj_user_active_listing_count($userId) > 0 : false,
        'Trust score' => (int)($user['trust_score'] ?? 0) >= 50,
    ];
    $done = count(array_filter($checks));
    $score = (int)round(($done / max(1, count($checks))) * 100);
    return ['score'=>$score,'done'=>$done,'total'=>count($checks),'checks'=>$checks];
}
}

if (!function_exists('mj_v281_recent_activity')) {
function mj_v281_recent_activity(int $userId, int $limit=10): array {
    mj_v281_ensure_schema();
    try { return all_rows('SELECT * FROM mj_user_activity_logs WHERE user_id=? ORDER BY id DESC LIMIT '.max(1,min(50,$limit)), [$userId]); } catch(Throwable $e) { return []; }
}
}

if (!function_exists('mj_v281_support_tickets')) {
function mj_v281_support_tickets(int $userId, int $limit=20): array {
    mj_v281_ensure_schema();
    try { return all_rows('SELECT * FROM mj_support_tickets WHERE user_id=? ORDER BY id DESC LIMIT '.max(1,min(50,$limit)), [$userId]); } catch(Throwable $e) { return []; }
}
}

if (!function_exists('mj_v281_create_support_ticket')) {
function mj_v281_create_support_ticket(int $userId, string $subject, string $category, string $priority, string $message): int {
    mj_v281_ensure_schema();
    $subject = substr(trim($subject),0,220);
    $category = substr(trim($category),0,80);
    $priority = in_array($priority, ['low','normal','urgent'], true) ? $priority : 'normal';
    $message = trim($message);
    if ($subject === '' || $message === '') { throw new RuntimeException('Subject and message are required.'); }
    $data = ['user_id'=>$userId,'subject'=>$subject,'category'=>$category,'priority'=>$priority,'status'=>'open','message'=>$message,'created_at'=>date('Y-m-d H:i:s')];
    $data = mj_filter_existing_columns('mj_support_tickets', $data);
    if (empty($data['subject']) || empty($data['message'])) { throw new RuntimeException('Support ticket table is missing required columns.'); }
    $cols = array_keys($data);
    q('INSERT INTO mj_support_tickets(`'.implode('`,`',$cols).'`) VALUES('.implode(',', array_fill(0,count($cols),'?')).')', array_values($data));
    $id = (int)db()->lastInsertId();
    try { q('INSERT INTO mj_support_ticket_messages(ticket_id,sender_type,sender_user_id,message,created_at) VALUES(?,"user",?,?,NOW())', [$id,$userId,$message]); } catch(Throwable $e) {}
    mj_v281_log_activity($userId, 'support_ticket_created', 'Created support ticket: '.$subject);
    return $id;
}
}

if (!function_exists('mj_v281_table_count')) {
function mj_v281_table_count(string $table, string $where='1=1', array $params=[]): int {
    return table_exists($table) ? safe_count('SELECT COUNT(*) c FROM `'.preg_replace('/[^a-zA-Z0-9_]/','',$table).'` WHERE '.$where, $params) : 0;
}
}


/* v2.8.2 Frontend unified static page helper */
if (!function_exists('mj_frontend_static_page')) {
function mj_frontend_static_page(string $title, string $subtitle, array $cards = [], string $icon = 'bi-stars', array $actions = []): void {
    echo '<section class="mj-static-page"><div class="container">';
    echo '<div class="mj-static-hero mb-4"><span class="mj-badge mb-2"><i class="bi '.e($icon).'"></i> MJ Marketplace</span><h1>'.e($title).'</h1><p class="mb-0">'.e($subtitle).'</p>';
    if ($actions) {
        echo '<div class="mj-static-actions">';
        foreach ($actions as $a) {
            $href = e((string)($a[0] ?? '#'));
            $label = e((string)($a[1] ?? 'Open'));
            $btn = e((string)($a[2] ?? 'btn-primary'));
            $ai = e((string)($a[3] ?? 'bi-arrow-right'));
            echo '<a class="btn '.$btn.'" href="'.$href.'"><i class="bi '.$ai.' me-1"></i>'.$label.'</a>';
        }
        echo '</div>';
    }
    echo '</div>';
    if ($cards) {
        echo '<div class="mj-static-grid">';
        foreach ($cards as $c) {
            $ci = e((string)($c[0] ?? 'bi-check-circle'));
            $ct = e((string)($c[1] ?? 'Feature'));
            $cd = e((string)($c[2] ?? 'Ready for customization.'));
            echo '<div class="mj-static-card"><i class="bi '.$ci.'"></i><h3>'.$ct.'</h3><p>'.$cd.'</p></div>';
        }
        echo '</div>';
    }
    echo '</div></section>';
}
}

/* MJ Marketplace v2.8.4 Per-user feature/payment access controls */
if (!function_exists('mj_user_access_feature_defs')) {
function mj_user_access_feature_defs(): array {
    return [
        'account_center' => ['label'=>'Account Center','group'=>'Account'],
        'crm_leads' => ['label'=>'CRM / Leads','group'=>'Account'],
        'billing_payments' => ['label'=>'Billing & Payments','group'=>'Payments'],
        'wallet' => ['label'=>'Wallet / Credits','group'=>'Payments'],
        'payment_proof_upload' => ['label'=>'Upload Payment Proof','group'=>'Payments'],
        'renew_listing' => ['label'=>'Renew Listing Request','group'=>'Listing Paid Services'],
        'featured_listing' => ['label'=>'Featured Listing Request','group'=>'Listing Paid Services'],
        'sponsored_listing' => ['label'=>'Sponsored Listing Request','group'=>'Listing Paid Services'],
        'extra_listing_slots' => ['label'=>'Extra Listing Slots','group'=>'Listing Limits'],
        'extra_company_slots' => ['label'=>'Extra Company Slots','group'=>'Listing Limits'],
        'trust_verification' => ['label'=>'Trust & Verification','group'=>'Trust'],
        'support_center' => ['label'=>'Help & Support','group'=>'Support'],
        'selling_center' => ['label'=>'Selling Center','group'=>'Account Centers'],
        'buying_center' => ['label'=>'Buying Center','group'=>'Account Centers'],
        'appointment_booking' => ['label'=>'Appointment Booking','group'=>'Bookings'],
        'company_page_builder' => ['label'=>'Company Page Builder','group'=>'Company'],
        'saved_search_alerts' => ['label'=>'Saved Searches & Price Alerts','group'=>'Buyer Tools'],
        'qr_share_tools' => ['label'=>'QR / Share Poster Tools','group'=>'Marketing'],
        'reviews_ratings' => ['label'=>'Reviews & Ratings','group'=>'Trust'],
        'import_export' => ['label'=>'Import / Export','group'=>'Tools'],
    ];
}
}

if (!function_exists('mj_user_access_ensure_schema')) {
function mj_user_access_ensure_schema(): void {
    if (!mj_is_installed()) { return; }
    try {
        q("CREATE TABLE IF NOT EXISTS mj_user_feature_access (
            user_id BIGINT NOT NULL PRIMARY KEY,
            account_center TINYINT(1) NOT NULL DEFAULT 1,
            crm_leads TINYINT(1) NOT NULL DEFAULT 1,
            billing_payments TINYINT(1) NOT NULL DEFAULT 1,
            wallet TINYINT(1) NOT NULL DEFAULT 1,
            payment_proof_upload TINYINT(1) NOT NULL DEFAULT 1,
            renew_listing TINYINT(1) NOT NULL DEFAULT 1,
            featured_listing TINYINT(1) NOT NULL DEFAULT 1,
            sponsored_listing TINYINT(1) NOT NULL DEFAULT 1,
            extra_listing_slots TINYINT(1) NOT NULL DEFAULT 1,
            extra_company_slots TINYINT(1) NOT NULL DEFAULT 1,
            trust_verification TINYINT(1) NOT NULL DEFAULT 1,
            support_center TINYINT(1) NOT NULL DEFAULT 1,
            admin_note TEXT NULL,
            updated_by BIGINT NULL,
            updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            INDEX(updated_by)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
    } catch (Throwable $e) {}

    foreach (mj_user_access_feature_defs() as $key => $cfg) {
        try {
            if (!mj_column_exists('mj_user_feature_access', $key)) {
                q('ALTER TABLE mj_user_feature_access ADD COLUMN `'.$key.'` TINYINT(1) NOT NULL DEFAULT 1');
            }
        } catch (Throwable $e) {}
    }
    foreach (['admin_note'=>'TEXT NULL','updated_by'=>'BIGINT NULL','updated_at'=>'DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'] as $col=>$def) {
        try { if (!mj_column_exists('mj_user_feature_access', $col)) { q('ALTER TABLE mj_user_feature_access ADD COLUMN `'.$col.'` '.$def); } } catch (Throwable $e) {}
    }
}
}

if (!function_exists('mj_user_access_get')) {
function mj_user_access_get(int $userId): array {
    mj_user_access_ensure_schema();
    $out = ['user_id'=>$userId,'admin_note'=>''];
    foreach (mj_user_access_feature_defs() as $key=>$cfg) { $out[$key] = 1; }
    if ($userId <= 0 || !table_exists('mj_user_feature_access')) { return $out; }
    try {
        $row = one('SELECT * FROM mj_user_feature_access WHERE user_id=? LIMIT 1', [$userId]);
        if ($row) {
            foreach (mj_user_access_feature_defs() as $key=>$cfg) {
                if (array_key_exists($key, $row) && $row[$key] !== null && $row[$key] !== '') { $out[$key] = (int)$row[$key] ? 1 : 0; }
            }
            $out['admin_note'] = (string)($row['admin_note'] ?? '');
        }
    } catch (Throwable $e) {}
    return $out;
}
}

if (!function_exists('mj_user_feature_enabled')) {
function mj_user_feature_enabled(string $feature, int $userId = 0): bool {
    $feature = preg_replace('/[^a-zA-Z0-9_]/', '', $feature);
    if ($feature === '' || !isset(mj_user_access_feature_defs()[$feature])) { return true; }
    if ($userId <= 0) { $userId = (int)($_SESSION['user_id'] ?? 0); }
    if ($userId <= 0) { return true; }
    $access = mj_user_access_get($userId);
    return !empty($access[$feature]);
}
}

if (!function_exists('mj_user_access_save')) {
function mj_user_access_save(int $userId, array $posted, int $adminId = 0, string $note = ''): void {
    mj_user_access_ensure_schema();
    if ($userId <= 0) { throw new RuntimeException('Invalid user.'); }
    $defs = mj_user_access_feature_defs();
    $cols = ['user_id'];
    $vals = [$userId];
    foreach ($defs as $key=>$cfg) {
        $cols[] = $key;
        $vals[] = !empty($posted[$key]) ? 1 : 0;
    }
    $cols[] = 'admin_note'; $vals[] = $note;
    $cols[] = 'updated_by'; $vals[] = $adminId;
    $quoted = array_map(function($c){ return '`'.preg_replace('/[^a-zA-Z0-9_]/','',$c).'`'; }, $cols);
    $updates = [];
    foreach (array_slice($cols, 1) as $c) {
        $safe = preg_replace('/[^a-zA-Z0-9_]/','',$c);
        $updates[] = '`'.$safe.'`=VALUES(`'.$safe.'`)';
    }
    q('INSERT INTO mj_user_feature_access('.implode(',', $quoted).',updated_at) VALUES('.implode(',', array_fill(0, count($cols), '?')).',NOW()) ON DUPLICATE KEY UPDATE '.implode(',', $updates).', updated_at=NOW()', $vals);
}
}

if (!function_exists('mj_user_access_grouped_defs')) {
function mj_user_access_grouped_defs(): array {
    $groups = [];
    foreach (mj_user_access_feature_defs() as $key=>$cfg) {
        $group = (string)($cfg['group'] ?? 'Other');
        if (!isset($groups[$group])) { $groups[$group] = []; }
        $groups[$group][$key] = $cfg;
    }
    return $groups;
}
}

if (!function_exists('mj_require_user_feature')) {
function mj_require_user_feature(string $feature, int $userId = 0, string $message = ''): void {
    if ($userId <= 0) { $userId = (int)($_SESSION['user_id'] ?? 0); }
    if (!mj_user_feature_enabled($feature, $userId)) {
        flash('error', $message ?: 'This module is not enabled for your account. Please contact admin.');
        redirect('my-account.php');
    }
}
}


// ===== v2.8.8 Complete Frontend/User/Admin cleanup helpers =====
$__mj_v288_helper = __DIR__ . '/v288_helpers.php';
if (file_exists($__mj_v288_helper)) { require_once $__mj_v288_helper; }

// ===== v2.8.9 Admin Command Center helpers =====
$__mj_v289_admin_helper = __DIR__ . '/v289_admin_helpers.php';
if (file_exists($__mj_v289_admin_helper)) { require_once $__mj_v289_admin_helper; }

// ===== v2.9.0 Advanced Admin Layout Pro helpers =====
$__mj_v290_admin_helper = __DIR__ . '/v290_admin_helpers.php';
if (file_exists($__mj_v290_admin_helper)) { require_once $__mj_v290_admin_helper; }

// ===== v2.9.1 Bulk Payment Cart + Invoice Checkout helpers =====
$__mj_v291_payment_helper = __DIR__ . '/v291_payment_helpers.php';
if (file_exists($__mj_v291_payment_helper)) { require_once $__mj_v291_payment_helper; }

// ===== v3.0.4.2.7 Form Security + Duplicate Guard =====
$__mj_v30427_form_security = __DIR__ . '/mj_form_security.php';
if (file_exists($__mj_v30427_form_security)) { require_once $__mj_v30427_form_security; }

// ===== v3.0.4.2.8 Auto Repair + Debug + Error Logging =====
$__mj_v30428_auto_repair = __DIR__ . '/mj_auto_repair.php';
if (file_exists($__mj_v30428_auto_repair)) { require_once $__mj_v30428_auto_repair; }

// ===== v3.0.4.2.9 Lead Popup + Notification Dedupe + Speed Helpers =====
$__mj_v30429_helper = __DIR__ . '/mj_v30429_helpers.php';
if (file_exists($__mj_v30429_helper)) { require_once $__mj_v30429_helper; }

