<?php
// Второй файл ядра CMS (вспомогательные функции, без непосредственно выполняемого кода).
// В данном файле реализованы следующие задачи:
// — Управление системой хуков: добавление функций в очередь, их запуск с учетом приоритета и защита от рекурсивных вызовов через стек.
// — Работа с конфигурацией и логами: сохранение массива настроек в файл через var_export (с очисткой opcache) и запись отладочных строк в файл с ротацией (.old).
// — Отправка почты: формирование MIME-сообщений с поддержкой вложений, встроенных base64-изображений и отправка как через mail(), так и напрямую через SMTP-сокеты (fsockopen).
// — Локализация и текст: перевод строк из файлов локалей (с определением модуля по backtrace), транслитерация кириллицы, форматирование дат через IntlDateFormatter и выравнивание строк.
// — Работа с файловой системой: потоковая отдача файлов браузеру порциями по 1024 байта, рекурсивное удаление папок и проверка директорий на пустоту.
// — Безопасность путей: фильтрация относительных путей (запрет «..», «.» и двойных слэшей) для предотвращения выхода за пределы разрешенных директорий.
// — Работа с данными и БД: выполнение SQL-команд из внешних файлов через PDO, сортировка массивов по ключу "sort", форматирование JSON (pretty print).
// — Системные утилиты: расчет лимитов загрузки файлов из php.ini, генерация случайных UID/паролей, установка cookie с флагом SameSite.
// — Интерфейсные функции: рендеринг виджетов (подключение PHP или парсинг HTML с переводом переменных) и проверка видимости пунктов меню админки.


// Функции принимаются по именам чтобы иметь возможность отключать таким образом:
// $cms["hooks"]["template"][10]["func_name"] = false;
function hook_add_fn( $hook, $function, $priority = 10 ) {
    global $cms;
    if ( ! is_string( $function ) ) {
        return false;
    }

    $cms["hooks"][$hook][$priority][$function] = true; // true - enabled
    if ( ! isset( $cms["hooks"][$hook]["next"] ) ) {
        $cms["hooks"][$hook]["next"] = "";
    }
    return true;
}


// Параметры для хука принимаются по ссылке и могут быть модифицированы.
// Для предотвращения зацикливания от вложенных хуков вероятно нужно будет добавить
// стек и прерывать вызов если такой хук уже есть в стеке.
function do_hook( $hook = "", &$params = array() ) {
    global $cms;
    
    if ( empty( $hook ) ||
         empty( $cms["hooks"][$hook] ) ||
         ! empty( $cms["hooks"][$hook]["disabled"] ) ) {
        return;
    }

    // Предотвращаем зацикливание от рекурсии
    if ( in_array( $hook, $cms["hooks_stack"] ) ) {
        error_log( "recursive hook {$hook}. " . var_export( $cms["hooks_stack"], true ) );
        return;
    } else {
        array_push( $cms["hooks_stack"], $hook );
    }
    
    ksort( $cms["hooks"][$hook] );
    
    // Чтобы одни функции могли отключить другие $functions берется по ссылке.
    // Пример:
    //     $cms["hooks"]["template"][10]["test2"] = false;
    foreach ( $cms["hooks"][$hook] as $priority => &$functions ) {
        // Обязательно числовой приоритет
        if ( is_numeric( $priority ) && is_array( $functions ) ) {
            // $enabled так же берется по ссылке, чтобы иметь отключаемость.
            foreach ( $functions as $function => &$enabled ) {
                if ( $enabled && function_exists( $function ) ) {
                    $function( $params );
                    // Принимающие функции должны быть определены так:
                    // function test222( &$params ) {
                    //     $params["z"] = 777;
                    // }
                }
                // Можно было бы использовать анонимную функцию в $enabled для динамической проверки условия запуска,
                // но потребуется PHP 5.3+ и это не даст ощутимого результата поскольку и без этого можно заранее вычислять
                // и ставить true/false значения для простой проверки:
                //     $cms["hooks"]["template"][10]["test2"] = $cms["pdo"] ? true : false;
                // Также is_callable() менее производительна чем простая проверка function_exists().
            }
        }
    }

    // Удаляем текущий хук из стека
    array_pop( $cms["hooks_stack"] );
}


