<?php
/**
 * 全局辅助函数（视图与控制器中可直接调用）
 */

if (!function_exists('site_url')) {
    function base_url(): string
    {
        $proto = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
        $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
        // 由 DOCUMENT_ROOT + SCRIPT_FILENAME 推导站点根路径，兼容内置服务器路由模式与生产环境
        $docroot = $_SERVER['DOCUMENT_ROOT'] ?? '';
        $scriptFile = $_SERVER['SCRIPT_FILENAME'] ?? '';
        $basePath = '';
        if ($docroot && $scriptFile && strpos($scriptFile, $docroot) === 0) {
            $rel = substr($scriptFile, strlen($docroot)); // 如 /index.php 或 /sub/index.php
            $basePath = rtrim(dirname($rel), '/');
        }
        return rtrim($proto . '://' . $host . $basePath, '/');
    }
    function site_url(string $path = ''): string
    {
        return base_url() . '/' . ltrim($path, '/');
    }
    function asset(string $path = ''): string
    {
        return site_url('assets/' . ltrim($path, '/'));
    }
    function e($v): string
    {
        return htmlspecialchars((string) $v, ENT_QUOTES | ENT_HTML5, 'UTF-8');
    }
    function slugify(string $s): string
    {
        $s = preg_replace('~[^\pL\pN]+~u', '-', $s);
        $s = trim($s, '-');
        return strtolower($s) ?: 'item';
    }
    function format_date($ts, string $fmt = 'Y-m-d'): string
    {
        if (!$ts) return '';
        $t = is_numeric($ts) ? (int)$ts : strtotime($ts);
        return $t ? date($fmt, $t) : '';
    }
    function csrf_token(): string
    {
        if (empty($_SESSION['_csrf'])) {
            $_SESSION['_csrf'] = bin2hex(random_bytes(16));
        }
        return $_SESSION['_csrf'];
    }
    function csrf_field(): string
    {
        return '<input type="hidden" name="_csrf" value="' . csrf_token() . '">';
    }
    function csrf_check(): bool
    {
        $token = $_POST['_csrf'] ?? ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
        return isset($_SESSION['_csrf']) && hash_equals($_SESSION['_csrf'], $token);
    }
    /** 当前管理员是否已登录 */
    function is_admin(): bool
    {
        return !empty($_SESSION['admin_logged']);
    }
    function admin_required()
    {
        if (!is_admin()) {
            header('Location: ' . site_url('admin/login'));
            exit;
        }
    }
    /** 当前登录管理员的角色：super_admin | admin | user | none */
    function admin_role(): string
    {
        return $_SESSION['admin_role'] ?? 'user';
    }
    /** 当前登录管理员 ID（配置文件兜底登录时为 0） */
    function admin_uid(): ?int
    {
        return $_SESSION['admin_id'] ?? null;
    }
    /** 角色 -> 能力映射（可访问的模块/操作） */
    function admin_role_map(): array
    {
        return [
            'super_admin' => ['dashboard', 'products', 'categories', 'news', 'cases', 'pages', 'banners', 'settings', 'theme', 'users', 'system', 'orders', 'payments', 'password'],
            'admin'       => ['dashboard', 'products', 'categories', 'news', 'cases', 'pages', 'banners', 'orders', 'password'],
            // user 为受限角色：默认仅仪表盘权限，不预开 products/news/cases 等后台模块。
            // 进入 CRM/PSI 后左导据此严格收敛——CRM 操作员（后台角色=user）只看到仪表盘 + 当前子系统功能，
            // 其余后台模块由其是否拥有对应 admin 能力决定；后台管理员/超管不受影响（见 admin / super_admin 行）。
            'user'        => ['dashboard', 'password'],
            // none = 无后台主角色：该账号仅作为 CRM/PSI 子系统账号存在，不拥有任何后台模块权限。
            // 进入子系统后左导仍按 subsys_role 严格收敛，避免与「用户」混淆导致角色分配混乱。
            'none'        => ['password'],
        ];
    }
    /** 当前登录管理员是否具备某项能力 */
    function admin_can(string $cap): bool
    {
        $role = admin_role();
        return in_array($cap, admin_role_map()[$role] ?? [], true);
    }
    /** 角色中文名 */
    function admin_role_label(string $role): string
    {
        return ['super_admin' => '超级管理员', 'admin' => '管理员', 'user' => '用户', 'none' => '无'][$role] ?? '用户';
    }
    /** 必须是指定角色，否则拦截（用于控制器构造函数） */
    function role_required(string $role): void
    {
        if (!is_admin()) {
            header('Location: ' . site_url('admin/login'));
            exit;
        }
        if (admin_role() !== $role) {
            \Core\App::forbidden('需要 ' . admin_role_label($role) . ' 权限');
            exit;
        }
    }
    /** 根据种子生成品牌渐变（用于占位图） */
    function gradient($seed = 0): string
    {
        $angle = 110 + (intval($seed) * 37) % 160;
        return "linear-gradient({$angle}deg,var(--c-primary),var(--c-secondary))";
    }

    /* ---------- 分系统权限（CRM / 进销存 PSI） ---------- */
    /**
     * 用户在某业务系统中的角色。super_admin 在任意系统都视为最高权限管理员。
     * @param string $sys crm | psi
     */
    function subsys_role(string $sys): string
    {
        if (admin_role() === 'super_admin') return 'super_admin';
        $key = $sys . '_role';
        return $_SESSION[$key] ?? 'none';
    }
    /** 是否为某系统的管理员（含超管） */
    function subsys_admin(string $sys): bool
    {
        return in_array(subsys_role($sys), ['super_admin', 'admin'], true);
    }
    /** 是否为某系统的用户（含管理员、超管） */
    function subsys_user(string $sys): bool
    {
        return subsys_role($sys) !== 'none';
    }
    /** 是否能进入某业务系统（拥有该系统任意角色即可：超管/管理员/用户） */
    function subsys_can_enter(string $sys): bool
    {
        return subsys_user($sys);
    }

    /** 分系统页面清单（key 与子系统 nav 的 k 对应；dashboard 始终可见） */
    function subsys_pages(string $sys): array
    {
        return $sys === 'psi'
            ? ['dashboard', 'materials', 'products', 'suppliers', 'purchases', 'sales', 'stock', 'orders',
               'sales_orders', 'purchase_orders', 'outbounds', 'reports', 'reminders']
            : ['dashboard', 'customers', 'leads', 'followups', 'contacts'];
    }

    /** 当前 PSI 用户未读的紧急事件数量（用于导航铃铛徽标） */
    function psi_unread_events(): int
    {
        if (!subsys_user('psi')) return 0;
        $uid = (int) ($_SESSION['admin_uid'] ?? 0);
        if ($uid <= 0) return 0;
        try {
            $events = (new \App\Models\PSI\Event())->all();
        } catch (\Throwable $e) {
            return 0;
        }
        $n = 0;
        foreach ($events as $ev) {
            $read = json_decode($ev['read_by'] ?? '[]', true) ?: [];
            if (!in_array($uid, $read, true)) $n++;
        }
        return $n;
    }
    /**
     * 当前登录用户在 $sys 系统各页面的「可见」权限数组。
     * 子系统管理员（含超管，subsys_admin）拥有该系统全部页面；
     * 仅“用户”角色读 session 中的 {sys}_perms（登录时写入）做细粒度控制；
     * 旧账号无 perms 记录则默认全部可见（向后兼容，不会突然锁死）。
     */
    function subsys_page_perms(string $sys): array
    {
        // 关键修复：以“分系统角色”判定管理员，而非仅看主角色是否为 super_admin。
        // 否则 CRM/PSI 管理员（crm_role=admin、主角色为“管理员”）会被误判为普通用户，
        // 一旦 {sys}_perms 受限就只剩仪表盘，导致左导菜单残缺、子页面 403。
        if (subsys_admin($sys)) {
            return array_fill_keys(subsys_pages($sys), true) + ['dashboard' => true];
        }
        $perms = $_SESSION[$sys . '_perms'] ?? null;
        $out = ['dashboard' => true];
        foreach (subsys_pages($sys) as $p) {
            if ($p === 'dashboard') continue;
            $out[$p] = ($perms === null) ? true : !empty($perms[$p]);
        }
        return $out;
    }
    /** 当前用户能否进入 $sys 系统的某页面 */
    function subsys_page_can(string $sys, string $page): bool
    {
        return !empty(subsys_page_perms($sys)[$page]);
    }
    /** 过滤子系统侧边导航：隐藏无权限页面项（dashboard 永留） */
    function subsys_filter_nav(string $sys, array $nav): array
    {
        return array_values(array_filter($nav, function ($n) use ($sys) {
            if (($n['k'] ?? '') === 'dashboard') return true;
            return subsys_page_can($sys, $n['k']);
        }));
    }
    /**
     * 登录后落地页：依据子系统权限优先进入对应子系统仪表盘。
     * 设计目标：拥有 CRM / PSI 权限的账号登录后直达「CRM / PSI 仪表盘」，
     * 而非总后台仪表盘；总后台仪表盘仅留给「无任何子系统权限」的纯后台账号。
     * - 仅拥有 CRM：进入 CRM 仪表盘
     * - 仅拥有 PSI：进入 PSI 仪表盘
     * - 同时拥有 CRM+PSI：默认进入 CRM 仪表盘（左侧导航可切换 PSI）
     * - 无任何子系统权限（纯内容管理员 / 编辑）：进入总后台仪表盘
     */
    function login_landing(): string
    {
        $crm = subsys_user('crm');
        $psi = subsys_user('psi');
        if ($crm && !$psi) return 'CRM';
        if ($psi && !$crm) return 'PSI';
        if ($crm && $psi)  return 'CRM';
        return 'admin';
    }

    /**
     * 后台（admin）左侧导航全量项。admin 主后台与 CRM / PSI 子系统布局共用，
     * 保证「后台框架一致」：进入 CRM / PSI 后左侧仍是同一套完整后台菜单（当前子系统主项高亮）。
     * 各页面项按角色能力（admin_can）过滤，子系统入口按 subsys_user 显隐，
     * 与 App 路由层的 capMap / 权限拦截保持一致。
     */
    function admin_nav_items(): array
    {
        $baseItems = [
            ['k' => '',            'label' => '仪表盘',   'ic' => 'dashboard', 'url' => 'admin'],
            ['k' => 'products',    'label' => '产品管理', 'ic' => 'snowflake', 'url' => 'admin/products'],
            ['k' => 'categories',  'label' => '分类管理', 'ic' => 'folders',   'url' => 'admin/categories'],
            ['k' => 'news',        'label' => '新闻管理', 'ic' => 'newspaper', 'url' => 'admin/news'],
            ['k' => 'cases',       'label' => '客户案例', 'ic' => 'handshake', 'url' => 'admin/cases'],
            ['k' => 'pages',       'label' => '单页管理', 'ic' => 'file-text', 'url' => 'admin/pages'],
            ['k' => 'banners',     'label' => '轮播管理', 'ic' => 'images',    'url' => 'admin/banners'],
            ['k' => 'settings',    'label' => '站点设置', 'ic' => 'settings',  'url' => 'admin/settings'],
            ['k' => 'theme',       'label' => '风格设置', 'ic' => 'palette',   'url' => 'admin/theme'],
        ];
        $nav = [];
        foreach ($baseItems as $it) {
            // 仪表盘始终可见；其余按角色能力 admin_can 过滤（无权限则隐藏且不可直访）
            if ($it['k'] === '' || admin_can($it['k'])) $nav[] = $it;
        }
        // 子系统入口：拥有对应系统角色的管理员可见（点击进入 CRM / PSI）
        if (subsys_user('crm')) $nav[] = ['k' => 'crm', 'label' => '客户管理 CRM', 'ic' => 'handshake', 'url' => 'CRM'];
        if (subsys_user('psi')) $nav[] = ['k' => 'psi', 'label' => '进销存 PSI', 'ic' => 'package', 'url' => 'PSI'];
        // 仅超级管理员可见「用户管理 / 订单管理 / 支付设置 / 系统设置 / 数据库管理 / 数据库升级」
        if (admin_role() === 'super_admin') {
            $nav[] = ['k' => 'users',    'label' => '用户管理', 'ic' => 'users',    'url' => 'admin/users'];
            $nav[] = ['k' => 'orders',   'label' => '订单管理', 'ic' => 'receipt',  'url' => 'admin/orders'];
            $nav[] = ['k' => 'payments', 'label' => '支付设置', 'ic' => 'wallet',   'url' => 'admin/payments'];
            $nav[] = ['k' => 'system',   'label' => '系统设置', 'ic' => 'settings', 'url' => 'admin/system'];
            $nav[] = ['k' => 'db',       'label' => '数据库管理', 'ic' => 'database', 'url' => 'admin/db'];
            $nav[] = ['k' => 'upgrade',  'label' => '数据库升级', 'ic' => 'upload',   'url' => 'admin/upgrade',
                'badge' => (\Core\Db::driver() === 'mysql' ? db_pending_upgrades() : 0) ?: null];
        } elseif (admin_role() === 'admin') {
            $nav[] = ['k' => 'orders', 'label' => '订单管理', 'ic' => 'receipt', 'url' => 'admin/orders'];
        }
        return $nav;
    }

    /**
     * 分系统（CRM/PSI）侧边导航：统一来源，含「用户管理」（仅该系统管理员可见）。
     * 与 layouts/subsys.php 共用，避免逐个控制器重复维护导航数组。
     */
    function subsys_nav(string $sys): array
    {
        $pages = $sys === 'psi'
            ? [
                ['k' => 'dashboard', 'label' => '仪表盘',     'url' => 'PSI'],
                ['k' => 'materials', 'label' => '物料管理',   'url' => 'PSI/materials'],
                ['k' => 'products',  'label' => '成品管理',   'url' => 'PSI/products'],
                ['k' => 'suppliers', 'label' => '供应商',     'url' => 'PSI/suppliers'],
                ['k' => 'purchases', 'label' => '采购入库',   'url' => 'PSI/purchases'],
                ['k' => 'sales',     'label' => '销售出库',   'url' => 'PSI/sales'],
                ['k' => 'stock',     'label' => '库存流水',   'url' => 'PSI/stock'],
                ['k' => 'orders',    'label' => '订单管理',   'url' => 'PSI/orders'],
                ['k' => 'sales_orders',    'label' => '销售订单',   'url' => 'PSI/sales_orders'],
                ['k' => 'purchase_orders', 'label' => '采购订单',   'url' => 'PSI/purchase_orders'],
                ['k' => 'outbounds',       'label' => '出库单',     'url' => 'PSI/outbounds'],
                ['k' => 'reports',         'label' => '报表中心',   'url' => 'PSI/reports'],
                ['k' => 'reminders',       'label' => '紧急提醒',   'url' => 'PSI/reminders'],
            ]
            : [
                ['k' => 'dashboard', 'label' => '仪表盘',     'url' => 'CRM'],
                ['k' => 'customers', 'label' => '客户管理',   'url' => 'CRM/customers'],
                ['k' => 'leads',     'label' => '商机线索',   'url' => 'CRM/leads'],
                ['k' => 'followups', 'label' => '跟进记录',   'url' => 'CRM/followups'],
                ['k' => 'contacts',  'label' => '客户联系人', 'url' => 'CRM/contacts'],
            ];
        // 仅该系统管理员可管理本系统用户
        if (subsys_admin($sys)) {
            $pages[] = ['k' => 'users', 'label' => '用户管理', 'url' => strtoupper($sys) . '/users'];
            if ($sys === 'psi') {
                $pages[] = ['k' => 'notifications', 'label' => '通知设置', 'url' => 'PSI/notifications'];
            }
        }
        // 仅主角色为超管/管理员时显示「管理后台」入口
        if (in_array(admin_role(), ['super_admin', 'admin'], true)) {
            $pages[] = ['k' => 'admin', 'label' => '管理后台', 'url' => 'admin'];
        }
        return $pages;
    }

    /** PSI 系统完整导航（含订单/出库/报表，所有控制器统一调用） */
    function psi_nav(): array
    {
        return [
            ['k' => 'dashboard',       'label' => '仪表盘',   'icon' => admin_icon('home', 16) ?: '📊', 'url' => 'PSI'],
            ['k' => 'materials',       'label' => '物料管理', 'icon' => admin_icon('box', 16) ?: '🧵', 'url' => 'PSI/materials'],
            ['k' => 'products',        'label' => '成品管理', 'icon' => admin_icon('shirt', 16) ?: '👕', 'url' => 'PSI/products'],
            ['k' => 'suppliers',       'label' => '供应商',   'icon' => admin_icon('buildings', 16) ?: '🏭', 'url' => 'PSI/suppliers'],
            ['k' => 'purchases',       'label' => '采购入库', 'icon' => admin_icon('download', 16) ?: '📥', 'url' => 'PSI/purchases'],
            ['k' => 'sales',           'label' => '销售出库', 'icon' => admin_icon('upload', 16) ?: '📤', 'url' => 'PSI/sales'],
            ['k' => 'stock',           'label' => '库存流水', 'icon' => admin_icon('package', 16) ?: '📦', 'url' => 'PSI/stock'],
            ['k' => 'orders',          'label' => '订单管理', 'icon' => admin_icon('receipt', 16) ?: '🧾', 'url' => 'PSI/orders'],
            ['k' => 'sales_orders',    'label' => '销售订单', 'icon' => admin_icon('file-text', 16) ?: '📝', 'url' => 'PSI/sales_orders'],
            ['k' => 'purchase_orders', 'label' => '采购订单', 'icon' => admin_icon('file-text', 16) ?: '📋', 'url' => 'PSI/purchase_orders'],
            ['k' => 'outbounds',       'label' => '出库单',   'icon' => admin_icon('truck', 16) ?: '🚚', 'url' => 'PSI/outbounds'],
            ['k' => 'reports',         'label' => '报表中心', 'icon' => admin_icon('chart-bar', 16) ?: '📊', 'url' => 'PSI/reports'],
        ];
    }

    /** 生成单据号：前缀 + 秒级时间 + 进程内计数器 + 随机，保证唯一 */
    function psi_gen_no(string $prefix): string
    {
        static $c = 0;
        $c++;
        return strtoupper($prefix) . date('YmdHis') . str_pad($c, 4, '0', STR_PAD_LEFT) . mt_rand(10, 99);
    }

    /** 根据已交付数量重算销售订单状态（pending/partial/delivered） */
    function psi_recompute_so(int $soId): void
    {
        $items = (new \App\Models\PSI\SalesOrderItem())->whereAll('so_id', $soId);
        $total = 0; $delivered = 0;
        foreach ($items as $it) {
            $total     += (int)($it['qty'] ?? 0);
            $delivered += (int)($it['delivered_qty'] ?? 0);
        }
        $status = $total <= 0 ? 'pending' : ($delivered >= $total ? 'delivered' : ($delivered > 0 ? 'partial' : 'pending'));
        (new \App\Models\PSI\SalesOrder())->update($soId, ['status' => $status]);
    }

    /** 打印页外壳：独立 HTML + A4 样式 + 自动打印 */
    function psi_print_shell(string $title, string $body): string
    {
        $css = 'body{font-family:-apple-system,"Microsoft YaHei",sans-serif;color:#111;margin:0;padding:24px;background:#fff;}'
            . '.doc{width:210mm;max-width:100%;margin:0 auto;}'
            . '@media print{body{padding:0;}.no-print{display:none!important;}@page{margin:12mm;}}'
            . '.doc h2{text-align:center;margin:0 0 4px;font-size:20px;}'
            . '.doc .sub{text-align:center;color:#666;margin-bottom:16px;font-size:13px;}'
            . '.doc .meta{display:flex;flex-wrap:wrap;gap:6px 28px;font-size:13px;margin:12px 0;border-bottom:1px dashed #ccc;padding-bottom:10px;}'
            . '.doc .meta b{color:#374151;}'
            . '.doc table{border-collapse:collapse;width:100%;font-size:13px;margin-top:8px;}'
            . '.doc th,.doc td{border:1px solid #bbb;padding:7px 9px;}'
            . '.doc th{background:#f3f4f6;}'
            . '.doc .total{text-align:right;font-weight:700;margin-top:12px;font-size:14px;}'
            . '.doc .sign{display:flex;justify-content:space-between;margin-top:40px;font-size:13px;color:#374151;}'
            . '.btn-print{position:fixed;top:16px;right:16px;padding:10px 18px;border-radius:8px;border:1px solid #0ea5e9;background:#0ea5e9;color:#fff;cursor:pointer;font-size:14px;box-shadow:0 2px 8px rgba(0,0,0,.15);}';
        return '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>' . e($title) . '</title>'
            . '<style>' . $css . '</style></head><body>'
            . '<button class="btn-print no-print" onclick="window.print()">打印 / 导出 PDF</button>'
            . '<div class="doc">' . $body . '</div>'
            . '<script>window.onload=function(){setTimeout(function(){window.print();},300);};</script>'
            . '</body></html>';
    }

    /** 一次性提示消息（跨重定向，取值后清空） */
    function flash(string $msg, string $type = 'ok'): void
    {
        $_SESSION['_flash'] = ['msg' => $msg, 'type' => $type];
    }
    function flash_html(): string
    {
        if (empty($_SESSION['_flash'])) return '';
        $f = $_SESSION['_flash'];
        unset($_SESSION['_flash']);
        $cls = $f['type'] === 'err' ? 'alert alert-err' : 'alert alert-ok';
        return '<div class="' . $cls . '">' . e($f['msg']) . '</div>';
    }

    /* ---------- mbstring 兼容层：远端 PHP 未启用 mbstring 扩展时提供兜底实现 ---------- */
    if (!function_exists('mb_substr')) {
        function mb_substr(string $str, int $start, ?int $length = null, string $encoding = 'UTF-8'): string
        {
            $chars = preg_split('//u', $str, -1, PREG_SPLIT_NO_EMPTY);
            if ($chars === false) { $chars = preg_split('//', $str, -1, PREG_SPLIT_NO_EMPTY) ?: []; }
            $len = $length ?? count($chars);
            return implode('', array_slice($chars, $start, $len));
        }
    }
    if (!function_exists('mb_strlen')) {
        function mb_strlen(string $str, string $encoding = 'UTF-8'): int
        {
            $chars = preg_split('//u', $str, -1, PREG_SPLIT_NO_EMPTY);
            return $chars === false ? strlen($str) : count($chars);
        }
    }
    if (!function_exists('mb_strtolower')) {
        function mb_strtolower(string $str, string $encoding = 'UTF-8'): string
        {
            return strtolower($str);
        }
    }
    if (!function_exists('mb_strtoupper')) {
        function mb_strtoupper(string $str, string $encoding = 'UTF-8'): string
        {
            return strtoupper($str);
        }
    }
    if (!function_exists('mb_check_encoding')) {
        function mb_check_encoding($var, ?string $encoding = null): bool
        {
            if (is_array($var) || is_object($var)) return false;
            $str = (string)$var;
            if ($encoding === null || strtoupper((string)$encoding) === 'UTF-8') {
                return (bool) preg_match('/\A(?: [\x00-\x7F] | [\xC2-\xDF][\x80-\xBF] | \xE0[\xA0-\xBF][\x80-\xBF] | [\xE1-\xEC\xEE\xEF][\x80-\xBF]{2} | \xED[\x80-\x9F][\x80-\xBF] | \xF0[\x90-\xBF][\x80-\xBF]{2} | [\xF1-\xF3][\x80-\xBF]{3} | \xF4[\x80-\x8F][\x80-\xBF]{2} )*\z/x', $str);
            }
            return true;
        }
    }
    if (!function_exists('mb_convert_encoding')) {
        function mb_convert_encoding(string $str, string $to, ?string $from = null): string
        {
            if (function_exists('iconv')) {
                $conv = @iconv($from ?? 'UTF-8', $to . '//IGNORE', $str);
                if ($conv !== false) return $conv;
            }
            return $str;
        }
    }

    /**
     * 内联 SVG 图标（Lucide 线性风格，stroke=currentColor）。
     * 取代后台原有 emoji 图标：清晰、随主题变色、跨平台一致。
     */
    function admin_icon(string $name, int $size = 20): string
    {
        static $paths = null;
        if ($paths === null) {
            $paths = [
                'dashboard' => '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
                'snowflake' => '<line x1="2" x2="22" y1="12" y2="12"/><line x1="12" x2="12" y1="2" y2="22"/><path d="m20 16-4-4 4-4"/><path d="m4 8 4 4-4 4"/><path d="m16 4-4 4-4-4"/><path d="m8 20 4-4 4 4"/>',
                'folders'   => '<path d="M8 17h12a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3.9a2 2 0 0 1-1.69-.9l-.81-1.2a2 2 0 0 0-1.67-.9H8a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2Z"/><path d="M2 8v11a2 2 0 0 0 2 2h14"/>',
                'newspaper' => '<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/><path d="M18 14h-8"/><path d="M15 18h-5"/><path d="M10 6h8v4h-8V6Z"/>',
                'handshake' => '<path d="m11 17 2 2a1 1 0 1 0 3-3"/><path d="m14 14 2.5 2.5a1 1 0 1 0 3-3l-3.88-3.88a3 3 0 0 0-4.24 0l-.88.88a1 1 0 1 1-3-3l2.81-2.81a5.79 5.79 0 0 1 7.06-.87l.47.28a2 2 0 0 0 1.42.25L21 4"/><path d="m21 3 1 11h-2"/><path d="M3 3 2 14l6.5 6.5a1 1 0 1 0 3-3"/><path d="M3 4h8"/>',
                'file-text' => '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>',
                'images'    => '<path d="M18 22H4a2 2 0 0 1-2-2V6"/><path d="m22 13-1.296-1.296a2.41 2.41 0 0 0-3.408 0L11 18"/><circle cx="12" cy="8" r="2"/><rect width="16" height="16" x="6" y="2" rx="2"/>',
                'settings'  => '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
                'palette'   => '<circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/>',
                'users'     => '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
                'receipt'   => '<path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 17.5v-11"/>',
                'wallet'    => '<path d="M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1"/><path d="M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4"/>',
                'upload'    => '<circle cx="12" cy="12" r="10"/><path d="m16 12-4-4-4 4"/><path d="M12 16V8"/>',
                'database'  => '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/>',
                'package'   => '<path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"/><path d="M12 22V12"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="m7.5 4.27 9 5.15"/>',
                'logout'    => '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
                'key'       => '<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>',
                'globe'     => '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
                'menu'      => '<line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/>',
                'plus'      => '<path d="M5 12h14"/><path d="M12 5v14"/>',
                'user'      => '<circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/>',
                'pencil'    => '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>',
                'shield'    => '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
                'lightbulb' => '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>',
                'phone'     => '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/>',
                'shopping-bag' => '<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/>',
                'truck'     => '<path d="M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2"/><path d="M15 18H9"/><path d="M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.624l-3.48-4.35A1 1 0 0 0 17.52 8H14"/><circle cx="17" cy="18" r="2"/><circle cx="7" cy="18" r="2"/>',
                'inbox'     => '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
                'cart'      => '<circle cx="8" cy="21" r="1"/><circle cx="19" cy="21" r="1"/><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"/>',
                'user-plus' => '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/>',
                'key'       => '<path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>',
                'lock'      => '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
                'bar-chart' => '<line x1="12" y1="20" x2="12" y2="10"/><line x1="18" y1="20" x2="18" y2="4"/><line x1="6" y1="20" x2="6" y2="16"/><line x1="3" y1="20" x2="21" y2="20"/>',
                'printer'   => '<path d="M6 9V2h12v7"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8" rx="1"/>',
                'check'     => '<path d="M20 6 9 17l-5-5"/>',
                'clipboard' => '<rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>',
                'trending-up' => '<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/>',
                'alert'     => '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
            ];
        }
        $p = $paths[$name] ?? $paths['file-text'];
        return '<svg class="li li-' . e($name) . '" width="' . $size . '" height="' . $size . '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' . $p . '</svg>';
    }

    /* ---------- 数据库升级：升级包目录与待升级计数 ---------- */

    /** 升级包目录（放置 *.sql 后，后台「数据库升级」即提示可升级） */
    function db_upgrade_dir(): string
    {
        return BASE_PATH . '/install/upgrades';
    }

    /** 将字节数格式化为人类可读大小 */
    function human_size(int $bytes): string
    {
        if ($bytes < 1024) return $bytes . ' B';
        $units = ['KB', 'MB', 'GB', 'TB'];
        $i = -1;
        do { $bytes /= 1024; $i++; } while ($bytes >= 1024 && $i < count($units) - 1);
        return round($bytes, 2) . ' ' . $units[$i];
    }

    /**
     * 统计待升级的 SQL 文件数量（未记录或内容已变更）。
     * 用于在导航上提示「可升级」。失败安全：任何异常都返回 0。
     */
    function db_pending_upgrades(): int
    {
        try {
            if (\Core\Db::driver() !== 'mysql') return 0;
            $dir = db_upgrade_dir();
            if (!is_dir($dir)) return 0;
            $files = glob($dir . '/*.sql') ?: [];
            if (!$files) return 0;
            \Core\Installer::ensureUpgradeLog();
            $applied = \Core\Db::query("SELECT file, hash FROM db_upgrades")->fetchAll(\PDO::FETCH_KEY_PAIR);
            $n = 0;
            foreach ($files as $f) {
                $name = basename($f);
                $h = md5_file($f);
                if (!isset($applied[$name]) || $applied[$name] !== $h) {
                    $n++;
                }
            }
            return $n;
        } catch (\Throwable $e) {
            return 0;
        }
    }

    /* ---------- 安全响应头（质量红线：所有后台/API 统一应用） ---------- */
    /** 生成每次请求唯一的 CSP nonce（同请求内多次调用返回同一值，并去除 base64 填充符以兼容 CSP） */
    function csp_nonce(): string
    {
        static $n;
        if ($n === null) {
            $n = rtrim(base64_encode(random_bytes(16)), '=');
        }
        return $n;
    }

    function apply_security_headers(): void
    {
        if (headers_sent()) return;
        $nonce = csp_nonce();
        // 通用安全响应头（HSTS / X-Frame-Options / X-Content-Type-Options 等）已统一在
        // Nginx 服务器层下发（含静态资源），无需在此重复。
        // 此处仅补充依赖动态随机数的「严格 CSP」——nonce 每次请求不同，必须走 PHP。
        header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'");
    }

    /** 当前请求的完整绝对 URL（用于 canonical 规范链接等） */
    function absolute_url(): string
    {
        $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
        $host = $_SERVER['HTTP_HOST'] ?? ($_SERVER['SERVER_NAME'] ?? 'localhost');
        $uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
        return $scheme . '://' . $host . $uri;
    }

    /* ---------- 图片验证码（GD 库，无第三方依赖，防机器人暴力/垃圾提交） ---------- */

    /** 生成验证码字符串并存入 session，返回图片 URL */
    function captcha_make(): array
    {
        if (session_status() !== PHP_SESSION_ACTIVE) @session_start();
        // 排除易混淆字符：0/O、1/I/L、2/Z
        $pool = '3456789ABCDEFGHJKMNPQRSTUVWXY';
        $code = '';
        for ($i = 0; $i < 4; $i++) {
            $code .= $pool[random_int(0, strlen($pool) - 1)];
        }
        $_SESSION['captcha_code'] = $code;
        // 添加随机参数防浏览器缓存同名图片
        return ['url' => site_url('captcha/image') . '?_t=' . dechex(time() . random_int(1000, 9999))];
    }

    function captcha_check($input): bool
    {
        if (session_status() !== PHP_SESSION_ACTIVE) @session_start();
        $ok = isset($_SESSION['captcha_code'])
            && is_string($input)
            && strtoupper(trim($input)) === strtoupper($_SESSION['captcha_code']);
        unset($_SESSION['captcha_code']); // 一次性，防重放
        return $ok;
    }

    /** 输出验证码图片（由 App 路由调用） */
    function captcha_image(): void
    {
        if (session_status() !== PHP_SESSION_ACTIVE) @session_start();
        $code = $_SESSION['captcha_code'] ?? '';
        if (empty($code)) {
            // 无有效 code 时生成一个默认的，避免空白图
            $code = 'XXXX';
        }

        $w = 130;
        $h = 44;
        $img = imagecreatetruecolor($w, $h);
        if (!$img) {
            http_response_code(500);
            exit('验证码图片生成失败');
        }

        // ── 背景 ──
        $bg = imagecolorallocate($img, 248, 250, 252);
        imagefilledrectangle($img, 0, 0, $w, $h, $bg);

        // ── 干扰线（5 条随机弧线）────
        $colors = [];
        for ($i = 0; $i < 8; $i++) {
            $colors[] = imagecolorallocate($img,
                random_int(140, 210),
                random_int(140, 210),
                random_int(160, 220)
            );
        }
        for ($i = 0; $i < 5; $i++) {
            $c = $colors[random_int(0, count($colors) - 1)];
            imageline($img,
                random_int(0, $w), random_int(0, $h),
                random_int(0, $w), random_int(0, $h),
                $c
            );
        }

        // ── 干扰像素点 ──
        for ($i = 0; $i < 80; $i++) {
            $c = $colors[random_int(0, count($colors) - 1)];
            imagesetpixel($img, random_int(0, $w), random_int(0, $h), $c);
        }

        // ── 文字（每个字符独立颜色、角度、位置）────
        $len = strlen($code);
        $cx = 15;
        $cy = 30;
        $fontFile = BASE_PATH . '/public/assets/arial.ttf'; // 可选 TTF，若无则 fallback

        $hasTtf = is_file($fontFile);
        $dark = imagecolorallocate($img, 25, 55, 100);

        for ($i = 0; $i < $len; $i++) {
            $char = $code[$i];
            $textColor = imagecolorallocate($img,
                random_int(20, 80),
                random_int(40, 100),
                random_int(80, 160)
            );

            if ($hasTtf) {
                $size = random_int(18, 22);
                $angle = random_int(-15, 15);
                $x = $cx + ($i * ($w - 20) / $len);
                $y = $cy + random_int(-4, 6);
                imagettftext($img, $size, $angle, (int)$x, (int)$y, $textColor, $fontFile, $char);
            } else {
                // 无 TTF 字体时用内置字体（效果较差但仍可工作）
                $fontSize = 5;
                $x = $cx + ($i * ($w - 20) / $len) + random_int(-2, 2);
                $y = 12 + random_int(-3, 3);
                imagestring($img, $fontSize, (int)$x, (int)$y, $char, $textColor);
            }
        }

        // ── 输出 ──
        if (ob_get_level() > 0) ob_clean();
        header('Content-Type: image/png');
        header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
        header('Pragma: no-cache');
        header('Expires: 0');
        imagepng($img);
        imagedestroy($img);
        exit;
    }

    /* ---------- IP 级失败限速（fail2ban 式，文件缓存，越会话更抗爆破） ---------- */
    function ip_login_blocked(string $ip): bool
    {
        $file = BASE_PATH . '/storage/login_ip.json';
        if (!is_file($file)) return false;
        $data = json_decode(@file_get_contents($file), true) ?: [];
        $now = time();
        if (!isset($data[$ip])) return false;
        return $data[$ip]['count'] >= 8;
    }
    function ip_login_register(string $ip): void
    {
        $file = BASE_PATH . '/storage/login_ip.json';
        if (!is_dir(dirname($file))) @mkdir(dirname($file), 0755, true);
        $data = is_file($file) ? (json_decode(@file_get_contents($file), true) ?: []) : [];
        $now = time();
        if (!isset($data[$ip]) || ($data[$ip]['time'] + 900) < $now) {
            $data[$ip] = ['count' => 0, 'time' => $now];
        }
        $data[$ip]['count']++;
        @file_put_contents($file, json_encode($data));
    }
    function ip_login_clear(string $ip): void
    {
        $file = BASE_PATH . '/storage/login_ip.json';
        if (!is_file($file)) return;
        $data = json_decode(@file_get_contents($file), true) ?: [];
        unset($data[$ip]);
        @file_put_contents($file, json_encode($data));
    }

    /* ---------- 通用 IP 级限速（可用于任意提交场景，如联系表单） ---------- */
    function ip_rate_blocked(string $ip, string $bucket, int $limit, int $window): bool
    {
        $file = BASE_PATH . '/storage/rate_' . $bucket . '.json';
        if (!is_file($file)) return false;
        $data = json_decode(@file_get_contents($file), true) ?: [];
        if (!isset($data[$ip])) return false;
        return $data[$ip]['count'] >= $limit;
    }
    function ip_rate_register(string $ip, string $bucket, int $window): void
    {
        $file = BASE_PATH . '/storage/rate_' . $bucket . '.json';
        if (!is_dir(dirname($file))) @mkdir(dirname($file), 0755, true);
        $data = is_file($file) ? (json_decode(@file_get_contents($file), true) ?: []) : [];
        $now = time();
        if (!isset($data[$ip]) || ($data[$ip]['time'] + $window) < $now) {
            $data[$ip] = ['count' => 0, 'time' => $now];
        }
        $data[$ip]['count']++;
        @file_put_contents($file, json_encode($data));
    }
}