// Чтобы не потерять старый конфиг при записи при нехватке места, выключении,
// запись идет во временный файл и затем переименование.
function cms_save_config() {
    global $cms;
    
    $permissions = 0644;
    if ( is_file( $cms["config_file"] ) ) {
        $perms = fileperms( $cms["config_file"] );
        if ( $perms !== false ) {
            $permissions = $perms & 0777;
        }
    }

    $tmp_name = $cms["config_file"] . "." . uniqid( "", true );
    $content = '<?php $cms["config"] = ' . var_export( $cms["config"], true) . ";\n";

    if ( file_put_contents( $tmp_name, $content ) === false ) {
        return false;
    }

    if ( ! chmod( $tmp_name, $permissions ) ) {
        unlink( $tmp_name );
        return false;
    }

    if ( ! rename( $tmp_name, $cms["config_file"] ) ) {
        unlink( $tmp_name );
        return false;
    }
    
    // PHP 5 >= 5.5.0
    if ( function_exists( "opcache_invalidate" ) ) {
        opcache_invalidate( $cms["config_file"], true );
    }

    return true;
}
// И при таком подходе, и при подходе в котором запись идет непосредственно в файл,
// возможны ситуации "рассинхронизации" конфигурации, когда два экземпляра CMS
// прочитали одни и те же данные или даже разные и затем поменяли настройки в разных
// местах и сохраняют. В итоге в файле окажутся последние записанные настройки,
// с потерей от предыдущих экземпляров CMS.
// Возможно поэтому многие CMS используют базу данных для хранения настроек,
// но при таком подходе при потере соединения, CMS оказывается полностью неработоспособна
// в отличии от подхода в котором настройки хранятся в файле.
// Для идеальной синхронизации при файловом подходе, нужно писать функцию, которая
// перед изменением настроек будет эксклюзивно блокировать файл на чтение,
// перечитывать текущее состояние, вносить изменение и затем записывать.
// Но даже такой подход не решит проблему синхронизации - пользователь может видеть
// одни настройки, в то время как они уже изменены другим экземпляром CMS.
// И такое возможно даже если они хранятся в базе данных.


#region Функции отправки письма - это комментарий для схлопывания всех фукнций письма

function cms_email_callback2( $matches ) {
    global $email_images;
    $n = count( $email_images );
    $email_images[$n] = array( "ext" => $matches[1], "base64" => $matches[2] );
    return "cid:{$n}.{$matches[1]}";
}

function cms_email_callback1( $matches ) {
    $r2 = preg_replace_callback( "/data:image\/(\w+);base64,(.+)/u", "cms_email_callback2", $matches[1] );
    return "<img src=\"$r2\"";
}

// $data["to_name"]
// $data["to_email"]
// $data["subject"]
// $data["email_body"]
// $data["from_name"]
// $data["from_email"]
// $data["reply_name"]
// $data["reply_to"]
// $data["files"]
// $data["type"] ( text/html or text/plain (default) )
// $data["sender"] if empty, then noreply@$_SERVER["SERVER_NAME"]
// $data["bcc"]
// return "" if success of "error:..." if error
function cms_email( $data ) {
    global $email_images;

    // На пустую to_email не надо проверять, потому что возможна рассылка через скрытые копии
    if ( isset( $data["to_name"] ) ) {
        $to = "=?utf-8?b?" . base64_encode( $data["to_name"] ) . "?= <" . $data["to_email"] . ">";
    } else {
        $to = $data["to_email"];
    }

    $subject = "=?utf-8?b?" . base64_encode( $data["subject"] ) . "?=";

    if ( isset( $data["from_name"] ) ) {
        $from = "=?utf-8?b?" . base64_encode( $data["from_name"] ) . "?= <" . $data["from_email"] . ">";
    } else {
        $from = $data["from_email"];
    }

    if ( empty( $data["reply_to"] ) ) {
        $reply = $from;
    } else {
        $reply = $data["reply_to"];
        if ( ! empty( $data["reply_name"] ) ) {
            $reply = "=?utf-8?b?" . base64_encode( $data["reply_name"] ) . "?= <" . $data["reply_to"] . ">";
        }
    }

    $boundary = md5( uniqid( time() - 25 ) );
    $boundary_id = md5( uniqid( time() ) );

    if ( empty( $data["sender"] ) ) {
        $sender = "noreply@{$_SERVER['SERVER_NAME']}";
    } else {
        $sender = $data["sender"];
    }

    $headers  = "";
    $headers .= "Sender: {$sender}\r\n";
    $headers .= "From: {$from}\r\n";
    $headers .= "Reply-To: {$reply}\r\n";
    $headers .= "Content-Type: multipart/mixed; boundary=\"{$boundary}\"\r\n";

    if ( ! empty( $data["bcc"] ) ) {
        $headers .= "Bcc: {$data['bcc']}\r\n";
    }

    $email_images = array();
    $body2 = preg_replace_callback( '/<img\s[^>]*src="([^"]+)"/u', "cms_email_callback1", $data["email_body"] );

    if ( empty( $data["type"] ) ) {
        $type = "text/plain";
    } else {
        $type = $data["type"];
    }

    $body_all  = "";
    $body_all .= "--{$boundary}\r\n";
    $body_all .= "Content-Type: multipart/related; boundary=\"{$boundary_id}\"\r\n";
    $body_all .= "\r\n";
    
    $body_all .= "--{$boundary_id}\r\n";
    $body_all .= "Content-Type: {$type}; charset=utf-8;\r\n";
    $body_all .= "Content-Transfer-Encoding: base64\r\n";
    $body_all .= "\r\n";
    $body_all .= chunk_split( base64_encode( $body2 ) ) . "\r\n";
    $body_all .= "\r\n";

    foreach( $email_images as $ind => $image ) {
        $body_all .= "--{$boundary_id}\r\n";
        $body_all .= "Content-Type: image/{$image['ext']}; name=\"{$ind}.{$image['ext']}\"\r\n";
        $body_all .= "Content-ID: <{$ind}.{$image['ext']}>\r\n";
        $body_all .= "Content-Disposition: inline; filename=\"{$ind}.{$image['ext']}\"\r\n";
        $body_all .= "Content-transfer-encoding: base64\r\n";
        $body_all .= "\r\n";
        $body_all .= chunk_split( $image["base64"] ) . "\r\n";
        $body_all .= "\r\n";
    }

    if ( isset( $data["files"] ) && is_array( $data["files"] ) )
    foreach( $data["files"] as $file ) {
        $mime = mime_content_type( $file );
        $name = preg_replace( "/.*\//u", "", $file );
        $body_all .= "--{$boundary_id}\r\n";
        $body_all .= "Content-Type: {$mime}; name=\"{$name}\"\r\n";
        $body_all .= "Content-Transfer-Encoding: base64\r\n";
        $body_all .= "Content-Disposition: attachment; filename=\"{$name}\"\r\n";
        $body_all .= "\r\n";
        $body_all .= chunk_split( base64_encode( file_get_contents( $file ) ) ) . "\r\n";
        $body_all .= "\r\n";
    }

    $body_all .= "--{$boundary_id}--\r\n";
    $body_all .= "\r\n";
    $body_all .= "--{$boundary}--\r\n";
    $body_all .= "\r\n";

    if ( mail( $to, $subject, $body_all, $headers, "-f{$data['from_email']}" ) ) {
        return "";
    } else {
        return "error mail() function";
    }
}

function cms_smtp_read( $socket, $expected_code, $error_message, $debug_file ) {
    $response = '';
    $ok = false;
    while ( $line = fgets( $socket, 1000 ) ) {
        $response .= $line;
        cms_debug( $debug_file, "SERVER: {$line}" );
        // Первые три цифры - код ответа
        if ( substr( $line, 0, 3 ) === $expected_code ) {
            $ok = true;
        }
        // По стандарту (RFC 5321) SMTP-сервер может отправлять многострочные ответы.
        // Если четвертый символ — дефис (-), ответ будет продолжен на следующей строке.
        // Если четвертый символ — пробел (), данная строка является последней в ответе сервера.
        if ( substr( $line, 3, 1 ) === ' ' ) {
            break;
        }
    }

    // fgets() в случае таймаута возвращает false, что прерывает цикл.
    // Чтобы отличить таймаут от другой ошибки, мы проверяем метаданные потока.
    $meta = stream_get_meta_data( $socket );
    if ( $meta['timed_out'] ) {
        fclose( $socket );
        $message = "Connection timed out: {$error_message}";
        cms_debug( $debug_file, $message );
        return $message;
    }

    if ( ! $ok ) {
        fclose( $socket );
        $message = "Error: {$error_message}. Response: {$response}";
        cms_debug( $debug_file, $message );
        return $message;
    }

    return true;
}

// $data["to_email"]
// $data["subject"]
// $data["email_body"]
// $data["from_name"]
// $data["from_email"]
// $data["reply_name"]
// $data["reply_to"]
// $data["files"]
// $data["type"] ( text/plain (default) or text/html )
// $data["sender"] if empty, then noreply@$_SERVER["SERVER_NAME"]
// $data["bcc"]
// $data["smtp_host"]
// $data["smtp_port"]
// $data["smtp_login"]
// $data["smtp_password"]
// return "" if success of "error:..." if error
function cms_smtp_email( $data ) {
    global $email_images, $cms;

    if ( isset( $data["from_name"] ) ) {
        $from = "=?utf-8?b?" . base64_encode( $data["from_name"] ) . "?= <" . $data["from_email"] . ">";
    } else {
        $from = $data["from_email"];
    }

    if ( empty( $data["reply_to"] ) ) {
        $reply = $from;
    } else {
        $reply = $data["reply_to"];
        if ( ! empty( $data["reply_name"] ) ) {
            $reply = "=?utf-8?b?" . base64_encode( $data["reply_name"] ) . "?= <" . $data["reply_to"] . ">";
        }
    }

    $boundary = md5( uniqid( time() - 25 ) );
    $boundary_id = md5( uniqid( time() ) );

    if ( empty( $data["sender"] ) ) {
        $sender = $data["from_email"];
    } else {
        $sender = $data["sender"];
    }
    
    if ( empty( $data["return_path"] ) ) {
        $return_path = $data["from_email"];
    } else {
        $return_path = $data["return_path"];
    }


    /* start headers */
    $SEND  = "Date: " . date( "r" ) . "\r\n";
    $SEND .= "Sender: {$sender}\r\n";
    $SEND .= "Return-Path: {$return_path}\r\n";
    $SEND .= "Reply-To: {$reply}\r\n";
    $SEND .= "From: {$from}\r\n";
    $SEND .= "To: {$data['to_email']}\r\n";
    if ( ! empty( $data["bcc"] ) ) { $SEND .= "Bcc: {$data['bcc']}\r\n"; }
	$SEND .= "Subject: =?utf-8?b?" . base64_encode( $data["subject"] ) . "?=\r\n";
    $SEND .= "Content-Type: multipart/mixed; boundary=\"{$boundary}\"\r\n";
    $SEND .= "\r\n";
    /* end headers */

    /* Move images to appendix */
    $email_images = array();
    $body2 = preg_replace_callback( '/<img +src="([^"]+)"/u', "cms_email_callback1", $data["email_body"] );

    if ( empty( $data["type"] ) ) {
        $type = "text/plain";
    } else {
        $type = $data["type"];
    }

    $SEND .= "--{$boundary}\r\nContent-Type: multipart/related; boundary=\"{$boundary_id}\"\r\n\r\n";
    $SEND .= "--{$boundary_id}\r\nContent-Type: {$type}; charset=utf-8;\r\nContent-Transfer-Encoding: base64\r\n\r\n" . chunk_split( base64_encode( $body2 ) ) . "\r\n\r\n";

    foreach( $email_images as $ind => $image ) {
        $SEND .= "--{$boundary_id}\r\n";
        $SEND .= "Content-Type: image/{$image['ext']}; name=\"{$ind}.{$image['ext']}\"\r\n";
        $SEND .= "Content-ID: <{$ind}.{$image['ext']}>\r\n";
        $SEND .= "Content-Disposition: inline; filename=\"{$ind}.{$image['ext']}\"\r\n";
        $SEND .= "Content-transfer-encoding: base64\r\n\r\n";
        $SEND .= chunk_split( $image['base64'] );
        $SEND .= "\r\n\r\n";
    }

    if ( isset( $data["files"] ) && is_array( $data["files"] ) ) {
        foreach( $data["files"] as $file ) {
            $mime = mime_content_type( $file );
            $name = preg_replace( "/.*\//u", "", $file );
            $SEND .= "--{$boundary_id}\r\n";
            $SEND .= "Content-Type: {$mime}; name=\"{$name}\"\r\n";
            $SEND .= "Content-Transfer-Encoding: base64\r\n";
            $SEND .= "Content-Disposition: attachment; filename=\"{$name}\"\r\n\r\n";
            $SEND .= chunk_split( base64_encode( file_get_contents( $file ) ) ) . "\r\n\r\n";
        }
    }

    $SEND .= "--{$boundary_id}--\r\n\r\n";
    $SEND .= "--{$boundary}--\r\n\r\n";

    $debug_file = "{$cms['site_dir']}/.smtp_debug.txt";
    cms_debug( $debug_file, "\n---" );

    // Connect
    cms_debug( $debug_file, "fsockopen {$data['smtp_host']} {$data['smtp_port']}" );
    $socket = fsockopen( $data["smtp_host"], $data["smtp_port"], $errno, $errstr, 10 ); // 10 sec timeout
    if( $socket === false ) {
        cms_debug( $debug_file, "fsockopen error {$errno} {$errstr}" );
        return "{$errno} {$errstr}";
    }
    cms_debug( $debug_file, "fsockopen ok" );

    $sst = stream_set_timeout( $socket, 5 ); // 5 sec timeout
    cms_debug( $debug_file, "stream_set_timeout: {$sst}" );
 
    $result = cms_smtp_read( $socket, "220", "waiting for greeting", $debug_file );
    if ( $result !== true ) return $result;

    // HELLO
    cms_debug( $debug_file, "ME: EHLO {$_SERVER['SERVER_NAME']}" );
    fputs( $socket, "EHLO {$_SERVER['SERVER_NAME']}\r\n");

    $result = cms_smtp_read( $socket, "250", "after EHLO", $debug_file );
    if ( $result !== true ) return $result;
    
    // AUTH
    cms_debug( $debug_file, "ME: AUTH LOGIN" );
    fputs( $socket, "AUTH LOGIN\r\n" );

    $result = cms_smtp_read( $socket, "334", "waiting for login prompt", $debug_file );
    if ( $result !== true ) return $result;
    
    // Send login
    cms_debug( $debug_file, "ME: base64_encode( smtp_login )" );
    fputs( $socket, base64_encode( $data["smtp_login"] ) . "\r\n" );

    $result = cms_smtp_read( $socket, "334", "waiting for password prompt", $debug_file );
    if ( $result !== true ) return $result;

    // Send password
    cms_debug( $debug_file, "ME: base64_encode( smtp_password )" );
    fputs( $socket, base64_encode( $data["smtp_password"] ) . "\r\n" );

    $result = cms_smtp_read( $socket, "235", "after sending password", $debug_file );
    if ( $result !== true ) return $result;

    // Send FROM
    cms_debug( $debug_file, "ME: MAIL FROM: <{$data['from_email']}>" );
    fputs( $socket, "MAIL FROM: <{$data['from_email']}>\r\n" );

    $result = cms_smtp_read( $socket, "250", "after MAIL FROM", $debug_file );
    if ( $result !== true ) return $result;

    // Send recipient
    // Через запятую можно указать несколько получателей
    foreach ( explode( ",", $data["to_email"] ) as $rcpt ) {
        cms_debug( $debug_file, "ME: RCPT TO: {$rcpt}" );
        fputs( $socket, "RCPT TO: {$rcpt}\r\n" );

        $result = cms_smtp_read( $socket, "250", "after RCPT TO", $debug_file );
        if ( $result !== true ) return $result;
    }

    // BCC
    // Через запятую можно указать несколько получателей
    if ( ! empty( $data["bcc"] ) ) {
        foreach ( explode( ",", $data["bcc"] ) as $rcpt ) {
            cms_debug( $debug_file, "ME (bcc): RCPT TO: {$rcpt}" );
            fputs( $socket, "RCPT TO: {$rcpt}\r\n" );

            $result = cms_smtp_read( $socket, "250", "after RCPT TO (BCC)", $debug_file );
            if ( $result !== true ) return $result;
        }
    }
    
    // Email body command
    cms_debug( $debug_file, "ME: DATA" );
    fputs( $socket, "DATA\r\n" );

    $result = cms_smtp_read( $socket, "354", "after DATA command", $debug_file );
    if ( $result !== true ) return $result;

    // Send email body
    cms_debug( $debug_file, "ME SEND DATA" );
	fputs( $socket, "{$SEND}\r\n.\r\n" );

    $result = cms_smtp_read( $socket, "250", "after sending email body", $debug_file );
    if ( $result !== true ) return $result;

    // Quit command
    cms_debug( $debug_file, "ME: QUIT\n" );
	fputs( $socket, "QUIT\r\n" );
	fclose( $socket );
	return "";
}

#endregion Функции отправки письма


function cms_debug( $debug_file, $string ) {
    global $cms;
    if ( empty( $cms["config"]["debug"] ) ) {
        return;
    }
    // Следим за размером файла
    if ( is_file( $debug_file ) && filesize( $debug_file ) > 16384 ) {
        rename( $debug_file, $debug_file . ".old" );
    }
    // Сбрасываем инфу
    if ( substr( $string, -1 ) !== "\n" ) { $string .= "\n"; }
    file_put_contents( $debug_file, $string, FILE_APPEND );
}


// Функция для отправки файла браузеру малыми порциями,
// чтобы не было ошибок с закончившейся памятью
function cms_readfile( $file, $headers = true ) {
    if ( file_exists( $file ) ) {
        /*
        if (ob_get_level()) {
            ob_end_clean();
        }
        */
        if ( $headers ) {
            header( "Content-Description: File Transfer" );
            $mime = mime_content_type( $file );
            header( "Content-Type: {$mime}" );
            $basename = basename( $file );
            header( "Content-Disposition: attachment; filename=\"{$basename}\"" );
            header( "Content-Transfer-Encoding: binary" );
            header( "Expires: 0" );
            header( "Cache-Control: must-revalidate, post-check=0, pre-check=0" );
            header( "Pragma: public" );
            $filesize = filesize( $file );
            header( "Content-Length: {$filesize}" );
            ob_clean();
            flush();
        }
        if ( $handle = fopen( $file, "rb" ) ) {
            while ( ! feof( $handle ) ) {
              print fread( $handle, 1024 );
            }
            return fclose( $handle );
        } else {
            return false;
        }
    } else {
        return false;
    }
}


// Принимает строку, которую нужно перевести
// и модуль ("page.mod.php") если нужно дернуть перевод из другого
function __( $string, $module = "" ) {
    global $cms;

    // Определяем вызвавший файл
    $backtrace = debug_backtrace();
    $basename = pathinfo( $backtrace[0]["file"], PATHINFO_BASENAME );
    $basedir = basename( pathinfo( $backtrace[0]["file"], PATHINFO_DIRNAME ) );
    
    if ( $module === "" ) {
        // Имя модуля определяем по имени файла из которого вызвана функция перевода
        $module = $basename;
        
        // Автовыбор модуля для шаблона админки
        if ( $basedir === "admin.cms" && $basename === "html.php" ) {
            $module = "admin.mod.php";
        }
    }

    // Загрузка файла перевода если еще не загружен
    if ( ! isset( $cms["lang"][$module][ $cms["config"]["locale"] ] ) ) {
        $file = "{$cms['cms_dir']}/lang/{$cms['config']['locale']}/{$module}";

        if ( is_file( $file ) ) {
            include_once( $file );
        } else {
            $cms["lang"][$module][ $cms["config"]["locale"] ] = array();
        }

        // translit
        include_once( "{$cms["cms_dir"]}/lang/translit.php" );
    }

    // Translate
    if ( isset( $cms["lang"][$module][ $cms["config"]["locale"] ][$string] ) ) {
        return $cms["lang"][$module][ $cms["config"]["locale"] ][$string];
    } else {
        // Чтобы узнать какие переводы отсутствуют,
        // нужно включить отладку в config.php
        //     $cms["config"]["debug"] = true;
        // Результаты буду в файле /.debug.log.php
        if ( ! empty( $cms["config"]["debug"] )
          && ! empty( $string )
          && empty( $cms["debug"]["translate"][$string] ) )
        {
            $line = $backtrace[0]["line"];
            $t = "translate to {$cms["config"]["locale"]}";
            $f = "{$basename}:{$line}";
            if ( ! isset( $cms["debug"][$t][$f] ) ) {
                $cms["debug"][$t][$f] = array();
            }
            if ( ! in_array( $string, $cms["debug"][$t][$f] ) ) {
                array_push( $cms["debug"][$t][$f], $string );
            }
        }
        return $string;
    }
}


function cms_translit( $string, $trim_dash = true ) {
    global $cms;
    $tr1 = strtr( $string, $cms["tr"] );
    $tr2 = strtr( $tr1, array( " " => "-" ) );
    $tr3 = preg_replace( "/[^-A-Za-z0-9_]+/u", "", $tr2 );
    if ( $trim_dash ) {
        $tr3 = trim( $tr3, "-_" );
    }
    return $tr3;
}


// Вспомогательная функция для последующей cms_asort()
function cms_uasort( $a, $b ) {
    if ( ! isset( $a["sort"] ) || ! isset( $b["sort"] ) ) {
        return 0;
    }
    if ( $a["sort"] <= $b["sort"] ) {
        return -1;
    } else {
        return 1;
    }
}
// Сортирует массив из элементов, которые тоже являются массивами
// и содержат ключ sort
function cms_asort( &$array ) {
    if ( ! is_array( $array ) ) return false;
    return uasort( $array, "cms_uasort" );
}


// Рекурсивное удаление папок
function recurse_rm( $src ) /* bool */ {
    if ( ! is_dir( $src ) ) {
        return false;
    }
    $dir = opendir( $src );
    // readdir может вернуть аналог false, поэтому строгая проверка
    while( false !== ( $file = readdir( $dir ) ) ) {
        if ( ( $file != "." ) && ( $file != ".." ) ) {
            if ( is_dir( "{$src}/{$file}" ) ) {
                recurse_rm( "{$src}/{$file}" );
            } else {
                if ( ! unlink( "{$src}/{$file}" ) ) {
                    return false;
                }
            }
        }
    }
    closedir( $dir );
    return rmdir( $src );
}


function is_email( $str ) {
    return preg_match( "/^([a-zA-Z0-9_\-\.]+)@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.)|(([a-zA-Z0-9\-]+\.)+))([a-zA-Z]{2,4}|[0-9]{1,9})(\]?)$/", $str );
}


// Returns a file size limit in bytes based on the PHP upload_max_filesize
// and post_max_size
function file_upload_max_size() {
    $max_size = -1;
  
    if ( $max_size < 0 ) {
        // Start with post_max_size.
        $post_max_size = parse_size( ini_get( "post_max_size" ) );
        if ( $post_max_size > 0 ) {
            $max_size = $post_max_size;
        }
    
        // If upload_max_size is less, then reduce. Except if upload_max_size is
        // zero, which indicates no limit.
        $upload_max = parse_size( ini_get( "upload_max_filesize" ) );
        if ( $upload_max > 0 && $upload_max < $max_size ) {
            $max_size = $upload_max;
        }
    }
    return $max_size;
}


function parse_size( $size ) {
    $unit = preg_replace( "/[^bkmgtpezy]/i", "", $size ); // Remove the non-unit characters from the size.
    $size = preg_replace( "/[^0-9\.]/", "", $size ); // Remove the non-numeric characters from the size.
    if ( $unit ) {
        // Find the position of the unit in the ordered string which is the power of magnitude to multiply a kilobyte by.
        return round( $size * pow( 1024, stripos( "bkmgtpezy", $unit[0] ) ) );
    } else {
        return round( $size );
    }
}


// Возвращает true только если это пустая папка
function is_dir_and_empty( $dir ) {
    if ( ! is_dir( $dir ) ) {
        return false;
    }
    $handle = opendir( $dir );
    while ( false !== ( $entry = readdir( $handle ) ) ) {
        if ( $entry != "." && $entry != ".." ) {
            closedir( $handle );
            return false;
        }
    }
    closedir( $handle );
    return true;
}


// Генерация uid для всяких нужд
function cms_admin_pass_gen( $count = 8 ) {
    $chars = "0123456789AaBbCcDdEeFfGgHhiJjKkLMmNnOoPpQqRrSsTtUuVvWwXxYyZz";
    $strlen = strlen( $chars ) - 1;
    $password = "";
    while ( $count-- ) {
        $password .= $chars[ mt_rand( 0, $strlen ) ];
    }
    return $password;
}


// Генерация uid для всяких нужд
function cms_uid( $count = 8 ) {
    $chars = "0123456789AaBbCcDdEeFfGgHhiJjKkLMmNnOoPpQqRrSsTtUuVvWwXxYyZz";
    $strlen = strlen( $chars ) - 1;
    $password = "";
    while ( $count-- ) {
        $password .= $chars[ mt_rand( 0, $strlen ) ];
    }
    return $password;
}


// Выравнивание текста по правому краю.
function align_right( $string, $width ) {
    $repeat = $width - mb_strlen( $string );
    if ( $repeat > 0 ) {
        $padding = str_repeat( " ", $repeat );
    } else {
        $padding = "";
    }
    return $padding . $string;
}


function format_date( $date, $pattern = "d MMMM yyyy, HH:mm" ) {
    global $cms;

    // Для старых версий PHP без Intl или DateTimeImmutable
    if (PHP_VERSION_ID < 50500) {
        // Простой fallback для very old PHP
        if (is_numeric($date)) {
            return date('d M Y, H:i', $date);
        }
        return (string)$date;
    }

    // Создаем копию форматтера, чтобы не изменять глобальный объект
    $formatter = new IntlDateFormatter(
        $cms["config"]["locale"], // Явно указываем локаль из конфигурации
        IntlDateFormatter::NONE, // Отключаем стандартный формат даты
        IntlDateFormatter::NONE, // Отключаем стандартный формат времени
        null, // Используем временную зону из date_default_timezone_set()
        null,
        $pattern // Формат: 27 сентября 2025, 16:25
    );

    // Если на входе число (timestamp), сразу форматируем его
    if ( is_numeric( $date ) ) {
        return $formatter->format( $date );
    }

    // Если на входе строка, пытаемся создать из нее объект DateTime
    // и затем форматируем. В случае неудачи возвращаем исходную строку.
    try {
        $date_obj = new DateTimeImmutable( (string) $date );
        return $formatter->format( $date_obj );
    } catch ( Exception $e ) {
        return (string) $date; // Возвращаем исходное значение при ошибке
    }
}


function cms_exec_sql_file( $sql_file ) {
    global $cms;

    if ( is_file( $sql_file ) ) {
        cms_base_connect();
        if ( ! empty( $cms["pdo"] ) ) {
            // Предполагаются однострочные запросы.
            $statements = file( $sql_file ); //: array|false
            if ( $statements ) {
                foreach ( $statements as $stmt ) {
                    $stmt = trim( $stmt );
                    if ( $stmt === "" ) {
                        continue;
                    }
                    try {
                        $cms["pdo"]->exec( $stmt );
                    } catch ( PDOException $e ) {
                        $err_msg = $e->getMessage();
                        error_log( "SQL execution error in file \"{$sql_file}\": " . $err_msg );
                        return array( "error" => $err_msg );
                    }
                }
            }
        } else {
            return array( "error" => "no_pdo" );
        }
    } else {
        return array( "error" => "no_file" );
    }
    return array( "result" => true );
}


// Выбираем во что завернуть регулярное выражение чтобы не экранировать символы
function preg_prepare( $pattern ) {
    // Список безопасных символов для разделителя
    $delimiters = array( '`', '!', '@', '%', '~', ';', '|', ':', '#' );

    // Ищем первый символ, который не встречается в паттерне
    foreach ( $delimiters as $current ) {
        if ( strpos( $pattern, $current ) === false ) {
            $delimiter = $current;
            break;
        }
    }

    if ( isset( $delimiter ) ) {
        return $delimiter . $pattern . $delimiter . "u";
    }

    return "/" . str_replace( "/", "\/", $pattern ) . "/u";
}


function json_pretty( $arr, $indent = 0, $trim_first = true ) {
    if ( PHP_VERSION_ID < 50400 ) {
        return json_encode( $arr );
    }
    $text = json_encode( $arr, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
    $lines  = explode( "\n" , $text );

    $indent = str_repeat( " " , $indent );

    foreach ( $lines as &$line ) {
        if ( trim( $line ) !== "" ) {
            $line = $indent . $line;
        }
    }

    $result = implode( "\n" , $lines );
    
    if ( $trim_first ) {
        $result = ltrim( $result );
    }

    return $result;
}

function lines_indent( $text, $indent ) {
    $indent = str_repeat( " " , $indent );
    $lines = explode( "\n", $text );
    foreach ( $lines as &$line ) {
        if ( trim( $line ) !== "" ) {
            $line = $indent . $line;
        }
    }
    return implode( "\n", $lines );
}

function cms_api_url() {
    global $cms;

    $url = "{$cms["url"]["scheme"]}://{$cms["url"]["host"]}";
    if ( ! empty( $cms["url"]["port"] ) ) {
        $url .= ":{$cms["url"]["port"]}";
    }
    $url .= "{$cms["base_path"]}{$cms["config"]["admin.mod.php"]["api_url"]}";

    // Добавляем базовый путь CMS и, при необходимости, дополнительный путь
    return $url;
}

// $delta - через сколько секунд истекают
function cms_set_cookie( $key, $value, $delta ) {
    if ( PHP_VERSION_ID < 70300 ) {
        setcookie( $key, $value, time() + $delta );
    } else {
        setcookie( $key, $value, array( "SameSite" => "Lax", "expires" => time() + $delta ) );
    }
}

function cms_set_sess_cookie( $key, $value ) {
    if ( PHP_VERSION_ID < 70300 ) {
        setcookie( $key, $value );
    } else {
        setcookie( $key, $value, array( "SameSite" => "Lax" ) );
    }
}

// Возвращает абсолютный путь к файлу или false, если путь подозрительный
// $rel_path не должен начинаться на / и не должен содержать .. или . или //
// При флаге SITE_ESC_CHECK_FILE проверяет существование файла
define( 'SITE_ESC_CHECK_FILE', 1 );
function site_esc_file( $base_path, $rel_path, $flags = 0 ) {
    global $cms;
    
    // Проверяем что $base_path не пустой и не выше корня сайта
    if ( empty( $base_path ) || strpos( $base_path, $cms["site_dir"] ) !== 0 ) {
        return false;
    }

    $in_parts = preg_split( "#/|\\\\#", $rel_path );
    $out_parts = array();
    foreach ( $in_parts as $part ) {
        if ( in_array( $part, array( "..", "", "." ) ) ) {
            return false;
        } else {
            array_push( $out_parts, $part );
        }
    }

    if ( count( $out_parts ) < 1 ) {
        return false;
    }

    $result_path = rtrim( $base_path, "/" );
    $result_path .= "/" . implode( "/", $out_parts );

    if ( strpos( $result_path, $cms["cms_dir"] ) === 0 ) {
        return false;
    }

    if ( $flags & SITE_ESC_CHECK_FILE && ! is_file( $result_path ) ) {
        return false;
    }

    return $result_path;
}

function is_admin_header_on( $mod ) {
    global $cms;
    if ( isset( $cms["config"][$mod]["menu"] ) ) {
        $visibles = array();
        $menu = $cms["config"][$mod]["menu"];
        foreach ( $menu as $name => $item ) {
            $visible = empty( $item["hide"] );
            $section = $item["section"];
            $section_visible = empty( $cms["config"]["admin_sections"][$section]["hide"] );
            array_push( $visibles, $visible && $section_visible );
        }
        $visible = false;
        // Логическое "или" для всех пунктов
        foreach ( $visibles as $vis ) {
            $visible = $visible || $vis;
        }
    } else {
        // Нет данных о скрытии
        $visible = true;
    }

    return $visible;
}

function widget( $widget, $options = array() ) {
    global $cms;
    $backtrace = debug_backtrace();
    $module = pathinfo( $backtrace[0]["file"], PATHINFO_BASENAME );
    $file = "{$cms["cms_dir"]}/.widgets/{$widget}";
    if ( ! is_file( $file ) ) {
        return "<!-- no widget file -->";
    }
    $ext = strtolower( pathinfo( $file, PATHINFO_EXTENSION ) );
    if ( $ext === "php" ) {
        ob_start();
        include( $file );
        $content = ob_get_clean();
    } elseif ( $ext === "html" ) {
        $content = file_get_contents( $file );
        if ( preg_match_all( "/(__(.+?)__)/", $content, $matches, PREG_SET_ORDER ) ) {
            foreach ( $matches as $match ) {
                $translate = __( $match[2], $module );
                $content = str_replace( $match[1], $translate, $content );
            }
        }
    } else {
        return "<!-- unknown widget extension -->";
    }
    return $content;
}

if ( ! function_exists( "array_is_list" ) ) {
    function array_is_list( $array ) {
        if ( ! is_array( $array ) ) {
            return false;
        }
        
        if ( empty( $array ) ) {
            return true;
        }
        
        $i = 0;
        foreach ( $array as $key => $value ) {
            if ( ! is_int( $key ) || $key !== $i ) {
                return false;
            }
            $i++;
        }
        
        return true;
    }
}
